diff --git a/apps/roam/src/components/canvas/CanvasEmbed.tsx b/apps/roam/src/components/canvas/CanvasEmbed.tsx
new file mode 100644
index 000000000..2b0fc9c0b
--- /dev/null
+++ b/apps/roam/src/components/canvas/CanvasEmbed.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import ExtensionApiContextProvider from "roamjs-components/components/ExtensionApiContext";
+import { OnloadArgs } from "roamjs-components/types";
+import renderWithUnmount from "roamjs-components/util/renderWithUnmount";
+import getBlockUidFromTarget from "roamjs-components/dom/getBlockUidFromTarget";
+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();
+};
+
+const CanvasEmbedPlaceholder = ({ message }: { message: string }) => (
+
+ {message}
+
+);
+
+export const renderCanvasEmbed = (
+ button: HTMLElement,
+ onloadArgs: OnloadArgs,
+) => {
+ button.hidden = true;
+
+ if (!button.parentElement) return;
+
+ const title = extractCanvasTitle(button);
+ if (!title) return;
+
+ const pageUid = getPageUidByPageTitle(title);
+ if (!pageUid) {
+ const wrapper = document.createElement("div");
+ button.parentElement.appendChild(wrapper);
+ renderWithUnmount(
+ ,
+ wrapper,
+ );
+ return;
+ }
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "dg-canvas-embed my-2 w-full overflow-hidden rounded-md";
+ wrapper.onmousedown = (e: MouseEvent) => e.stopPropagation();
+ button.parentElement.appendChild(wrapper);
+
+ renderWithUnmount(
+
+
+ ,
+ wrapper,
+ );
+};
diff --git a/apps/roam/src/components/canvas/CanvasEmbedDialog.tsx b/apps/roam/src/components/canvas/CanvasEmbedDialog.tsx
new file mode 100644
index 000000000..308dc8132
--- /dev/null
+++ b/apps/roam/src/components/canvas/CanvasEmbedDialog.tsx
@@ -0,0 +1,71 @@
+import React, { useCallback, useEffect, useState } from "react";
+import { Dialog } from "@blueprintjs/core";
+import AutocompleteInput from "roamjs-components/components/AutocompleteInput";
+import renderOverlay, {
+ RoamOverlayProps,
+} from "roamjs-components/util/renderOverlay";
+import { getCanvasPageTitles } from "~/utils/isCanvasPage";
+
+type CanvasEmbedDialogProps = {
+ onSelect: (title: string) => void;
+};
+
+const CanvasEmbedDialog = ({
+ isOpen,
+ onClose,
+ onSelect,
+}: RoamOverlayProps) => {
+ const [canvasPages, setCanvasPages] = useState(null);
+
+ useEffect(() => {
+ void getCanvasPageTitles().then(setCanvasPages);
+ }, []);
+
+ const handleSetValue = useCallback(
+ (title: string) => {
+ if (canvasPages?.includes(title)) {
+ onSelect(title);
+ onClose();
+ }
+ },
+ [canvasPages, onSelect, onClose],
+ );
+
+ const renderContent = () => {
+ if (canvasPages === null)
+ return (
+ Loading canvas pages...
+ );
+ if (canvasPages.length === 0)
+ return (
+ No canvas pages found
+ );
+ return (
+
+ );
+ };
+
+ return (
+
+ );
+};
+
+export const renderCanvasEmbedDialog = (props: CanvasEmbedDialogProps) =>
+ renderOverlay({
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ Overlay: CanvasEmbedDialog,
+ props,
+ });
diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx
index 92ce8ae74..e9a1175ec 100644
--- a/apps/roam/src/components/canvas/Tldraw.tsx
+++ b/apps/roam/src/components/canvas/Tldraw.tsx
@@ -189,7 +189,7 @@ export const isPageUid = (uid: string) =>
":node/title"
];
-const TldrawCanvas = ({ title }: { title: string }) => {
+export const TldrawCanvas = ({ title }: { title: string }) => {
// In Roam, canvas identity is currently keyed by the page UID.
// Room sync is graph/page encoded as an opaque base64url token.
const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]);
diff --git a/apps/roam/src/components/canvas/tldrawStyles.ts b/apps/roam/src/components/canvas/tldrawStyles.ts
index 946af91b2..03a5265e7 100644
--- a/apps/roam/src/components/canvas/tldrawStyles.ts
+++ b/apps/roam/src/components/canvas/tldrawStyles.ts
@@ -1,12 +1,12 @@
// tldrawStyles.ts because some of these styles need to be inlined
export default /* css */ `
- /* Hide Roam Blocks only when a canvas is present under the root */
- .roam-article:has(.roamjs-tldraw-canvas-container) .rm-block-children {
+ /* Hide Roam Blocks only when a full-page canvas is present (not embedded) */
+ .roam-article:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children {
display: none;
}
-
- /* Hide Roam Blocks in sidebar when a canvas is present */
- .rm-sidebar-outline:has(.roamjs-tldraw-canvas-container) .rm-block-children {
+
+ /* Hide Roam Blocks in sidebar when a full-page canvas is present (not embedded) */
+ .rm-sidebar-outline:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children {
display: none;
}
diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts
index 1e1581329..72cef618c 100644
--- a/apps/roam/src/index.ts
+++ b/apps/roam/src/index.ts
@@ -9,6 +9,7 @@ import { fireQuerySync } from "./utils/fireQuery";
import parseQuery from "./utils/parseQuery";
import refreshConfigTree from "./utils/refreshConfigTree";
import { registerCommandPaletteCommands } from "./utils/registerCommandPaletteCommands";
+import { registerSlashCommands } from "./utils/registerSlashCommands";
import { createSettingsPanel } from "~/utils/createSettingsPanel";
import { listActiveQueries } from "./utils/listActiveQueries";
import { registerSmartBlock } from "./utils/registerSmartBlock";
@@ -91,7 +92,7 @@ export default runExtension(async (onloadArgs) => {
addGraphViewNodeStyling();
registerCommandPaletteCommands(onloadArgs);
-
+ const unregisterSlashCommands = registerSlashCommands();
createSettingsPanel(onloadArgs);
registerSmartBlock(onloadArgs);
@@ -212,6 +213,7 @@ export default runExtension(async (onloadArgs) => {
cleanupPullWatchers();
cleanups.forEach((fn) => fn());
setSyncActivity(false);
+ unregisterSlashCommands();
window.roamjs.extension?.smartblocks?.unregisterCommand("QUERYBUILDER");
// @ts-expect-error - tldraw throws a warning on multiple loads
delete window[Symbol.for("__signia__")];
diff --git a/apps/roam/src/styles/styles.css b/apps/roam/src/styles/styles.css
index ef6ccf4f8..096345e95 100644
--- a/apps/roam/src/styles/styles.css
+++ b/apps/roam/src/styles/styles.css
@@ -191,3 +191,26 @@
#dg-left-sidebar-root .bp3-dark .bp3-button:not([class*="bp3-intent-"]):hover {
color: #f5f8fa;
}
+
+.dg-canvas-embed {
+ height: 400px;
+}
+
+.dg-canvas-embed > .roamjs-tldraw-canvas-container {
+ height: 100%;
+ width: 100%;
+}
+
+.dg-canvas-embed-placeholder {
+ height: 100px;
+}
+
+.dg-canvas-embed-dialog.bp3-dialog {
+ width: 400px;
+}
+
+.dg-canvas-embed-dialog .roamjs-autocomplete-input-target,
+.dg-canvas-embed-dialog .roamjs-autocomplete-input-target .bp3-input-group {
+ display: block;
+ width: 100%;
+}
diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts
index d7a4ee7c1..227dcd34a 100644
--- a/apps/roam/src/utils/initializeObserversAndListeners.ts
+++ b/apps/roam/src/utils/initializeObserversAndListeners.ts
@@ -49,6 +49,7 @@ import { getFeatureFlag } from "~/components/settings/utils/accessors";
import { getCleanTagText } from "~/components/settings/NodeConfig";
import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors";
import { renderPossibleDuplicates } from "~/components/VectorDuplicateMatches";
+import { renderCanvasEmbed } from "~/components/canvas/CanvasEmbed";
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
import findDiscourseNode from "./findDiscourseNode";
@@ -148,6 +149,11 @@ export const initObservers = ({
render: (b) => renderQueryBlock(b, onloadArgs),
});
+ const canvasEmbedObserver = createButtonObserver({
+ attribute: "dg-canvas",
+ render: (b) => renderCanvasEmbed(b, onloadArgs),
+ });
+
let batchedTagNodes: DiscourseNode[] | null = null;
const getNodesForTagBatch = (): DiscourseNode[] => {
if (batchedTagNodes === null) {
@@ -447,6 +453,7 @@ export const initObservers = ({
observers: [
pageTitleObserver,
queryBlockObserver,
+ canvasEmbedObserver,
graphOverviewExportObserver,
nodeTagPopupButtonObserver,
leftSidebarObserver,
diff --git a/apps/roam/src/utils/isCanvasPage.ts b/apps/roam/src/utils/isCanvasPage.ts
index 4c7d97331..749618ded 100644
--- a/apps/roam/src/utils/isCanvasPage.ts
+++ b/apps/roam/src/utils/isCanvasPage.ts
@@ -5,6 +5,15 @@ import {
} from "~/components/settings/utils/accessors";
import { GLOBAL_KEYS } from "~/components/settings/utils/settingKeys";
+const getCanvasPageRegex = (snapshot?: SettingsSnapshot): RegExp => {
+ const format =
+ (snapshot
+ ? snapshot.globalSettings[GLOBAL_KEYS.canvasPageFormat]
+ : getGlobalSetting([GLOBAL_KEYS.canvasPageFormat])) ||
+ DEFAULT_CANVAS_PAGE_FORMAT;
+ return new RegExp(`^${format}$`.replace(/\*/g, ".+"));
+};
+
export const isCanvasPage = ({
title,
snapshot,
@@ -12,13 +21,7 @@ export const isCanvasPage = ({
title: string;
snapshot?: SettingsSnapshot;
}) => {
- const format =
- (snapshot
- ? snapshot.globalSettings[GLOBAL_KEYS.canvasPageFormat]
- : getGlobalSetting([GLOBAL_KEYS.canvasPageFormat])) ||
- DEFAULT_CANVAS_PAGE_FORMAT;
- const canvasRegex = new RegExp(`^${format}$`.replace(/\*/g, ".+"));
- return canvasRegex.test(title);
+ return getCanvasPageRegex(snapshot).test(title);
};
export const isCurrentPageCanvas = ({
@@ -46,3 +49,20 @@ export const isSidebarCanvas = ({
isCanvasPage({ title, snapshot }) && !!h1.closest(".rm-sidebar-outline")
);
};
+
+export const getCanvasPageTitles = async (): Promise => {
+ const regex = getCanvasPageRegex();
+ const escaped = regex.source.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ try {
+ const results = (await window.roamAlphaAPI.data.async.fast.q(`[
+ :find ?title
+ :where
+ [(re-pattern "${escaped}") ?regex]
+ [?node :node/title ?title]
+ [(re-find ?regex ?title)]
+ ]`)) as [string][];
+ return results.map(([title]) => title).sort((a, b) => a.localeCompare(b));
+ } catch {
+ return [];
+ }
+};
diff --git a/apps/roam/src/utils/registerSlashCommands.ts b/apps/roam/src/utils/registerSlashCommands.ts
new file mode 100644
index 000000000..a234a0ad7
--- /dev/null
+++ b/apps/roam/src/utils/registerSlashCommands.ts
@@ -0,0 +1,48 @@
+import { updateBlock } from "roamjs-components/writes";
+import { renderCanvasEmbedDialog } from "~/components/canvas/CanvasEmbedDialog";
+
+type SlashCommandContext = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ "block-uid": string;
+};
+
+type SlashCommandApi = {
+ addCommand: (cmd: {
+ label: string;
+ callback: (context: SlashCommandContext) => void;
+ }) => void;
+ removeCommand: (cmd: { label: string }) => void;
+};
+
+const getSlashCommandApi = (): SlashCommandApi =>
+ (window.roamAlphaAPI.ui as unknown as { slashCommand: SlashCommandApi })
+ .slashCommand;
+
+const SLASH_COMMANDS: {
+ label: string;
+ callback: (context: SlashCommandContext) => void;
+}[] = [
+ {
+ label: "DG: Embed canvas",
+ callback: (context) => {
+ const uid = context["block-uid"];
+ if (!uid) return;
+ renderCanvasEmbedDialog({
+ onSelect: (title: string) => {
+ void updateBlock({
+ uid,
+ text: `{{dg-canvas: [[${title}]]}}`,
+ });
+ },
+ });
+ },
+ },
+];
+
+export const registerSlashCommands = (): (() => void) => {
+ const api = getSlashCommandApi();
+ for (const cmd of SLASH_COMMANDS) api.addCommand(cmd);
+ return () => {
+ for (const { label } of SLASH_COMMANDS) api.removeCommand({ label });
+ };
+};