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 (