diff --git a/README.md b/README.md
index f0954c0..085e528 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ As a markdown parser, [Earmark](https://github.com/pragdave/earmark) is used bec
For styling, [daisyUI](https://github.com/saadeghi/daisyui) allows easy change of themes and creation of your own style using [Tailwind](https://github.com/tailwindlabs/tailwindcss). The site is fully responsive for mobile and desktop resolutions, and supports themes for dark and light mode. Code blocks are automatically highlighted using [highlight.js](https://github.com/highlightjs/highlight.js).
-With [LiveDeck](priv/pages/LiveDeck.md), AlchemyPub comes with an interactive presentation framework. It can turn any markdown page into a dynamic, interactive presentation.
+With [LiveDeck](priv/pages/LiveDeck.md), AlchemyPub comes with an interactive presentation framework. It can turn any markdown page into a dynamic, interactive presentation. You can see an example of [LiveDeck running here](https://j4nk.dev/livedeck).

diff --git a/lib/alchemy_pub/deck_server.ex b/lib/alchemy_pub/deck_server.ex
index 81c0a25..3818372 100644
--- a/lib/alchemy_pub/deck_server.ex
+++ b/lib/alchemy_pub/deck_server.ex
@@ -1,5 +1,5 @@
defmodule AlchemyPub.DeckState do
- defstruct name: "", slide: 0, pages: 0
+ defstruct name: "", slide: 0, pages: 0, mute: false
end
defmodule AlchemyPub.DeckSupervisor do
@@ -41,12 +41,12 @@ defmodule AlchemyPub.DeckServer do
GenServer.call({:via, Registry, {AlchemyPub.Registry, name}}, {:page, page})
end
- def get_page(name) do
- GenServer.call({:via, Registry, {AlchemyPub.Registry, name}}, :get_page)
+ def set_mute(name, mute) do
+ GenServer.call({:via, Registry, {AlchemyPub.Registry, name}}, {:mute, mute})
end
- def get_count(name) do
- GenServer.call({:via, Registry, {AlchemyPub.Registry, name}}, :get_count)
+ def get_state(name) do
+ GenServer.call({:via, Registry, {AlchemyPub.Registry, name}}, :get_state)
end
@impl true
@@ -62,12 +62,14 @@ defmodule AlchemyPub.DeckServer do
end
@impl true
- def handle_call(:get_page, _from, state) do
- {:reply, state.slide, state}
+ def handle_call({:mute, mute}, _from, state) do
+ new_state = %{state | mute: mute}
+ PubSub.broadcast(AlchemyPub.PubSub, "deck_state", new_state)
+ {:reply, new_state.slide, new_state}
end
@impl true
- def handle_call(:get_count, _from, state) do
- {:reply, state.pages, state}
+ def handle_call(:get_state, _from, state) do
+ {:reply, state, state}
end
end
diff --git a/lib/alchemy_pub/engine.ex b/lib/alchemy_pub/engine.ex
index 17d1354..462b155 100644
--- a/lib/alchemy_pub/engine.ex
+++ b/lib/alchemy_pub/engine.ex
@@ -32,7 +32,11 @@ defmodule AlchemyPub.Engine do
nil
end
- defp compile(md, is_deck) do
+ defp has_notes({"code", [{"class", "notes"}], _, _}), do: true
+ defp has_notes({_, _, content, _}) when is_list(content), do: Enum.any?(content, &has_notes/1)
+ defp has_notes(_), do: false
+
+ defp compile(md, false) do
link_headers = fn {h, attrs, content, meta} ->
id = urlify(content)
@@ -51,10 +55,6 @@ defmodule AlchemyPub.Engine do
], meta}}
end
- page_split = fn {hr, _attrs, content, meta} ->
- {:replace, {hr, [{"class", "split"}], content, meta}}
- end
-
ast =
Earmark.as_ast!(md,
pure_links: false,
@@ -64,16 +64,9 @@ defmodule AlchemyPub.Engine do
title = find_h1(ast)
- processors =
- if is_deck do
- [{"hr", page_split}]
- else
- [{"h2", link_headers}, {"h3", link_headers}]
- end
-
postprocessor =
Earmark.Transform.make_postprocessor(
- Earmark.Options.make_options!(registered_processors: processors)
+ Earmark.Options.make_options!(registered_processors: [{"h2", link_headers}, {"h3", link_headers}])
)
content =
@@ -81,14 +74,54 @@ defmodule AlchemyPub.Engine do
|> Earmark.Transform.map_ast(postprocessor)
|> Earmark.transform()
- content =
- if is_deck do
- content |> String.split("
")
+ {title, content}
+ end
+
+ defp compile(md, true) do
+ page_split = fn {hr, _attrs, content, meta} ->
+ {:replace, {hr, [{"class", "split"}], content, meta}}
+ end
+
+ strip_notes = fn node ->
+ if has_notes(node) do
+ {:replace, []}
else
- content
+ node
end
+ end
- {title, content}
+ ast =
+ Earmark.as_ast!(md,
+ pure_links: false,
+ wikilinks: true,
+ gfm_tables: true
+ )
+
+ title = find_h1(ast)
+
+ processors = [{"hr", page_split}]
+
+ public_post =
+ Earmark.Transform.make_postprocessor(
+ Earmark.Options.make_options!(registered_processors: [{"pre", strip_notes} | processors])
+ )
+
+ private_post =
+ Earmark.Transform.make_postprocessor(Earmark.Options.make_options!(registered_processors: processors))
+
+ public =
+ ast
+ |> Earmark.Transform.map_ast(public_post)
+ |> Earmark.transform()
+ |> String.split("
")
+
+ private =
+ ast
+ |> Earmark.Transform.map_ast(private_post)
+ |> Earmark.transform()
+ |> String.split("
")
+
+ {title, Enum.zip(public, private)}
end
def title(filename, h1, frontmatter) do
@@ -103,8 +136,14 @@ defmodule AlchemyPub.Engine do
def find_date(date) do
case Date.from_iso8601(date) do
- {:ok, d} -> :ets.match(@ets, {:"$1", :_, d, :_, :_}) |> List.first()
- _ -> nil
+ {:ok, d} ->
+ for [title, meta] <- :ets.match(@ets, {:"$1", :_, d, :"$2", :_}), !meta["hidden"] do
+ [title]
+ end
+ |> List.first()
+
+ _ ->
+ nil
end
end
diff --git a/lib/alchemy_pub_web/page_live.ex b/lib/alchemy_pub_web/page_live.ex
index 3154e9c..4d1d9a4 100644
--- a/lib/alchemy_pub_web/page_live.ex
+++ b/lib/alchemy_pub_web/page_live.ex
@@ -46,8 +46,10 @@ defmodule AlchemyPubWeb.PageLive do
follow: false,
page_count: nil,
fullscreen: false,
+ preview: nil,
mute: false,
all_pages: false,
+ has_next: false,
admin: admin
)
|> stream_configure(:deck, [])
@@ -68,19 +70,18 @@ defmodule AlchemyPubWeb.PageLive do
end
defp apply_keypress(param, assigns) do
- %{a: a, f: f, m: m} = params = get_params(assigns)
+ %{a: a, f: f} = params = get_params(assigns)
case param["key"] do
- "a" -> %{params | a: not a}
- "f" -> %{params | f: not f}
- "m" -> %{params | m: not m}
+ "a" -> %{params | a: not a, f: f && a}
+ "f" -> %{params | f: not f, a: a && f}
"Home" -> %{params | p: 0}
"End" when assigns.admin -> %{params | p: assigns.page_count}
"End" -> %{params | p: -1}
"ArrowRight" -> %{params | p: assigns.slide + 1}
"ArrowLeft" -> %{params | p: max(0, assigns.slide - 1)}
"Escape" -> %{params | f: false}
- _ -> params
+ _ -> nil
end
end
@@ -93,9 +94,23 @@ defmodule AlchemyPubWeb.PageLive do
end
f = get_in(assigns.fullscreen) || false
- m = get_in(assigns.mute) || false
a = get_in(assigns.all_pages) || false
- %{a: a, p: p, f: f, m: m}
+ %{a: a, p: p, f: f}
+ end
+
+ @impl true
+ def handle_event("key", %{"key" => "m"}, socket) do
+ params = get_params(socket.assigns)
+ mute = !socket.assigns.mute
+ DeckServer.set_mute(socket.assigns.title, mute)
+
+ {:noreply,
+ socket
+ |> assign(mute: mute)
+ |> push_patch(
+ to: "/#{socket.assigns.title}?#{URI.encode_query(params)}",
+ replace: true
+ )}
end
@impl true
@@ -106,7 +121,7 @@ defmodule AlchemyPubWeb.PageLive do
if params && socket.assigns.meta["rank"] == :deck do
socket
|> push_patch(
- to: "/#{socket.assigns.title}?#{URI.encode_query(params)}",
+ to: "/#{socket.assigns.title}?#{URI.encode_query(params)}#slide-#{socket.assigns.slide}",
replace: true
)
else
@@ -118,7 +133,7 @@ defmodule AlchemyPubWeb.PageLive do
@impl true
def handle_event("jump", %{"slide" => num}, socket) do
- params = get_params(socket.assigns) |> Map.merge(%{p: num, f: true, a: false})
+ params = get_params(socket.assigns) |> Map.merge(%{p: num, a: false})
socket =
socket
@@ -228,29 +243,34 @@ defmodule AlchemyPubWeb.PageLive do
true ->
visible_pages = pages |> filter_hidden()
- {visible_pages |> filter_rank(), posts |> filter_hidden(),
- visible_pages |> filter_rank(:deck)}
+ {visible_pages |> filter_rank(), posts |> filter_hidden(), visible_pages |> filter_rank(:deck)}
false ->
visible_pages = pages |> filter_robot()
- {visible_pages |> filter_rank(), posts |> filter_robot(),
- visible_pages |> filter_rank(:deck)}
+ {visible_pages |> filter_rank(), posts |> filter_robot(), visible_pages |> filter_rank(:deck)}
end
socket |> assign(posts: posts, pages: pages, decks: decks, tags: tags, params: params)
end
defp build_deck(socket, params, {title, meta, content}) do
- page_count = DeckServer.get_count(title)
- %{admin: admin, slide: slide} = socket.assigns
- {slide, direction} = match_slide(params, title, slide, page_count, admin)
+ %{pages: page_count, mute: mute, slide: admin_slide} = DeckServer.get_state(title)
+ admin = socket.assigns.admin
+ slide = match_slide(params, title, page_count, admin)
fullscreen = params["f"] == "true"
- mute = params["m"] == "true"
all_pages = params["a"] == "true" && admin
follow = params["p"] == "-1" && !admin
+ preview =
+ cond do
+ all_pages || !admin -> nil
+ slide == page_count -> %{slide: "last"}
+ mute && admin -> paginate(content, slide, false, true)
+ true -> paginate(content, slide + 1, mute, true)
+ end
+
socket =
socket
|> assign(
@@ -264,14 +284,21 @@ defmodule AlchemyPubWeb.PageLive do
follow: follow,
fullscreen: fullscreen && !all_pages,
mute: mute && !all_pages,
+ preview: preview,
all_pages: all_pages,
- page_count: page_count
+ page_count: page_count,
+ has_next:
+ if admin do
+ slide < page_count - 1
+ else
+ slide < admin_slide
+ end
)
if all_pages do
content
|> Enum.with_index()
- |> Enum.reduce(socket, fn {p, num}, socket ->
+ |> Enum.reduce(socket, fn {{p, _}, num}, socket ->
stream_insert(
socket,
:deck,
@@ -286,7 +313,13 @@ defmodule AlchemyPubWeb.PageLive do
)
end)
else
- socket |> stream_insert(:deck, paginate(content, slide, direction, mute), at: -1, limit: -2)
+ socket
+ |> stream_insert(
+ :deck,
+ paginate(content, slide, mute, false, "opacity-0 saturate-20"),
+ at: -1,
+ limit: -2
+ )
end
end
@@ -309,22 +342,19 @@ defmodule AlchemyPubWeb.PageLive do
end)
end
- defp paginate(deck, page, direction, mute) do
- animation =
- case direction do
- :left -> "-translate-x-1/4 scale-90 opacity-0"
- :right -> "translate-x-1/4 scale-110 opacity-0"
- nil -> ""
- end
+ defp paginate(deck, page, mute, preview, animation \\ "opacity-0") do
+ slide = deck |> Enum.at(page)
%{
id: "deck-page-#{Ulid.generate()}",
num: page,
slide:
- if mute do
- ""
- else
- deck |> Enum.at(page)
+ cond do
+ mute -> ""
+ is_tuple(slide) && preview -> slide |> elem(1)
+ is_tuple(slide) -> slide |> elem(0)
+ preview -> "end of slides"
+ true -> slide
end,
animation:
if mute do
@@ -358,33 +388,22 @@ defmodule AlchemyPubWeb.PageLive do
"Tag not found"
end
- defp match_slide(%{"p" => p}, title, prev_page, page_count, admin) do
+ defp match_slide(%{"p" => p}, title, page_count, admin) do
page =
case Integer.parse(p) do
{page, _} -> page
_ -> 0
end
- page =
- case {admin, page} do
- {true, -1} -> DeckServer.set_page(title, page_count)
- {true, p} -> DeckServer.set_page(title, p)
- {_, -1} -> DeckServer.get_page(title)
- {_, p} -> min(p, DeckServer.get_page(title))
- end
-
- direction =
- cond do
- prev_page == nil -> nil
- prev_page > page -> :left
- prev_page < page -> :right
- true -> nil
- end
-
- {page, direction}
+ case {admin, page} do
+ {true, -1} -> DeckServer.set_page(title, page_count)
+ {true, p} -> DeckServer.set_page(title, p)
+ {_, -1} -> DeckServer.get_state(title).slide
+ {_, p} -> min(p, DeckServer.get_state(title).slide)
+ end
end
- defp match_slide(_, _, _, _, _), do: {0, nil}
+ defp match_slide(_, _, _, _), do: 0
defp match_params(params, pages) do
case params do
@@ -413,6 +432,6 @@ defmodule AlchemyPubWeb.PageLive do
end
defp animation(classes) do
- JS.transition({"ease-in-out duration-150", classes, "opacity-100 scale-100 translate-0"})
+ JS.transition({"ease-in-out duration-300", classes, "opacity-100 scale-100 translate-0"})
end
end
diff --git a/lib/alchemy_pub_web/page_live.html.heex b/lib/alchemy_pub_web/page_live.html.heex
index c5bb0d0..b555d10 100644
--- a/lib/alchemy_pub_web/page_live.html.heex
+++ b/lib/alchemy_pub_web/page_live.html.heex
@@ -110,7 +110,7 @@
-
+
{raw(@content)}
-
- "flex flex-col md:w-[34rem] lg:min-w-[42rem] print:min-w-[50rem] deck"
+
+
+ "flex flex-col md:w-136 lg:min-w-2xl print:min-w-200 deck"
- @fullscreen ->
- "absolute top-0 left-0 w-full h-full z-1 deck overflow-hidden"
+ @fullscreen ->
+ "absolute top-0 left-0 w-full h-full z-1 deck overflow-hidden"
- true ->
- "relative md:w-[34rem] lg:min-w-[42rem] print:min-w-[50rem] aspect-16/10 deck"
- end
- }
- phx-window-keyup="key"
- phx-hook
- >
-
+ "relative md:w-136 lg:min-w-2xl print:min-w-200 aspect-video deck"
+ end
+ }
+ phx-window-keyup="key"
+ phx-hook
>
- {raw(content.slide)}
-
-
-
-
Prev
-
- Follow
+
+
Prev
+
+ Follow
+
+
+ All
+
+
+ Next
+
-
- All
+
+
+
+ {raw(@preview.slide)}
+
-
Next
diff --git a/mix.exs b/mix.exs
index 0570029..18c7b1b 100644
--- a/mix.exs
+++ b/mix.exs
@@ -4,7 +4,7 @@ defmodule AlchemyPub.MixProject do
def project do
[
app: :alchemy_pub,
- version: "1.0.0",
+ version: "1.1.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
diff --git a/priv/pages/LiveDeck.md b/priv/pages/LiveDeck.md
index d1fccbf..05152da 100644
--- a/priv/pages/LiveDeck.md
+++ b/priv/pages/LiveDeck.md
@@ -43,6 +43,17 @@ Becoming an Admin
3. Navigate back to your page.
4. An "Admin" badge will appear in the menu, confirming your status for the current session.
+### Preview
+
+- Preview: The next slide will be displayed below the current slide.
+- Speaker notes: Code blocks with the `notes` class will be hidden from the slides, but are visible in the preview. You can use them to put your speaker notes on the next slide. Example:
+
+ ~~~markdown
+ ```notes
+ Put your speaker notes here
+ ```
+ ~~~
+
### Presentation Control
As an admin, you control the presentation for all viewers. Non-admin visitors can only navigate up to the slide you are currently on.
@@ -54,6 +65,7 @@ As an admin, you control the presentation for all viewers. Non-admin visitors ca
### Slide Overview
Press the `A` key or the All button to see a complete overview of all slides in your deck. This is useful for quickly jumping to a specific slide. This is only possible as an Admin.
+- Navigation: Clicking a slide in the overview will jump directly to this slide.
- Printing: From the slide overview screen (press `A`), use your browser's print function (`Ctrl+P` or `Cmd+P`) to generate a print-friendly layout or PDF of all your slides.
## ⌨️ General Features & Navigation