neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Add a frame mode to embeds and keep cursors out of the fit
0b76ad1 · Gabriel Morell · 2026-10-13 14:26
Message
{commit_body(@commit)}
Files changed
modified
README.md
+3
−1
@@ -16,7 +16,9 @@ site. Phoenix LiveView on top of PostgreSQL, with live multi-user editing.
| 16 | 16 | - **Sharing & permissions** — role-based grants per folder/canvas, plus |
| 17 | 17 | one-off share links (`/s/:token`). |
| 18 | 18 | - **Embeds** — publish a canvas read-only via `/embed/:token` (and the |
| 19 | − `/c-embed/:slug` redirect for in-canvas links). | |
| 19 | + `/c-embed/:slug` redirect for in-canvas links). Add `?mode=frame` for a | |
| 20 | + static framing — no live cursors or identity chip, diagram scaled to fill | |
| 21 | + the iframe — suited to slides and docs. | |
| 20 | 22 | - **JSON export** — `/c/:slug/export.json` for any canvas you can see. |
| 21 | 23 | - **Accounts** — magic-link login by default, password optional, with |
| 22 | 24 | invite-gated registration (`/users/invites`). |
modified
lib/diogramos_web/live/canvas_live/embed.ex
+56
−32
@@ -6,7 +6,7 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 6 | 6 | alias DiogramosWeb.CanvasPresence |
| 7 | 7 | |
| 8 | 8 | @impl true |
| 9 | − def mount(%{"token" => token}, _session, socket) do | |
| 9 | + def mount(%{"token" => token} = params, _session, socket) do | |
| 10 | 10 | case Diagrams.get_canvas_for_embed(token) do |
| 11 | 11 | nil -> |
| 12 | 12 | {:ok, |
@@ -25,6 +25,12 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 25 | 25 | |
| 26 | 26 | topic = Authority.topic(canvas.id) |
| 27 | 27 | |
| 28 | + # `?mode=frame` is a static framing of the diagram for slides and | |
| 29 | + # docs: no presence, no cursors, no identity chip, and the camera | |
| 30 | + # fills the frame instead of capping at 1:1. | |
| 31 | + frame = params["mode"] == "frame" | |
| 32 | + show_cursors = canvas.embed_show_cursors and not frame | |
| 33 | + | |
| 28 | 34 | identity = %{ |
| 29 | 35 | key: "embed-" <> Diogramos.ULID.generate(), |
| 30 | 36 | name: CanvasPresence.random_animal_name(), |
@@ -35,7 +41,7 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 35 | 41 | if connected?(socket) do |
| 36 | 42 | Phoenix.PubSub.subscribe(Diogramos.PubSub, topic) |
| 37 | 43 | |
| 38 | − if canvas.embed_show_cursors do | |
| 44 | + if show_cursors do | |
| 39 | 45 | CanvasPresence.track(self(), topic, identity) |
| 40 | 46 | end |
| 41 | 47 | end |
@@ -48,6 +54,8 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 48 | 54 | |> assign(:not_found, false) |
| 49 | 55 | |> assign(:page_title, canvas.title) |
| 50 | 56 | |> assign(:topic, topic) |
| 57 | + |> assign(:frame, frame) | |
| 58 | + |> assign(:show_cursors, show_cursors) | |
| 51 | 59 | |> assign(:peers, []) |
| 52 | 60 | |> assign(:identity, identity)} |
| 53 | 61 | end |
@@ -68,7 +76,7 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 68 | 76 | end |
| 69 | 77 | |
| 70 | 78 | def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do |
| 71 | − if socket.assigns[:canvas] && socket.assigns.canvas.embed_show_cursors do | |
| 79 | + if socket.assigns[:show_cursors] do | |
| 72 | 80 | peers = CanvasPresence.list_peers(socket.assigns.topic, socket.assigns.identity.key) |
| 73 | 81 | {:noreply, assign(socket, :peers, peers)} |
| 74 | 82 | else |
@@ -94,7 +102,7 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 94 | 102 | end |
| 95 | 103 | |
| 96 | 104 | def handle_event("cursor", %{"x" => x, "y" => y}, socket) when is_number(x) and is_number(y) do |
| 97 | − if socket.assigns.canvas && socket.assigns.canvas.embed_show_cursors do | |
| 105 | + if socket.assigns[:show_cursors] do | |
| 98 | 106 | CanvasPresence.update(self(), socket.assigns.topic, socket.assigns.identity.key, fn meta -> |
| 99 | 107 | Map.put(meta, :cursor, %{x: x, y: y}) |
| 100 | 108 | end) |
@@ -108,7 +116,7 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 108 | 116 | defp apply_identity(socket, name, color, opts) do |
| 109 | 117 | identity = %{socket.assigns.identity | name: name, color: color} |
| 110 | 118 | |
| 111 | − if socket.assigns.canvas && socket.assigns.canvas.embed_show_cursors do | |
| 119 | + if socket.assigns[:show_cursors] do | |
| 112 | 120 | CanvasPresence.update(self(), socket.assigns.topic, identity.key, fn meta -> |
| 113 | 121 | meta |> Map.put(:name, name) |> Map.put(:color, color) |
| 114 | 122 | end) |
@@ -147,7 +155,8 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 147 | 155 | <svg |
| 148 | 156 | id="embed-svg" |
| 149 | 157 | phx-hook=".EmbedViewport" |
| 150 | − data-cursors={to_string(@canvas.embed_show_cursors)} | |
| 158 | + data-cursors={to_string(@show_cursors)} | |
| 159 | + data-fit={if @frame, do: "frame", else: "cap"} | |
| 151 | 160 | class="w-screen h-screen select-none" |
| 152 | 161 | xmlns="http://www.w3.org/2000/svg" |
| 153 | 162 | > |
@@ -259,27 +268,31 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 259 | 268 | </defs> |
| 260 | 269 | |
| 261 | 270 | <g id="embed-viewport"> |
| 262 | − <g :for={id <- @document["order"]} id={"el-" <> id}> | |
| 263 | − <.embed_element_node element={@document["elements"][id]} /> | |
| 264 | − <.embed_link_overlay element={@document["elements"][id]} /> | |
| 265 | − </g> | |
| 271 | + <%!-- The fit measures only this group, so cursors drawn alongside | |
| 272 | + it in the same coordinate space never move the camera. --%> | |
| 273 | + <g id="embed-content"> | |
| 274 | + <g :for={id <- @document["order"]} id={"el-" <> id}> | |
| 275 | + <.embed_element_node element={@document["elements"][id]} /> | |
| 276 | + <.embed_link_overlay element={@document["elements"][id]} /> | |
| 277 | + </g> | |
| 266 | 278 | |
| 267 | − <g | |
| 268 | − :for={{cid, connector, render} <- @rendered_connectors} | |
| 269 | − id={"con-" <> cid} | |
| 270 | − > | |
| 271 | − <path | |
| 272 | − d={render.d} | |
| 273 | − fill="none" | |
| 274 | − style={"stroke: #{DiogramosWeb.CanvasLive.Edit.token_var(connector["color"] || "base-content")}; color: #{DiogramosWeb.CanvasLive.Edit.token_var(connector["color"] || "base-content")};"} | |
| 275 | − stroke-width={render.stroke_width} | |
| 276 | − stroke-dasharray={render.dash_array} | |
| 277 | − marker-start={marker_id(render.marker_start)} | |
| 278 | − marker-end={marker_id(render.marker_end)} | |
| 279 | − /> | |
| 279 | + <g | |
| 280 | + :for={{cid, connector, render} <- @rendered_connectors} | |
| 281 | + id={"con-" <> cid} | |
| 282 | + > | |
| 283 | + <path | |
| 284 | + d={render.d} | |
| 285 | + fill="none" | |
| 286 | + style={"stroke: #{DiogramosWeb.CanvasLive.Edit.token_var(connector["color"] || "base-content")}; color: #{DiogramosWeb.CanvasLive.Edit.token_var(connector["color"] || "base-content")};"} | |
| 287 | + stroke-width={render.stroke_width} | |
| 288 | + stroke-dasharray={render.dash_array} | |
| 289 | + marker-start={marker_id(render.marker_start)} | |
| 290 | + marker-end={marker_id(render.marker_end)} | |
| 291 | + /> | |
| 292 | + </g> | |
| 280 | 293 | </g> |
| 281 | 294 | |
| 282 | − <%= if @canvas.embed_show_cursors do %> | |
| 295 | + <%= if @show_cursors do %> | |
| 283 | 296 | <g id="embed-cursors" class="pointer-events-none"> |
| 284 | 297 | <g |
| 285 | 298 | :for={peer <- @peers} |
@@ -316,17 +329,19 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 316 | 329 | this.tx = 0 |
| 317 | 330 | this.ty = 0 |
| 318 | 331 | this.viewport = this.el.querySelector("#embed-viewport") |
| 332 | + this.content = this.el.querySelector("#embed-content") | |
| 333 | + this.lastFitKey = null | |
| 319 | 334 | this.lastCursorAt = 0 |
| 320 | 335 | this.scheduleFit() |
| 321 | 336 | |
| 322 | − this.onResize = () => this.scheduleFit() | |
| 337 | + this.onResize = () => this.scheduleFit(true) | |
| 323 | 338 | this.onPointerMove = (e) => this.onCursorMove(e) |
| 324 | 339 | window.addEventListener("resize", this.onResize) |
| 325 | 340 | |
| 326 | 341 | // Refit when the iframe / parent container resizes — covers the |
| 327 | 342 | // common case where the SVG was 0 px tall during mount. |
| 328 | 343 | if (typeof ResizeObserver !== "undefined") { |
| 329 | − this.resizeObserver = new ResizeObserver(() => this.scheduleFit()) | |
| 344 | + this.resizeObserver = new ResizeObserver(() => this.scheduleFit(true)) | |
| 330 | 345 | this.resizeObserver.observe(this.el) |
| 331 | 346 | } |
| 332 | 347 |
@@ -360,14 +375,18 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 360 | 375 | updated() { |
| 361 | 376 | // Re-fit on document changes pushed via PubSub (new shape inserted, |
| 362 | 377 | // resize, etc) so the camera tracks the diagram's current bbox. |
| 378 | + // Cursor-only patches leave the bbox alone, so fit() skips them. | |
| 363 | 379 | this.scheduleFit() |
| 364 | 380 | }, |
| 365 | − scheduleFit() { | |
| 381 | + scheduleFit(force = false) { | |
| 382 | + this.forceFit = this.forceFit || force | |
| 366 | 383 | if (this.fitScheduled) return |
| 367 | 384 | this.fitScheduled = true |
| 368 | 385 | requestAnimationFrame(() => { |
| 369 | 386 | this.fitScheduled = false |
| 370 | − this.fit() | |
| 387 | + const force = this.forceFit | |
| 388 | + this.forceFit = false | |
| 389 | + this.fit(force) | |
| 371 | 390 | }) |
| 372 | 391 | }, |
| 373 | 392 | onCursorMove(e) { |
@@ -379,15 +398,20 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 379 | 398 | const y = (e.clientY - rect.top - this.ty) / this.scale |
| 380 | 399 | this.pushEvent("cursor", { x, y }) |
| 381 | 400 | }, |
| 382 | − fit() { | |
| 401 | + fit(force = false) { | |
| 383 | 402 | try { |
| 384 | − const bbox = this.viewport.getBBox() | |
| 403 | + const bbox = this.content.getBBox() | |
| 385 | 404 | if (bbox.width === 0 || bbox.height === 0) return |
| 386 | 405 | const rect = this.el.getBoundingClientRect() |
| 406 | + const key = [bbox.x, bbox.y, bbox.width, bbox.height, rect.width, rect.height].join(",") | |
| 407 | + if (!force && key === this.lastFitKey) return | |
| 408 | + this.lastFitKey = key | |
| 387 | 409 | const margin = 24 |
| 388 | 410 | const sx = (rect.width - margin * 2) / bbox.width |
| 389 | 411 | const sy = (rect.height - margin * 2) / bbox.height |
| 390 | − this.scale = Math.min(1, Math.min(sx, sy)) | |
| 412 | + const fill = Math.min(sx, sy) | |
| 413 | + // Frame mode fills the iframe; the default never zooms past 1:1. | |
| 414 | + this.scale = this.el.dataset.fit === "frame" ? fill : Math.min(1, fill) | |
| 391 | 415 | this.tx = -bbox.x * this.scale + (rect.width - bbox.width * this.scale) / 2 |
| 392 | 416 | this.ty = -bbox.y * this.scale + (rect.height - bbox.height * this.scale) / 2 |
| 393 | 417 | this.viewport.setAttribute( |
@@ -400,7 +424,7 @@ defmodule DiogramosWeb.CanvasLive.Embed do
| 400 | 424 | </script> |
| 401 | 425 | </svg> |
| 402 | 426 | |
| 403 | − <%= if @canvas.embed_show_cursors do %> | |
| 427 | + <%= if @show_cursors do %> | |
| 404 | 428 | <details |
| 405 | 429 | id="embed-identity" |
| 406 | 430 | class="dropdown dropdown-top dropdown-end absolute bottom-3 right-3 z-10" |
modified
test/diogramos_web/live/canvas_embed_test.exs
+31
−0
@@ -141,6 +141,37 @@ defmodule DiogramosWeb.CanvasEmbedTest do
| 141 | 141 | assert html =~ "background: #22c55e" |
| 142 | 142 | end |
| 143 | 143 | |
| 144 | + test "frame mode drops cursors, presence and the identity chip", %{ | |
| 145 | + conn: conn, | |
| 146 | + canvas: canvas, | |
| 147 | + owner: owner | |
| 148 | + } do | |
| 149 | + {:ok, embed_lv, html} = live(conn, ~p"/embed/#{canvas.embed_token}?mode=frame") | |
| 150 | + assert html =~ ~s(data-fit="frame") | |
| 151 | + assert html =~ ~s(data-cursors="false") | |
| 152 | + assert html =~ ~s(id="embed-content") | |
| 153 | + refute html =~ ~s(id="embed-identity") | |
| 154 | + refute html =~ ~s(id="embed-cursors") | |
| 155 | + | |
| 156 | + render_hook(embed_lv, "cursor", %{"x" => 50, "y" => 70}) | |
| 157 | + | |
| 158 | + editor_conn = | |
| 159 | + Phoenix.ConnTest.build_conn() | |
| 160 | + |> Phoenix.ConnTest.init_test_session(%{}) | |
| 161 | + |> log_in_user(owner) | |
| 162 | + | |
| 163 | + {:ok, edit_lv, _} = live(editor_conn, ~p"/c/#{canvas.slug}") | |
| 164 | + _ = :sys.get_state(edit_lv.pid) | |
| 165 | + refute render(edit_lv) =~ ~s(id="cur-embed-) | |
| 166 | + end | |
| 167 | + | |
| 168 | + test "default mode keeps cursors outside the measured content", %{conn: conn, canvas: canvas} do | |
| 169 | + {:ok, _lv, html} = live(conn, ~p"/embed/#{canvas.embed_token}") | |
| 170 | + assert html =~ ~s(data-fit="cap") | |
| 171 | + [_, after_content] = String.split(html, ~s(id="embed-content"), parts: 2) | |
| 172 | + assert after_content =~ ~s(id="embed-cursors") | |
| 173 | + end | |
| 174 | + | |
| 144 | 175 | test "embed receives live updates from the editor", %{conn: conn, canvas: canvas, owner: owner} do |
| 145 | 176 | {:ok, embed_lv, _} = live(conn, ~p"/embed/#{canvas.embed_token}") |
| 146 | 177 |
Parents: cc21d3c