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 ( + +
{renderContent()}
+
+ ); +}; + +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 }); + }; +};