Add a frame mode to embeds and keep cursors out of the fit

0b76ad1 · Gabriel Morell · 2026-10-13 14:26

10 files +90 -33
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