Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
98 changes: 98 additions & 0 deletions apps/roam/src/components/canvas/CanvasEmbed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 { getPageTitleValueByHtmlElement } from "roamjs-components/dom";
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 getCurrentPageTitle = (el: HTMLElement): string | null => {
try {
return getPageTitleValueByHtmlElement(el);
} catch {
return null;
}
};

const CanvasEmbedPlaceholder = ({ message }: { message: string }) => (
<div
style={{
display: "flex",
Comment thread
sid597 marked this conversation as resolved.
Outdated
alignItems: "center",
justifyContent: "center",
height: "100px",
color: "#8a9ba8",
fontSize: "14px",
border: "1px dashed #d1d5db",
borderRadius: "6px",
}}
>
{message}
</div>
);

export const renderCanvasEmbed = (
button: HTMLElement,
onloadArgs: OnloadArgs,
) => {
button.style.display = "none";
Comment thread
sid597 marked this conversation as resolved.
Outdated

if (!button.parentElement) return;

const title = extractCanvasTitle(button);
if (!title) return;

const currentPageTitle = getCurrentPageTitle(button);
if (currentPageTitle === title) {
Comment thread
sid597 marked this conversation as resolved.
Outdated
const wrapper = document.createElement("div");
button.parentElement.appendChild(wrapper);
renderWithUnmount(
<CanvasEmbedPlaceholder message="Cannot embed a canvas within itself" />,
wrapper,
);
return;
}

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

button.parentElement.onmousedown = (e: MouseEvent) => e.stopPropagation();
Comment thread
sid597 marked this conversation as resolved.
Outdated
Comment thread
sid597 marked this conversation as resolved.
Outdated

const wrapper = document.createElement("div");
wrapper.className = "dg-canvas-embed";
Comment thread
sid597 marked this conversation as resolved.
Outdated
wrapper.style.height = "400px";
wrapper.style.width = "100%";
wrapper.style.overflow = "hidden";
wrapper.style.borderRadius = "6px";
wrapper.style.margin = "8px 0";
button.parentElement.appendChild(wrapper);

renderWithUnmount(
<ExtensionApiContextProvider {...onloadArgs}>
<TldrawCanvas title={title} />
</ExtensionApiContextProvider>,
wrapper,
);
};
123 changes: 123 additions & 0 deletions apps/roam/src/components/canvas/CanvasEmbedDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React, { useState, useMemo, useCallback } from "react";
import { Dialog, InputGroup, Menu, MenuItem } from "@blueprintjs/core";
import renderOverlay, {
RoamOverlayProps,
} from "roamjs-components/util/renderOverlay";
import { DEFAULT_CANVAS_PAGE_FORMAT } from "~/index";
import { getFormattedConfigTree } from "~/utils/discourseConfigRef";

type CanvasEmbedDialogProps = {
onSelect: (title: string) => void;
};

const getCanvasPages = (): { title: string; uid: string }[] => {
const { canvasPageFormat } = getFormattedConfigTree();
const format = canvasPageFormat.value || DEFAULT_CANVAS_PAGE_FORMAT;
const regexSource = `^${format.replace(/\*/g, ".+")}$`;
const escaped = regexSource.replace(/\\/g, "\\\\").replace(/"/g, '\\"');

try {
const results = window.roamAlphaAPI.q(`[
Comment thread
sid597 marked this conversation as resolved.
Outdated
:find (pull ?node [:node/title :block/uid])
:where
[(re-pattern "${escaped}") ?regex]
[?node :node/title ?title]
[(re-find ?regex ?title)]
]`) as [{ title: string; uid: string }][];
Comment thread
sid597 marked this conversation as resolved.
Outdated

return results
.map(([r]) => ({ title: r.title, uid: r.uid }))
.sort((a, b) => a.title.localeCompare(b.title));
} catch {
return [];
}
};

const CanvasEmbedDialog = ({
isOpen,
onClose,
onSelect,
}: RoamOverlayProps<CanvasEmbedDialogProps>) => {
const [filter, setFilter] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const canvasPages = useMemo(getCanvasPages, []);

const filtered = useMemo(() => {
if (!filter) return canvasPages;
const lower = filter.toLowerCase();
return canvasPages.filter((p) => p.title.toLowerCase().includes(lower));
}, [filter, canvasPages]);

const handleSelect = useCallback(
(title: string) => {
onSelect(title);
onClose();
},
[onSelect, onClose],
);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && filtered.length > 0) {
e.preventDefault();
handleSelect(filtered[activeIndex].title);
}
},
[filtered, activeIndex, handleSelect],
);

return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title="Embed Canvas"
style={{ width: 400, paddingBottom: 0 }}
Comment thread
sid597 marked this conversation as resolved.
Outdated
>
<div style={{ padding: "16px" }}>
<InputGroup
Comment thread
sid597 marked this conversation as resolved.
Outdated
placeholder="Search canvas pages..."
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setActiveIndex(0);
}}
autoFocus
onKeyDown={handleKeyDown}
/>
<Menu
style={{
maxHeight: "300px",
overflowY: "auto",
marginTop: "8px",
}}
>
{filtered.length === 0 ? (
<MenuItem disabled text="No canvas pages found" />
) : (
filtered.map((page, i) => (
<MenuItem
key={page.uid}
text={page.title}
active={i === activeIndex}
onClick={() => handleSelect(page.title)}
/>
))
)}
</Menu>
</div>
</Dialog>
);
};

export const renderCanvasEmbedDialog = (props: CanvasEmbedDialogProps) =>
renderOverlay({
// eslint-disable-next-line @typescript-eslint/naming-convention
Overlay: CanvasEmbedDialog,
props,
});
2 changes: 1 addition & 1 deletion apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
10 changes: 5 additions & 5 deletions apps/roam/src/components/canvas/tldrawStyles.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
7 changes: 7 additions & 0 deletions apps/roam/src/utils/initializeObserversAndListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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";
Expand Down Expand Up @@ -131,6 +132,11 @@ export const initObservers = async ({
render: (b) => renderQueryBlock(b, onloadArgs),
});

const canvasEmbedObserver = createButtonObserver({
attribute: "dg-canvas",
render: (b) => renderCanvasEmbed(b, onloadArgs),
});

const nodeTagPopupButtonObserver = createHTMLObserver({
className: "rm-page-ref--tag",
tag: "SPAN",
Expand Down Expand Up @@ -394,6 +400,7 @@ export const initObservers = async ({
observers: [
pageTitleObserver,
queryBlockObserver,
canvasEmbedObserver,
configPageObserver,
graphOverviewExportObserver,
nodeTagPopupButtonObserver,
Expand Down
26 changes: 26 additions & 0 deletions apps/roam/src/utils/registerCommandPaletteCommands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { openQueryDrawer } from "~/components/QueryDrawer";
import { renderCanvasEmbedDialog } from "~/components/canvas/CanvasEmbedDialog";
import { render as exportRender } from "~/components/Export";
import { render as renderToast } from "roamjs-components/components/Toast";
import { createBlock, updateBlock } from "roamjs-components/writes";
Expand Down Expand Up @@ -314,6 +315,31 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => {
};

// Roam organizes commands alphabetically
Comment thread
sid597 marked this conversation as resolved.
(
window.roamAlphaAPI.ui as unknown as {
Comment thread
sid597 marked this conversation as resolved.
Outdated
slashCommand: {
addCommand: (cmd: {
label: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
callback: (context: { "block-uid": string }) => void;
}) => void;
};
}
).slashCommand.addCommand({
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}]]}}`,
});
},
});
},
});
void addCommand("DG: Create/Insert discourse node", () =>
createDiscourseNodeFromCommand(extensionAPI),
);
Expand Down
Loading