Skip to content

Commit 586955a

Browse files
authored
ENG-1553: Embed canvas in block (#988)
* ENG-1553 Embed canvas in block * Fix tsc and eslint errors for slashCommand registration Cast window.roamAlphaAPI.ui to access untyped slashCommand API with proper type annotation instead of relying on any. Fix void callback returning null. * ENG-1553 Address PR review comments - Move slash command out of registerCommandPaletteCommands into new registerSlashCommands.ts; wire teardown into index.ts unload - Replace hand-rolled InputGroup+Menu+keyboard logic in CanvasEmbedDialog with roamjs-components AutocompleteInput; add loading + empty states - Use data.async.fast.q (not the deprecated sync .q) for canvas page lookup - Extract canvas-page regex into shared helper in isCanvasPage.ts; escape regex special chars in user-provided canvasPageFormat - Convert inline styles to Tailwind in CanvasEmbed and CanvasEmbedDialog Pending follow-ups (not in this commit): - Upstream slashCommand type to roamjs-components so the `unknown` cast in registerSlashCommands.ts can be dropped - Dedupe getCanvasPageTargets (conditionToDatalog) and checkForCanvasPage (Export.tsx) onto the shared helper * ENG-1553 Replace Tailwind utilities with CSS classes in styles.css Tailwind isn't compiled in the roam build (config has empty content, no @tailwind directives in styles/*.css, no PostCSS step in scripts/compile.ts), so the utility classes added in 08161b0 produced no CSS and the canvas embed wrapper collapsed to 0x0. Move the styles to dedicated classes in styles.css (.dg-canvas-embed, .dg-canvas-embed-placeholder, .dg-canvas-embed-dialog*, .dg-canvas-embed-source-hidden) to match the existing pattern in that file. Uses descendant selectors and proper specificity to override Blueprint dialog defaults and to size the AutocompleteInput inside the dialog -- neither possible from inline styles. * ENG-1553 Attach canvas embed mousedown handler to wrapper Moves the stopPropagation handler from button.parentElement to the wrapper div. Attaching to the parent overwrote any existing onmousedown Roam had on the block element and was never cleaned up; attaching to the wrapper (which renderWithUnmount owns) scopes the handler to the canvas and removes it on unmount. * ENG-1553 Drop unreachable self-embed guard The check guarded against a {{dg-canvas: [[X]]}} block living on canvas page X. But canvas pages hide their block outline (tldrawStyles.ts:4-6), so users can't author the block via the normal flow on a canvas page. Removing the dead guard and the getCurrentPageTitle helper that only served it. * Minimize canvas embed CSS
1 parent 470fb5d commit 586955a

9 files changed

Lines changed: 246 additions & 14 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from "react";
2+
import ExtensionApiContextProvider from "roamjs-components/components/ExtensionApiContext";
3+
import { OnloadArgs } from "roamjs-components/types";
4+
import renderWithUnmount from "roamjs-components/util/renderWithUnmount";
5+
import getBlockUidFromTarget from "roamjs-components/dom/getBlockUidFromTarget";
6+
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
7+
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
8+
import { TldrawCanvas } from "./Tldraw";
9+
10+
const BLOCK_TEXT_REGEX = /\{\{dg-canvas:\s*\[\[(.+?)\]\]\s*\}\}/i;
11+
12+
const extractCanvasTitle = (button: HTMLElement): string | null => {
13+
const blockUid = getBlockUidFromTarget(button);
14+
if (!blockUid) return null;
15+
const blockText = getTextByBlockUid(blockUid);
16+
if (!blockText) return null;
17+
const match = blockText.match(BLOCK_TEXT_REGEX);
18+
if (!match) return null;
19+
return match[1].trim();
20+
};
21+
22+
const CanvasEmbedPlaceholder = ({ message }: { message: string }) => (
23+
<div className="dg-canvas-embed-placeholder flex items-center justify-center rounded-md border border-dashed border-gray-300 text-sm text-[#8a9ba8]">
24+
{message}
25+
</div>
26+
);
27+
28+
export const renderCanvasEmbed = (
29+
button: HTMLElement,
30+
onloadArgs: OnloadArgs,
31+
) => {
32+
button.hidden = true;
33+
34+
if (!button.parentElement) return;
35+
36+
const title = extractCanvasTitle(button);
37+
if (!title) return;
38+
39+
const pageUid = getPageUidByPageTitle(title);
40+
if (!pageUid) {
41+
const wrapper = document.createElement("div");
42+
button.parentElement.appendChild(wrapper);
43+
renderWithUnmount(
44+
<CanvasEmbedPlaceholder message={`Canvas not found: ${title}`} />,
45+
wrapper,
46+
);
47+
return;
48+
}
49+
50+
const wrapper = document.createElement("div");
51+
wrapper.className = "dg-canvas-embed my-2 w-full overflow-hidden rounded-md";
52+
wrapper.onmousedown = (e: MouseEvent) => e.stopPropagation();
53+
button.parentElement.appendChild(wrapper);
54+
55+
renderWithUnmount(
56+
<ExtensionApiContextProvider {...onloadArgs}>
57+
<TldrawCanvas title={title} />
58+
</ExtensionApiContextProvider>,
59+
wrapper,
60+
);
61+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React, { useCallback, useEffect, useState } from "react";
2+
import { Dialog } from "@blueprintjs/core";
3+
import AutocompleteInput from "roamjs-components/components/AutocompleteInput";
4+
import renderOverlay, {
5+
RoamOverlayProps,
6+
} from "roamjs-components/util/renderOverlay";
7+
import { getCanvasPageTitles } from "~/utils/isCanvasPage";
8+
9+
type CanvasEmbedDialogProps = {
10+
onSelect: (title: string) => void;
11+
};
12+
13+
const CanvasEmbedDialog = ({
14+
isOpen,
15+
onClose,
16+
onSelect,
17+
}: RoamOverlayProps<CanvasEmbedDialogProps>) => {
18+
const [canvasPages, setCanvasPages] = useState<string[] | null>(null);
19+
20+
useEffect(() => {
21+
void getCanvasPageTitles().then(setCanvasPages);
22+
}, []);
23+
24+
const handleSetValue = useCallback(
25+
(title: string) => {
26+
if (canvasPages?.includes(title)) {
27+
onSelect(title);
28+
onClose();
29+
}
30+
},
31+
[canvasPages, onSelect, onClose],
32+
);
33+
34+
const renderContent = () => {
35+
if (canvasPages === null)
36+
return (
37+
<div className="text-sm text-[#5c7080]">Loading canvas pages...</div>
38+
);
39+
if (canvasPages.length === 0)
40+
return (
41+
<div className="text-sm text-[#5c7080]">No canvas pages found</div>
42+
);
43+
return (
44+
<AutocompleteInput
45+
setValue={handleSetValue}
46+
options={canvasPages}
47+
placeholder="Search canvas pages..."
48+
autoFocus
49+
autoSelectFirstOption={false}
50+
/>
51+
);
52+
};
53+
54+
return (
55+
<Dialog
56+
isOpen={isOpen}
57+
onClose={onClose}
58+
title="Embed Canvas"
59+
className="dg-canvas-embed-dialog pb-0"
60+
>
61+
<div className="p-4">{renderContent()}</div>
62+
</Dialog>
63+
);
64+
};
65+
66+
export const renderCanvasEmbedDialog = (props: CanvasEmbedDialogProps) =>
67+
renderOverlay({
68+
// eslint-disable-next-line @typescript-eslint/naming-convention
69+
Overlay: CanvasEmbedDialog,
70+
props,
71+
});

apps/roam/src/components/canvas/Tldraw.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export const isPageUid = (uid: string) =>
188188
":node/title"
189189
];
190190

191-
const TldrawCanvas = ({ title }: { title: string }) => {
191+
export const TldrawCanvas = ({ title }: { title: string }) => {
192192
// In Roam, canvas identity is currently keyed by the page UID.
193193
// Room sync is graph/page encoded as an opaque base64url token.
194194
const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]);

apps/roam/src/components/canvas/tldrawStyles.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// tldrawStyles.ts because some of these styles need to be inlined
22
export default /* css */ `
3-
/* Hide Roam Blocks only when a canvas is present under the root */
4-
.roam-article:has(.roamjs-tldraw-canvas-container) .rm-block-children {
3+
/* Hide Roam Blocks only when a full-page canvas is present (not embedded) */
4+
.roam-article:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children {
55
display: none;
66
}
7-
8-
/* Hide Roam Blocks in sidebar when a canvas is present */
9-
.rm-sidebar-outline:has(.roamjs-tldraw-canvas-container) .rm-block-children {
7+
8+
/* Hide Roam Blocks in sidebar when a full-page canvas is present (not embedded) */
9+
.rm-sidebar-outline:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children {
1010
display: none;
1111
}
1212

apps/roam/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { fireQuerySync } from "./utils/fireQuery";
99
import parseQuery from "./utils/parseQuery";
1010
import refreshConfigTree from "./utils/refreshConfigTree";
1111
import { registerCommandPaletteCommands } from "./utils/registerCommandPaletteCommands";
12+
import { registerSlashCommands } from "./utils/registerSlashCommands";
1213
import { createSettingsPanel } from "~/utils/createSettingsPanel";
1314
import { listActiveQueries } from "./utils/listActiveQueries";
1415
import { registerSmartBlock } from "./utils/registerSmartBlock";
@@ -91,7 +92,7 @@ export default runExtension(async (onloadArgs) => {
9192
addGraphViewNodeStyling();
9293

9394
registerCommandPaletteCommands(onloadArgs);
94-
95+
const unregisterSlashCommands = registerSlashCommands();
9596
createSettingsPanel(onloadArgs);
9697

9798
registerSmartBlock(onloadArgs);
@@ -212,6 +213,7 @@ export default runExtension(async (onloadArgs) => {
212213
cleanupPullWatchers();
213214
cleanups.forEach((fn) => fn());
214215
setSyncActivity(false);
216+
unregisterSlashCommands();
215217
window.roamjs.extension?.smartblocks?.unregisterCommand("QUERYBUILDER");
216218
// @ts-expect-error - tldraw throws a warning on multiple loads
217219
delete window[Symbol.for("__signia__")];

apps/roam/src/styles/styles.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,26 @@
191191
#dg-left-sidebar-root .bp3-dark .bp3-button:not([class*="bp3-intent-"]):hover {
192192
color: #f5f8fa;
193193
}
194+
195+
.dg-canvas-embed {
196+
height: 400px;
197+
}
198+
199+
.dg-canvas-embed > .roamjs-tldraw-canvas-container {
200+
height: 100%;
201+
width: 100%;
202+
}
203+
204+
.dg-canvas-embed-placeholder {
205+
height: 100px;
206+
}
207+
208+
.dg-canvas-embed-dialog.bp3-dialog {
209+
width: 400px;
210+
}
211+
212+
.dg-canvas-embed-dialog .roamjs-autocomplete-input-target,
213+
.dg-canvas-embed-dialog .roamjs-autocomplete-input-target .bp3-input-group {
214+
display: block;
215+
width: 100%;
216+
}

apps/roam/src/utils/initializeObserversAndListeners.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { getFeatureFlag } from "~/components/settings/utils/accessors";
4949
import { getCleanTagText } from "~/components/settings/NodeConfig";
5050
import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors";
5151
import { renderPossibleDuplicates } from "~/components/VectorDuplicateMatches";
52+
import { renderCanvasEmbed } from "~/components/canvas/CanvasEmbed";
5253
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
5354
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
5455
import findDiscourseNode from "./findDiscourseNode";
@@ -148,6 +149,11 @@ export const initObservers = ({
148149
render: (b) => renderQueryBlock(b, onloadArgs),
149150
});
150151

152+
const canvasEmbedObserver = createButtonObserver({
153+
attribute: "dg-canvas",
154+
render: (b) => renderCanvasEmbed(b, onloadArgs),
155+
});
156+
151157
let batchedTagNodes: DiscourseNode[] | null = null;
152158
const getNodesForTagBatch = (): DiscourseNode[] => {
153159
if (batchedTagNodes === null) {
@@ -447,6 +453,7 @@ export const initObservers = ({
447453
observers: [
448454
pageTitleObserver,
449455
queryBlockObserver,
456+
canvasEmbedObserver,
450457
graphOverviewExportObserver,
451458
nodeTagPopupButtonObserver,
452459
leftSidebarObserver,

apps/roam/src/utils/isCanvasPage.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,23 @@ import {
55
} from "~/components/settings/utils/accessors";
66
import { GLOBAL_KEYS } from "~/components/settings/utils/settingKeys";
77

8+
const getCanvasPageRegex = (snapshot?: SettingsSnapshot): RegExp => {
9+
const format =
10+
(snapshot
11+
? snapshot.globalSettings[GLOBAL_KEYS.canvasPageFormat]
12+
: getGlobalSetting<string>([GLOBAL_KEYS.canvasPageFormat])) ||
13+
DEFAULT_CANVAS_PAGE_FORMAT;
14+
return new RegExp(`^${format}$`.replace(/\*/g, ".+"));
15+
};
16+
817
export const isCanvasPage = ({
918
title,
1019
snapshot,
1120
}: {
1221
title: string;
1322
snapshot?: SettingsSnapshot;
1423
}) => {
15-
const format =
16-
(snapshot
17-
? snapshot.globalSettings[GLOBAL_KEYS.canvasPageFormat]
18-
: getGlobalSetting<string>([GLOBAL_KEYS.canvasPageFormat])) ||
19-
DEFAULT_CANVAS_PAGE_FORMAT;
20-
const canvasRegex = new RegExp(`^${format}$`.replace(/\*/g, ".+"));
21-
return canvasRegex.test(title);
24+
return getCanvasPageRegex(snapshot).test(title);
2225
};
2326

2427
export const isCurrentPageCanvas = ({
@@ -46,3 +49,20 @@ export const isSidebarCanvas = ({
4649
isCanvasPage({ title, snapshot }) && !!h1.closest(".rm-sidebar-outline")
4750
);
4851
};
52+
53+
export const getCanvasPageTitles = async (): Promise<string[]> => {
54+
const regex = getCanvasPageRegex();
55+
const escaped = regex.source.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
56+
try {
57+
const results = (await window.roamAlphaAPI.data.async.fast.q(`[
58+
:find ?title
59+
:where
60+
[(re-pattern "${escaped}") ?regex]
61+
[?node :node/title ?title]
62+
[(re-find ?regex ?title)]
63+
]`)) as [string][];
64+
return results.map(([title]) => title).sort((a, b) => a.localeCompare(b));
65+
} catch {
66+
return [];
67+
}
68+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { updateBlock } from "roamjs-components/writes";
2+
import { renderCanvasEmbedDialog } from "~/components/canvas/CanvasEmbedDialog";
3+
4+
type SlashCommandContext = {
5+
// eslint-disable-next-line @typescript-eslint/naming-convention
6+
"block-uid": string;
7+
};
8+
9+
type SlashCommandApi = {
10+
addCommand: (cmd: {
11+
label: string;
12+
callback: (context: SlashCommandContext) => void;
13+
}) => void;
14+
removeCommand: (cmd: { label: string }) => void;
15+
};
16+
17+
const getSlashCommandApi = (): SlashCommandApi =>
18+
(window.roamAlphaAPI.ui as unknown as { slashCommand: SlashCommandApi })
19+
.slashCommand;
20+
21+
const SLASH_COMMANDS: {
22+
label: string;
23+
callback: (context: SlashCommandContext) => void;
24+
}[] = [
25+
{
26+
label: "DG: Embed canvas",
27+
callback: (context) => {
28+
const uid = context["block-uid"];
29+
if (!uid) return;
30+
renderCanvasEmbedDialog({
31+
onSelect: (title: string) => {
32+
void updateBlock({
33+
uid,
34+
text: `{{dg-canvas: [[${title}]]}}`,
35+
});
36+
},
37+
});
38+
},
39+
},
40+
];
41+
42+
export const registerSlashCommands = (): (() => void) => {
43+
const api = getSlashCommandApi();
44+
for (const cmd of SLASH_COMMANDS) api.addCommand(cmd);
45+
return () => {
46+
for (const { label } of SLASH_COMMANDS) api.removeCommand({ label });
47+
};
48+
};

0 commit comments

Comments
 (0)