diff --git a/apps/roam/src/components/canvas/CustomStylePanel.tsx b/apps/roam/src/components/canvas/CustomStylePanel.tsx new file mode 100644 index 000000000..2295e8c77 --- /dev/null +++ b/apps/roam/src/components/canvas/CustomStylePanel.tsx @@ -0,0 +1,384 @@ +import React, { useEffect, useMemo, 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 getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; +import type { DiscourseContextResults } from "~/components/DiscourseContext"; +import findDiscourseNode from "~/utils/findDiscourseNode"; +import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg"; +import { RenderRoamBlockString } from "~/utils/roamReactComponents"; +import { withAutoCanvasRelationsSuppressed } from "./autoCanvasRelationsSuppression"; +import { getAllRelations, isDiscourseNodeShape } from "./canvasUtils"; +import { + DISCOURSE_NODE_SHAPE_TYPE, + DiscourseNodeShape, +} from "./DiscourseNodeUtil"; +import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil"; +import { + getParallelArrowBend, + getRelationArrowsBetween, +} from "./DiscourseRelationShape/helpers"; +import { dispatchToastEvent } from "./ToastListener"; + +const NEW_NODE_OFFSET_PX = 80; +const NEW_NODE_GAP_PX = 24; + +const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => { + const editor = useEditor(); + const extensionAPI = useExtensionAPI(); + const [results, setResults] = useState(null); + const [failed, setFailed] = useState(false); + const [pendingKeys, setPendingKeys] = 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 getNodeShapeByUid = (relatedUid: string) => + editor + .getCurrentPageShapes() + .filter((s): s is DiscourseNodeShape => isDiscourseNodeShape(editor, s)) + .find((s) => s.props.uid === relatedUid); + + const relationIdsInResults = useMemo( + () => + new Set( + (results ?? []).flatMap((relation) => + Object.values(relation.results).flatMap((result) => + result.id ? [result.id] : [], + ), + ), + ), + [results], + ); + + const arrowKeysOnCanvas = useValue( + "discourse-relation-arrow-keys", + () => { + const keys = new Set(); + relationIdsInResults.forEach((relationId) => { + editor.getBindingsToShape(shape.id, relationId).forEach((binding) => { + const farBinding = editor + .getBindingsFromShape(binding.fromId, relationId) + .find((b) => b.toId !== shape.id); + if (!farBinding) return; + const farShape = editor.getShape(farBinding.toId); + if (farShape && isDiscourseNodeShape(editor, farShape)) { + keys.add(`${relationId}:${farShape.props.uid}`); + } + }); + }); + return keys; + }, + [editor, shape.id, relationIdsInResults], + ); + + const addNodeToCanvas = async ({ + relatedUid, + text, + }: { + relatedUid: string; + text: string; + }): Promise => { + if (!extensionAPI) return undefined; + 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 undefined; + } + const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({ + nodeText: text, + uid: relatedUid, + nodeType: node.type, + extensionAPI, + }); + const existing = getNodeShapeByUid(relatedUid); + if (existing) return existing; + 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, + y, + props: { + uid: relatedUid, + title: text, + w, + h, + ...(imageUrl && { imageUrl }), + size: "s", + fontFamily: "sans", + nodeTypeId: node.type, + }, + }, + ]), + ); + return editor.getShape(id); + }; + + const addRelationToCanvas = async ({ + relationId, + complement, + relatedUid, + text, + label, + }: { + relationId: string; + complement: boolean; + relatedUid: string; + text: string; + label: string; + }) => { + const nodeShape = + getNodeShapeByUid(relatedUid) ?? + (await addNodeToCanvas({ relatedUid, text })); + if (!nodeShape) return; + const alreadyOnCanvas = getRelationArrowsBetween({ + editor, + shapeId: shape.id, + otherShapeId: nodeShape.id, + relationIds: new Set([relationId]), + }); + if (alreadyOnCanvas.length) return; + const startId = complement ? nodeShape.id : shape.id; + const endId = complement ? shape.id : nodeShape.id; + const { bend } = getParallelArrowBend({ + editor, + startShapeId: startId, + endShapeId: endId, + relationIds: new Set(getAllRelations().map((r) => r.id)), + }); + const arrowId = createShapeId(); + editor + .createShapes([ + { + id: arrowId, + type: relationId, + props: { color: getRelationColor(label), bend }, + }, + ]) + .createBindings([ + { + type: relationId, + fromId: arrowId, + toId: startId, + props: { terminal: "start" }, + }, + { + type: relationId, + fromId: arrowId, + toId: endId, + props: { terminal: "end" }, + }, + ]); + }; + + const toggleRelationOnCanvas = async ({ + relationId, + complement, + relatedUid, + text, + label, + }: { + relationId: string; + complement: boolean; + relatedUid: string; + text: string; + label: string; + }) => { + const key = `${relationId}:${relatedUid}`; + setPendingKeys((prev) => [...prev, key]); + try { + const nodeShape = getNodeShapeByUid(relatedUid); + const existingArrows = nodeShape + ? getRelationArrowsBetween({ + editor, + shapeId: shape.id, + otherShapeId: nodeShape.id, + relationIds: new Set([relationId]), + }) + : []; + if (existingArrows.length) { + const bindingIds = existingArrows + .flatMap((arrow) => editor.getBindingsFromShape(arrow.id, relationId)) + .map((b) => b.id); + editor + .deleteShapes(existingArrows.map((a) => a.id)) + .deleteBindings(bindingIds); + } else { + await addRelationToCanvas({ + relationId, + complement, + relatedUid, + text, + label, + }); + } + } catch { + dispatchToastEvent({ + id: "dg-context-tab-toggle-failed", + title: "Failed to update the canvas for this result.", + severity: "error", + }); + } finally { + setPendingKeys((prev) => prev.filter((k) => k !== key)); + } + }; + + 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 relationId = result.id; + const key = `${relationId}:${relatedUid}`; + const onCanvas = !!relationId && arrowKeysOnCanvas.has(key); + return ( +
  • + + + + {relationId && ( +
  • + ); + })} +
+
+ ))} +
+ ); +}; + +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/DiscourseNodeUtil.tsx b/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx index 6a5733800..aefeb631a 100644 --- a/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx @@ -36,7 +36,10 @@ import { getCleanTagText } from "~/components/settings/NodeConfig"; import { discourseContext } from "./Tldraw"; import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg"; -import { createTextJsxFromSpans } from "./DiscourseRelationShape/helpers"; +import { + createTextJsxFromSpans, + getParallelArrowBend, +} from "./DiscourseRelationShape/helpers"; import { loadImage } from "~/utils/loadImage"; import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil"; import { getPersonalSetting } from "~/components/settings/utils/accessors"; @@ -316,10 +319,27 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { return { relationId, complement, nodeId, arrowId, label }; }); + const allRelationIds = getRelationIds(); + const reservedBendsByPair = new Map(); const shapesToCreate = toCreate.map( - ({ relationId, arrowId, label }, index) => { + ({ relationId, complement, nodeId, arrowId, label }, index) => { const color = getRelationColor(label, index); - return { id: arrowId, type: relationId, props: { color } }; + const startId = complement ? nodesInCanvas[nodeId].id : shape.id; + const endId = complement ? shape.id : nodesInCanvas[nodeId].id; + const pairKey = [startId, endId].sort().join(":"); + const reservedCanonicalBends = reservedBendsByPair.get(pairKey) ?? []; + const { bend, canonicalBend } = getParallelArrowBend({ + editor, + startShapeId: startId, + endShapeId: endId, + relationIds: allRelationIds, + reservedCanonicalBends, + }); + reservedBendsByPair.set(pairKey, [ + ...reservedCanonicalBends, + canonicalBend, + ]); + return { id: arrowId, type: relationId, props: { color, bend } }; }, ); diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/helpers.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/helpers.tsx index 96aa967b1..d591f7bde 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/helpers.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/helpers.tsx @@ -150,6 +150,83 @@ export function getArrowBindings( end: bindings.find((b) => b.props.terminal === "end"), }; } +const PARALLEL_ARROW_BEND_PX = 40; +export function getRelationArrowsBetween({ + editor, + shapeId, + otherShapeId, + relationIds, +}: { + editor: Editor; + shapeId: TLShapeId; + otherShapeId: TLShapeId; + relationIds: Set; +}): DiscourseRelationShape[] { + return Array.from(relationIds).flatMap((relationId) => + editor + .getBindingsToShape(shapeId, relationId) + .map((binding) => binding.fromId) + .filter((arrowId) => + editor + .getBindingsFromShape(arrowId, relationId) + .some((binding) => binding.toId === otherShapeId), + ) + .flatMap((arrowId) => { + const arrow = editor.getShape(arrowId); + return arrow ? [arrow] : []; + }), + ); +} +export function getParallelArrowBend({ + editor, + startShapeId, + endShapeId, + relationIds, + reservedCanonicalBends = [], +}: { + editor: Editor; + startShapeId: TLShapeId; + endShapeId: TLShapeId; + relationIds: Set; + reservedCanonicalBends?: number[]; +}): { bend: number; canonicalBend: number } { + // Canonical frame: bend as seen from the lexicographically lower shape id, + // so arrows in opposite directions still land on distinct visual arcs. + const lowerShapeId = startShapeId < endShapeId ? startShapeId : endShapeId; + const existingCanonicalBends = getRelationArrowsBetween({ + editor, + shapeId: startShapeId, + otherShapeId: endShapeId, + relationIds, + }).map((arrow) => { + const startBinding = editor + .getBindingsFromShape(arrow.id, arrow.type) + .find((binding) => binding.props.terminal === "start"); + return startBinding?.toId === lowerShapeId + ? arrow.props.bend + : -arrow.props.bend; + }); + const occupied = [...existingCanonicalBends, ...reservedCanonicalBends]; + const slotOffset = (slot: number) => + slot === 0 + ? 0 + : Math.ceil(slot / 2) * + PARALLEL_ARROW_BEND_PX * + (slot % 2 === 1 ? 1 : -1); + let slot = 0; + while ( + occupied.some( + (bend) => Math.abs(bend - slotOffset(slot)) < PARALLEL_ARROW_BEND_PX / 2, + ) + ) { + slot += 1; + } + const canonicalBend = slotOffset(slot); + return { + bend: startShapeId === lowerShapeId ? canonicalBend : -canonicalBend, + canonicalBend, + }; +} function getStraightArrowInfo( editor: Editor, relation: DiscourseRelationShape, diff --git a/apps/roam/src/components/canvas/tldrawStyles.ts b/apps/roam/src/components/canvas/tldrawStyles.ts index 03a5265e7..81cc2f561 100644 --- a/apps/roam/src/components/canvas/tldrawStyles.ts +++ b/apps/roam/src/components/canvas/tldrawStyles.ts @@ -79,4 +79,14 @@ 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; +} + +.dg-node-style-panel .bp3-tab-list { + justify-content: center; +} `; 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 (