Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 29 additions & 18 deletions apps/roam/src/components/canvas/CanvasEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,8 @@ import getUids from "roamjs-components/dom/getUids";
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import { TldrawCanvas } from "./Tldraw";

const BLOCK_TEXT_REGEX = /\{\{dg-canvas:\s*\[\[(.+?)\]\]\s*\}\}/i;

const extractCanvasTitle = (button: HTMLElement): string | null => {
const blockUid = getBlockUidFromTarget(button);
if (!blockUid) return null;
const blockText = getTextByBlockUid(blockUid);
if (!blockText) return null;
const match = blockText.match(BLOCK_TEXT_REGEX);
if (!match) return null;
return match[1].trim();
};
import { CanvasFrameEmbed, findCanvasFrameRef } from "./CanvasFrameEmbed";
import { parseDgCanvasEmbed } from "~/utils/dgCanvasEmbed";

const CanvasEmbedPlaceholder = ({ message }: { message: string }) => (
<div
Expand All @@ -41,6 +31,8 @@ const handleEditBlock = (location: { blockUid: string; windowId: string }) => {
});
};

// The whole-canvas embed, with an inline "Edit Block" button so the user can
// jump to the source block from the mounted canvas.
const CanvasEmbedChrome = ({
title,
location,
Expand All @@ -61,6 +53,11 @@ const CanvasEmbedChrome = ({
</div>
);

// `{{dg-canvas: [[Title]]}}` renders the classic whole-canvas embed. An
// optional frame argument (`"Frame Name"` and/or `shape:ID`) routes to the
// frame-anchored embed instead — but only when it maps to a real frame on the
// canvas; malformed or unmatched frame arguments are ignored so the embed
// always degrades to just showing the canvas.
export const renderCanvasEmbed = (
button: HTMLElement,
onloadArgs: OnloadArgs,
Expand All @@ -69,31 +66,45 @@ export const renderCanvasEmbed = (

if (!button.parentElement) return;

const title = extractCanvasTitle(button);
if (!title) return;
const blockUid = getBlockUidFromTarget(button);
if (!blockUid) return;
const blockText = getTextByBlockUid(blockUid);
const parsed = blockText ? parseDgCanvasEmbed(blockText) : null;
if (!parsed) return;

const pageUid = getPageUidByPageTitle(title);
const pageUid = getPageUidByPageTitle(parsed.title);
if (!pageUid) {
const wrapper = document.createElement("div");
button.parentElement.appendChild(wrapper);
renderWithUnmount(
<CanvasEmbedPlaceholder message={`Canvas not found: ${title}`} />,
<CanvasEmbedPlaceholder message={`Canvas not found: ${parsed.title}`} />,
wrapper,
);
return;
}

const frame = findCanvasFrameRef({
pageUid,
frameName: parsed.frameName,
frameShapeId: parsed.frameShapeId,
});
const location = getUids(button.closest<HTMLDivElement>(".roam-block"));

const wrapper = document.createElement("div");
wrapper.className = "dg-canvas-embed my-2 w-full overflow-hidden rounded-md";
wrapper.className = frame
? "dg-frame-embed my-2 w-full overflow-hidden rounded-md"
: "dg-canvas-embed my-2 w-full overflow-hidden rounded-md";
wrapper.style.height = "400px";
wrapper.onmousedown = (e: MouseEvent) => e.stopPropagation();
button.parentElement.appendChild(wrapper);

renderWithUnmount(
<ExtensionApiContextProvider {...onloadArgs}>
<CanvasEmbedChrome title={title} location={location} />
{frame ? (
<CanvasFrameEmbed title={parsed.title} frame={frame} />
) : (
<CanvasEmbedChrome title={parsed.title} location={location} />
)}
</ExtensionApiContextProvider>,
wrapper,
);
Expand Down
170 changes: 170 additions & 0 deletions apps/roam/src/components/canvas/CanvasFrameEmbed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button, Card, Elevation } from "@blueprintjs/core";
import type { Editor, TLFrameShape, TLShapeId } from "tldraw";
import { TldrawCanvas, type CanvasEmbedOptions } from "./Tldraw";
import { getCanvasFrameShapes } from "./useRoamStore";

const FRAME_ZOOM_INSET = 16;

export type FrameRef = { name?: string; shapeId?: string };

// Decide whether a parsed frame argument actually maps to a frame on the canvas
// by scanning the persisted frame shapes (no editor mounted). The classic embed
// renderer uses this to route: no match -> the frame argument is ignored and the
// whole canvas renders. Sync-mode canvases may have a slightly stale snapshot;
// the worst case is falling back to the whole-canvas embed.
export const findCanvasFrameRef = ({
pageUid,
frameName,
frameShapeId,
}: {
pageUid: string;
frameName?: string;
frameShapeId?: string;
}): FrameRef | null => {
if (!frameName && !frameShapeId) return null;
try {
const frames = getCanvasFrameShapes(pageUid);
const matchesId =
!!frameShapeId && frames.some((frame) => frame.id === frameShapeId);
const target = frameName?.trim().toLowerCase();
const matchesName =
!!target &&
frames.some(
(frame) => (frame.props.name ?? "").trim().toLowerCase() === target,
);

return matchesId || matchesName
? { name: frameName, shapeId: frameShapeId }
: null;
} catch {
return null;
}
};

// Resolve a frame by shape id first (stable across renames and moves), then fall
// back to a case-insensitive, trimmed name match. Duplicate names resolve to the
// first match with a warning. Returns null if neither path finds a frame.
const resolveFrameShape = (
editor: Editor,
frame: FrameRef,
): TLFrameShape | null => {
if (frame.shapeId) {
const byId = editor.getShape<TLFrameShape>(frame.shapeId as TLShapeId);
if (byId?.type === "frame") return byId;
}

if (frame.name) {
const target = frame.name.trim().toLowerCase();
const matches = editor
.getCurrentPageShapes()
.filter(
(shape): shape is TLFrameShape =>
shape.type === "frame" &&
((shape as TLFrameShape).props.name ?? "").trim().toLowerCase() ===
target,
);
if (matches.length > 1) {
// eslint-disable-next-line no-console
console.warn(
`dg-canvas: multiple frames named "${frame.name}"; using the first match.`,
);
}
if (matches[0]) return matches[0];
}

return null;
};

// Frame always wins: zoom to the frame's current bounds so the embed tracks the
// frame even when it has been moved or resized on the canvas since last mount.
const zoomToFrame = (editor: Editor, frame: FrameRef): boolean => {
const shape = resolveFrameShape(editor, frame);
if (!shape) return false;
const bounds = editor.getShapePageBounds(shape);
if (!bounds) return false;
editor.zoomToBounds(bounds, { inset: FRAME_ZOOM_INSET });
return true;
};

// Frame-anchored variant of the canvas embed: mounted by renderCanvasEmbed only
// when the block's frame argument maps to a real frame on the canvas.
export const CanvasFrameEmbed = ({
title,
frame,
}: {
title: string;
frame: FrameRef;
}) => {
const editorRef = useRef<Editor | null>(null);
const rafRef = useRef<number | null>(null);
const [frameMissing, setFrameMissing] = useState(false);

const handleEditorMount = useCallback(
(editor: Editor) => {
editorRef.current = editor;
// Defer one frame so tldraw has measured the embed's viewport before we
// compute a viewport-relative zoom on first mount. Track the handle so the
// cleanup effect can cancel it if the embed unmounts within the frame —
// otherwise the callback runs against a disposed editor.
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setFrameMissing(!zoomToFrame(editor, frame));
});
},
[frame],
);

useEffect(
() => () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
},
[],
);

const handleRecenter = useCallback(() => {
// Re-resolve on click so a frame deleted/renamed since mount clears (or
// raises) the not-found notice instead of leaving stale state.
if (editorRef.current)
setFrameMissing(!zoomToFrame(editorRef.current, frame));
}, [frame]);

// Manage the camera ourselves (zoom-to-frame on every mount, no session
// persistence) and start with the tldraw chrome hidden (cmd+. restores it).
const embedOptions: CanvasEmbedOptions = {
disableSessionPersistence: true,
onEditorMount: handleEditorMount,
defaultFocusMode: true,
};

return (
<div style={{ position: "relative", height: "100%", width: "100%" }}>
<TldrawCanvas title={title} embedOptions={embedOptions} />
<Button
small
title="Re-center on frame"
onMouseDown={(e) => e.stopPropagation()}
onClick={handleRecenter}
style={{ position: "absolute", top: 6, right: 6, zIndex: 300 }}
>
</Button>
{frameMissing && (
<Card
elevation={Elevation.ONE}
className="text-xs text-[#5c7080]"
style={{
position: "absolute",
bottom: 6,
left: 6,
zIndex: 300,
padding: "4px 8px",
}}
>
Frame {frame.name ? `“${frame.name}” ` : ""}not found on [[
{title}]]
</Card>
)}
</div>
);
};
Loading