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). ![LiveDeck example](docs/livedeck.png) 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)} - 0} - class="progress progress-accent absolute bottom-0 w-full" - value={@slide} - max={@page_count - 1} +
- + {raw(content.slide)} + 0} + class="progress progress-accent h-[1cqw] absolute bottom-0 w-full" + value={@slide} + max={@page_count - 1} + > + +
-
-
-
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