From 23946aaff87f7e443823b006d90e54e5ec57f791 Mon Sep 17 00:00:00 2001 From: sid597 Date: Fri, 31 Jul 2026 10:18:49 +0530 Subject: [PATCH 1/3] ENG-2037 Add tabbed node card context menu to Roam tldraw --- .../components/canvas/CustomStylePanel.tsx | 247 ++++++++++++++++++ .../src/components/canvas/tldrawStyles.ts | 6 + .../src/components/canvas/uiOverrides.tsx | 2 + 3 files changed, 255 insertions(+) create mode 100644 apps/roam/src/components/canvas/CustomStylePanel.tsx diff --git a/apps/roam/src/components/canvas/CustomStylePanel.tsx b/apps/roam/src/components/canvas/CustomStylePanel.tsx new file mode 100644 index 000000000..9832bd52b --- /dev/null +++ b/apps/roam/src/components/canvas/CustomStylePanel.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useState } from "react"; +import { + DefaultStylePanel, + DefaultStylePanelContent, + TLUiStylePanelProps, + createShapeId, + useEditor, + useRelevantStyles, + useValue, +} from "tldraw"; +import { Button, Tab, Tabs } from "@blueprintjs/core"; +import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext"; +import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; +import type { DiscourseContextResults } from "~/components/DiscourseContext"; +import findDiscourseNode from "~/utils/findDiscourseNode"; +import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg"; +import { withAutoCanvasRelationsSuppressed } from "./autoCanvasRelationsSuppression"; +import { isDiscourseNodeShape } from "./canvasUtils"; +import { + DISCOURSE_NODE_SHAPE_TYPE, + DiscourseNodeShape, + DiscourseNodeUtil, +} from "./DiscourseNodeUtil"; +import { dispatchToastEvent } from "./ToastListener"; + +const NEW_NODE_OFFSET_PX = 80; + +const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => { + const editor = useEditor(); + const extensionAPI = useExtensionAPI(); + const [results, setResults] = useState(null); + const [failed, setFailed] = useState(false); + const [pendingUids, setPendingUids] = useState([]); + const uid = shape.props.uid; + + useEffect(() => { + let cancelled = false; + setResults(null); + setFailed(false); + getDiscourseContextResults({ uid }) + .then((r) => { + if (!cancelled) setResults(r); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + }; + }, [uid]); + + const nodeShapesByUid = useValue( + "discourse-node-shapes-by-uid", + () => + new Map( + editor + .getCurrentPageShapes() + .filter((s): s is DiscourseNodeShape => + isDiscourseNodeShape(editor, s), + ) + .map((s) => [s.props.uid, s]), + ), + [editor], + ); + + const removeFromCanvas = (nodeShape: DiscourseNodeShape) => { + const util = editor.getShapeUtil(nodeShape); + if (util instanceof DiscourseNodeUtil) { + util.deleteRelationsInCanvas({ shape: nodeShape }); + } + editor.deleteShapes([nodeShape.id]); + }; + + const addToCanvas = async ({ + relatedUid, + text, + }: { + relatedUid: string; + text: string; + }) => { + if (!extensionAPI) return; + const node = findDiscourseNode({ uid: relatedUid }); + if (!node) { + dispatchToastEvent({ + id: "dg-context-tab-missing-node", + title: "Could not find a discourse node for this result.", + severity: "error", + }); + return; + } + const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({ + nodeText: text, + uid: relatedUid, + nodeType: node.type, + extensionAPI, + }); + const id = createShapeId(); + withAutoCanvasRelationsSuppressed(() => + editor.createShapes([ + { + id, + type: DISCOURSE_NODE_SHAPE_TYPE, + x: shape.x + shape.props.w + NEW_NODE_OFFSET_PX, + y: shape.y, + props: { + uid: relatedUid, + title: text, + w, + h, + ...(imageUrl && { imageUrl }), + size: "s", + fontFamily: "sans", + nodeTypeId: node.type, + }, + }, + ]), + ); + const created = editor.getShape(id); + if (!created) return; + const util = editor.getShapeUtil(created); + if (util instanceof DiscourseNodeUtil) { + await util.createExistingRelations({ shape: created }); + } + }; + + const toggleCanvasPresence = async ({ + relatedUid, + text, + }: { + relatedUid: string; + text: string; + }) => { + setPendingUids((prev) => [...prev, relatedUid]); + try { + const existing = nodeShapesByUid.get(relatedUid); + if (existing) { + removeFromCanvas(existing); + } else { + await addToCanvas({ relatedUid, text }); + } + } finally { + setPendingUids((prev) => prev.filter((u) => u !== relatedUid)); + } + }; + + if (failed) { + return
Failed to load relations.
; + } + if (results === null) { + return
Loading relations...
; + } + if (results.length === 0) { + return
No relations found.
; + } + + return ( +
+ {results.map((relation) => ( +
+
+ {relation.label} +
+
    + {Object.entries(relation.results).map(([relatedUid, result]) => { + const text = result.text ?? relatedUid; + const onCanvas = nodeShapesByUid.has(relatedUid); + return ( +
  • + + {text} + +
  • + ); + })} +
+
+ ))} +
+ ); +}; + +const NodeCardPanelContent = ({ shape }: { shape: DiscourseNodeShape }) => { + const styles = useRelevantStyles(); + const [activeTab, setActiveTab] = useState<"context" | "styling">("context"); + return ( +
+ + setActiveTab(tabId === "styling" ? "styling" : "context") + } + renderActiveTabPanelOnly + > + } + /> + } + /> + +
+ ); +}; + +export const CustomStylePanel = (props: TLUiStylePanelProps) => { + const editor = useEditor(); + const selectedNodeShape = useValue( + "selected-discourse-node-shape", + () => { + const selected = editor.getOnlySelectedShape(); + return selected && isDiscourseNodeShape(editor, selected) + ? selected + : null; + }, + [editor], + ); + if (!selectedNodeShape) return ; + return ( + + + + ); +}; diff --git a/apps/roam/src/components/canvas/tldrawStyles.ts b/apps/roam/src/components/canvas/tldrawStyles.ts index 03a5265e7..1fc754078 100644 --- a/apps/roam/src/components/canvas/tldrawStyles.ts +++ b/apps/roam/src/components/canvas/tldrawStyles.ts @@ -79,4 +79,10 @@ export default /* css */ ` background-color: var(--color-muted-2); opacity: 1; } + +/* Widen the style panel when it shows the node card Context/Styling tabs */ +.tlui-style-panel:has(.dg-node-style-panel) { + width: 280px; + max-width: 280px; +} `; diff --git a/apps/roam/src/components/canvas/uiOverrides.tsx b/apps/roam/src/components/canvas/uiOverrides.tsx index 111ebae41..d3d8cffd2 100644 --- a/apps/roam/src/components/canvas/uiOverrides.tsx +++ b/apps/roam/src/components/canvas/uiOverrides.tsx @@ -71,6 +71,7 @@ import { createOrUpdateArrowBinding } from "./DiscourseRelationShape/helpers"; import DiscourseGraphPanel from "./DiscourseToolPanel"; import type { CanvasNodeShortcuts } from "~/components/settings/utils/zodSchema"; import { CustomDefaultToolbar } from "./CustomDefaultToolbar"; +import { CustomStylePanel } from "./CustomStylePanel"; import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog"; import { CanvasSyncMode } from "./canvasSyncMode"; import { getPersonalSetting } from "~/components/settings/utils/accessors"; @@ -545,6 +546,7 @@ export const createUiComponents = ({ canvasSyncMode: CanvasSyncMode; }): TLUiComponents => { return { + StylePanel: CustomStylePanel, Toolbar: (props) => { const tools = useTools(); return ( From cc8000eff1cc1f67e871d9879e8f8ef18cae721e Mon Sep 17 00:00:00 2001 From: sid597 Date: Thu, 6 Aug 2026 00:05:27 +0530 Subject: [PATCH 2/3] Cascade added context nodes below column and toast on toggle failure --- .../components/canvas/CustomStylePanel.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/canvas/CustomStylePanel.tsx b/apps/roam/src/components/canvas/CustomStylePanel.tsx index 9832bd52b..59d28452d 100644 --- a/apps/roam/src/components/canvas/CustomStylePanel.tsx +++ b/apps/roam/src/components/canvas/CustomStylePanel.tsx @@ -24,6 +24,7 @@ import { import { dispatchToastEvent } from "./ToastListener"; const NEW_NODE_OFFSET_PX = 80; +const NEW_NODE_GAP_PX = 24; const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => { const editor = useEditor(); @@ -94,14 +95,23 @@ const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => { nodeType: node.type, extensionAPI, }); + const x = shape.x + shape.props.w + NEW_NODE_OFFSET_PX; + const columnBottoms = editor + .getCurrentPageShapes() + .filter((s): s is DiscourseNodeShape => isDiscourseNodeShape(editor, s)) + .filter((s) => s.x < x + w && s.x + s.props.w > x) + .map((s) => s.y + s.props.h); + const y = columnBottoms.length + ? Math.max(...columnBottoms) + NEW_NODE_GAP_PX + : shape.y; const id = createShapeId(); withAutoCanvasRelationsSuppressed(() => editor.createShapes([ { id, type: DISCOURSE_NODE_SHAPE_TYPE, - x: shape.x + shape.props.w + NEW_NODE_OFFSET_PX, - y: shape.y, + x, + y, props: { uid: relatedUid, title: text, @@ -138,6 +148,12 @@ const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => { } else { await addToCanvas({ relatedUid, text }); } + } catch { + dispatchToastEvent({ + id: "dg-context-tab-toggle-failed", + title: "Failed to update the canvas for this result.", + severity: "error", + }); } finally { setPendingUids((prev) => prev.filter((u) => u !== relatedUid)); } From a6443298365122372a66d58ad4792ad490a987df Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 10 Aug 2026 15:47:23 +0530 Subject: [PATCH 3/3] ENG-2096 Remove the discourse context overlay from Roam canvas --- .../components/canvas/DiscourseNodeUtil.tsx | 25 +------------------ .../settings/HomePersonalSettings.tsx | 13 ---------- .../components/settings/utils/accessors.ts | 4 --- .../components/settings/utils/settingKeys.ts | 1 - .../settings/utils/zodSchema.example.ts | 2 -- .../components/settings/utils/zodSchema.ts | 1 - apps/roam/src/data/userSettings.ts | 2 -- 7 files changed, 1 insertion(+), 47 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx b/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx index ddd45fbd1..5322374e7 100644 --- a/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx @@ -21,7 +21,7 @@ import { toDomPrecision, TLAnyShapeUtilConstructor, } from "tldraw"; -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useEffect, useRef, useMemo } from "react"; import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext"; import isLiveBlock from "roamjs-components/queries/isLiveBlock"; import updateBlock from "roamjs-components/writes/updateBlock"; @@ -41,7 +41,6 @@ import { loadImage } from "~/utils/loadImage"; import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil"; import { getPersonalSetting } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; -import DiscourseContextOverlay from "~/components/DiscourseContextOverlay"; import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors"; import { render as renderToast } from "roamjs-components/components/Toast"; import { RenderRoamBlockString } from "~/utils/roamReactComponents"; @@ -449,16 +448,9 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { const { canvasSettings: { alias = "", "key-image": isKeyImage = "" } = {}, } = discourseContext.nodes[getDiscourseNodeTypeId({ shape })] || {}; - // eslint-disable-next-line react-hooks/rules-of-hooks - const isOverlayEnabled = useMemo( - () => getPersonalSetting([PERSONAL_KEYS.overlayInCanvas]), - [], - ); const isEditing = this.editor.getEditingShapeId() === shape.id; // eslint-disable-next-line react-hooks/rules-of-hooks - const [overlayMounted, setOverlayMounted] = useState(false); - // eslint-disable-next-line react-hooks/rules-of-hooks const dialogRenderedRef = useRef(false); // Detect discourse node tags in block text for blck-node shapes @@ -613,7 +605,6 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { maxHeight: shape.props.h, boxSizing: "border-box", }} - onPointerEnter={() => setOverlayMounted(true)} >
{ fontSize: FONT_SIZES[shape.props.size], }} > - {overlayMounted && isOverlayEnabled && ( -
e.stopPropagation()} - > - -
- )} {showEmbeddedRoamBlock ? (
- { - void setSetting(DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY, checked); - }} - /> ([ [pathKey([PERSONAL_KEYS.disableSidebarOpen]), "disable-sidebar-open"], [pathKey([PERSONAL_KEYS.hideFeedbackButton]), "hide-feedback-button"], [pathKey([PERSONAL_KEYS.autoCanvasRelations]), "auto-canvas-relations"], - [ - pathKey([PERSONAL_KEYS.overlayInCanvas]), - "discourse-context-overlay-in-canvas", - ], [pathKey([PERSONAL_KEYS.streamlineStyling]), "streamline-styling"], [pathKey([PERSONAL_KEYS.disableProductDiagnostics]), "disallow-diagnostics"], [pathKey([PERSONAL_KEYS.discourseToolShortcut]), "discourse-tool-shortcut"], diff --git a/apps/roam/src/components/settings/utils/settingKeys.ts b/apps/roam/src/components/settings/utils/settingKeys.ts index 1dcec7bf0..ef5a46b45 100644 --- a/apps/roam/src/components/settings/utils/settingKeys.ts +++ b/apps/roam/src/components/settings/utils/settingKeys.ts @@ -23,7 +23,6 @@ export const PERSONAL_KEYS = { disableSidebarOpen: "Disable sidebar open", hideFeedbackButton: "Hide feedback button", autoCanvasRelations: "Auto canvas relations", - overlayInCanvas: "Overlay in canvas", streamlineStyling: "Streamline styling", disableProductDiagnostics: "Disable product diagnostics", discourseToolShortcut: "Discourse tool shortcut", diff --git a/apps/roam/src/components/settings/utils/zodSchema.example.ts b/apps/roam/src/components/settings/utils/zodSchema.example.ts index 58fc827da..68705d672 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.example.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.example.ts @@ -363,7 +363,6 @@ const personalSettings: PersonalSettings = { "Node search menu trigger": "//", "Discourse tool shortcut": { modifiers: 0, key: "d" }, "Discourse context overlay": true, - "Overlay in canvas": false, "Text selection popup": true, "Disable sidebar open": false, "Hide feedback button": false, @@ -395,7 +394,6 @@ const defaultPersonalSettings: PersonalSettings = { "Node search menu trigger": "", "Discourse tool shortcut": { modifiers: 0, key: "" }, "Discourse context overlay": false, - "Overlay in canvas": false, "Text selection popup": true, "Disable sidebar open": false, "Hide feedback button": false, diff --git a/apps/roam/src/components/settings/utils/zodSchema.ts b/apps/roam/src/components/settings/utils/zodSchema.ts index 7f6e0d592..9fb5cf7a3 100644 --- a/apps/roam/src/components/settings/utils/zodSchema.ts +++ b/apps/roam/src/components/settings/utils/zodSchema.ts @@ -248,7 +248,6 @@ export const PersonalSettingsSchema = z.object({ .default({ modifiers: 0, key: "" }), "Discourse context overlay": z.boolean().default(false), "Reified relation triples": z.boolean().default(true), - "Overlay in canvas": z.boolean().default(false), "Text selection popup": z.boolean().default(true), "Disable sidebar open": z.boolean().default(false), "Hide feedback button": z.boolean().default(false), diff --git a/apps/roam/src/data/userSettings.ts b/apps/roam/src/data/userSettings.ts index 9954dd3b3..6cc478438 100644 --- a/apps/roam/src/data/userSettings.ts +++ b/apps/roam/src/data/userSettings.ts @@ -6,8 +6,6 @@ export const QUERY_BUILDER_SETTINGS_KEY = "query-builder-settings"; export const AUTO_CANVAS_RELATIONS_KEY = "auto-canvas-relations"; export const DISCOURSE_TOOL_SHORTCUT_KEY = "discourse-tool-shortcut"; export const CANVAS_NODE_SHORTCUTS_KEY = "canvas-node-shortcuts"; -export const DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY = - "discourse-context-overlay-in-canvas"; export const STREAMLINE_STYLING_KEY = "streamline-styling"; export const DISALLOW_DIAGNOSTICS = "disallow-diagnostics"; export const USE_STORED_RELATIONS = "use-reified-relations"; // "use-reified-relations" is legacy terminology, but we keep it for backwards compatibility