From 895292bfc7e4cb2ecc4c2bfda4464ecf81841e49 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 4 Sep 2025 00:41:10 -0400 Subject: [PATCH] ENG-605: Add new relation flow --- .../canvas/DiscourseRelationTool.ts | 344 ++++++++++++++++++ ...seNodePanel.tsx => DiscourseToolPanel.tsx} | 99 ++++- .../components/canvas/TldrawViewComponent.tsx | 51 ++- .../src/components/canvas/ToastListener.tsx | 49 +++ .../canvas/shapes/DiscourseNodeShape.tsx | 1 - .../shapes/DiscourseRelationBinding.tsx | 51 ++- .../canvas/shapes/DiscourseRelationShape.tsx | 94 ++++- .../canvas/utils/frontmatterUtils.ts | 52 +++ .../canvas/utils/nodeCreationFlow.ts | 9 +- .../src/components/canvas/utils/toastUtils.ts | 21 ++ apps/obsidian/src/constants.ts | 5 +- .../components/canvas/DiscourseToolPanel.tsx | 12 +- .../roam/src/components/settings/Settings.tsx | 2 +- .../public/apps/assets/node-color-icon.svg | 1 + .../public/apps/assets/tool-arrow-icon.svg | 1 + 15 files changed, 760 insertions(+), 32 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/DiscourseRelationTool.ts rename apps/obsidian/src/components/canvas/{DiscourseNodePanel.tsx => DiscourseToolPanel.tsx} (75%) create mode 100644 apps/obsidian/src/components/canvas/ToastListener.tsx create mode 100644 apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts create mode 100644 apps/obsidian/src/components/canvas/utils/toastUtils.ts create mode 100644 apps/website/public/apps/assets/node-color-icon.svg create mode 100644 apps/website/public/apps/assets/tool-arrow-icon.svg diff --git a/apps/obsidian/src/components/canvas/DiscourseRelationTool.ts b/apps/obsidian/src/components/canvas/DiscourseRelationTool.ts new file mode 100644 index 000000000..94c9896f7 --- /dev/null +++ b/apps/obsidian/src/components/canvas/DiscourseRelationTool.ts @@ -0,0 +1,344 @@ +import { StateNode, TLEventHandlers, TLStateNodeConstructor } from "@tldraw/editor"; +import { createShapeId } from "tldraw"; +import type { TFile } from "obsidian"; +import DiscourseGraphPlugin from "~/index"; +import { getRelationTypeById } from "./utils/relationUtils"; +import { DiscourseRelationShape } from "./shapes/DiscourseRelationShape"; +import { getNodeTypeById } from "~/utils/utils"; +import { showToast } from "./utils/toastUtils"; + +type RelationToolContext = { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + relationTypeId: string; + onRelationComplete?: () => void; +} | null; + +let relationToolContext: RelationToolContext = null; + +export const setDiscourseRelationToolContext = ( + args: RelationToolContext, +): void => { + relationToolContext = args; +}; + +export const clearDiscourseRelationToolContext = (): void => { + relationToolContext = null; +}; + +export class DiscourseRelationTool extends StateNode { + static override id = "discourse-relation"; + static override initial = "idle"; + static override children = (): TLStateNodeConstructor[] => [Idle, Pointing]; + + override onEnter = () => { + this.editor.setCursor({ type: "cross" }); + }; +} + +class Idle extends StateNode { + static override id = "idle"; + + override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => { + this.parent.transition("pointing", info); + }; + + override onEnter = () => { + this.editor.setCursor({ type: "cross", rotation: 0 }); + }; + + override onCancel = () => { + this.editor.setCurrentTool("select"); + }; + + override onKeyUp: TLEventHandlers["onKeyUp"] = (info) => { + if (info.key === "Enter") { + if (this.editor.getInstanceState().isReadonly) return null; + const onlySelectedShape = this.editor.getOnlySelectedShape(); + // If the only selected shape is editable, start editing it + if ( + onlySelectedShape && + this.editor.getShapeUtil(onlySelectedShape).canEdit(onlySelectedShape) + ) { + this.editor.setCurrentTool("select"); + this.editor.setEditingShape(onlySelectedShape.id); + this.editor.root.getCurrent()?.transition("editing_shape", { + ...info, + target: "shape", + shape: onlySelectedShape, + }); + } + } + }; +} + +class Pointing extends StateNode { + static override id = "pointing"; + shape?: DiscourseRelationShape; + markId = ""; + + private showWarning = (message: string) => { + showToast({ + severity: "warning", + title: "Relation Tool", + description: message, + }); + this.cancel(); + }; + + private getCompatibleNodeTypes = ( + plugin: DiscourseGraphPlugin, + relationTypeId: string, + sourceNodeTypeId: string, + ): string[] => { + const compatibleTypes: string[] = []; + + // Find all discourse relations that match the relation type and source + const relations = plugin.settings.discourseRelations.filter( + (relation) => + relation.relationshipTypeId === relationTypeId && + relation.sourceId === sourceNodeTypeId, + ); + + relations.forEach((relation) => { + compatibleTypes.push(relation.destinationId); + }); + + // Also check reverse relations (where current node is destination) + const reverseRelations = plugin.settings.discourseRelations.filter( + (relation) => + relation.relationshipTypeId === relationTypeId && + relation.destinationId === sourceNodeTypeId, + ); + + reverseRelations.forEach((relation) => { + compatibleTypes.push(relation.sourceId); + }); + + return [...new Set(compatibleTypes)]; // Remove duplicates + }; + + override onEnter = () => { + this.didTimeout = false; + + const target = this.editor.getShapeAtPoint( + this.editor.inputs.currentPagePoint, + ); + + if (!relationToolContext) { + this.showWarning("No relation type selected"); + return; + } + + const plugin = relationToolContext.plugin; + const relationTypeId = relationToolContext.relationTypeId; + + // Validate source node + if (!target || target.type !== "discourse-node") { + this.showWarning("Must start on a discourse node"); + return; + } + + const sourceNodeTypeId = (target as { props?: { nodeTypeId?: string } }) + .props?.nodeTypeId; + if (!sourceNodeTypeId) { + this.showWarning("Source node must have a valid node type"); + return; + } + + // Check if this source node type can create relations of this type + if (sourceNodeTypeId) { + const compatibleTargetTypes = this.getCompatibleNodeTypes( + plugin, + relationTypeId, + sourceNodeTypeId, + ); + + if (compatibleTargetTypes.length === 0) { + const sourceNodeType = getNodeTypeById(plugin, sourceNodeTypeId); + const relationType = getRelationTypeById(plugin, relationTypeId); + this.showWarning( + `Node type "${sourceNodeType?.name}" cannot create "${relationType?.label}" relations`, + ); + return; + } + } + + if (!target) { + this.createArrowShape(); + } else { + this.editor.setHintingShapes([target.id]); + } + + this.startPreciseTimeout(); + }; + + override onExit = () => { + this.shape = undefined; + this.editor.setHintingShapes([]); + this.clearPreciseTimeout(); + }; + + override onPointerMove: TLEventHandlers["onPointerMove"] = () => { + if (this.editor.inputs.isDragging) { + if (!this.shape) { + this.createArrowShape(); + } + + if (!this.shape) throw Error(`expected shape`); + + this.updateArrowShapeEndHandle(); + + this.editor.setCurrentTool("select.dragging_handle", { + shape: this.shape, + handle: { id: "end", type: "vertex", index: "a3", x: 0, y: 0 }, + isCreating: true, + onInteractionEnd: "select", + }); + } + }; + + override onPointerUp: TLEventHandlers["onPointerUp"] = () => { + this.cancel(); + }; + + override onCancel: TLEventHandlers["onCancel"] = () => { + this.cancel(); + }; + + override onComplete: TLEventHandlers["onComplete"] = () => { + this.cancel(); + }; + + override onInterrupt: TLEventHandlers["onInterrupt"] = () => { + this.cancel(); + }; + + cancel() { + if (this.shape) { + // the arrow might not have been created yet! + this.editor.bailToMark(this.markId); + } + this.editor.setHintingShapes([]); + this.parent.transition("idle"); + } + + createArrowShape() { + const { originPagePoint } = this.editor.inputs; + + const id = createShapeId(); + + this.markId = `creating:${id}`; + this.editor.mark(this.markId); + + if (!relationToolContext) { + this.showWarning("Must start on a node"); + return; + } + + const relationType = getRelationTypeById( + relationToolContext.plugin, + relationToolContext.relationTypeId, + ); + + this.editor.createShape({ + id, + type: "discourse-relation", + x: originPagePoint.x, + y: originPagePoint.y, + props: { + relationTypeId: relationToolContext.relationTypeId, + text: relationType?.label ?? "", + scale: this.editor.user.getIsDynamicResizeMode() + ? 1 / this.editor.getZoomLevel() + : 1, + }, + }); + + const shape = this.editor.getShape(id); + if (!shape) throw Error(`expected shape`); + + const handles = this.editor.getShapeHandles(shape); + if (!handles) throw Error(`expected handles for arrow`); + + const util = + this.editor.getShapeUtil("discourse-relation"); + const initial = this.shape; + const startHandle = handles.find((h) => h.id === "start")!; + const change = util.onHandleDrag?.(shape, { + handle: { ...startHandle, x: 0, y: 0 }, + isPrecise: true, + initial: initial, + }); + + if (change) { + this.editor.updateShapes([change]); + } + + // Cache the current shape after those changes + this.shape = this.editor.getShape(id); + this.editor.select(id); + } + + updateArrowShapeEndHandle() { + const shape = this.shape; + + if (!shape) throw Error(`expected shape`); + + const handles = this.editor.getShapeHandles(shape); + if (!handles) throw Error(`expected handles for arrow`); + + // start update + { + const util = + this.editor.getShapeUtil("discourse-relation"); + const initial = this.shape; + const startHandle = handles.find((h) => h.id === "start")!; + const change = util.onHandleDrag?.(shape, { + handle: { ...startHandle, x: 0, y: 0 }, + isPrecise: this.didTimeout, + initial: initial, + }); + + if (change) { + this.editor.updateShapes([change]); + } + } + + // end update + { + const util = + this.editor.getShapeUtil("discourse-relation"); + const initial = this.shape; + const point = this.editor.getPointInShapeSpace( + shape, + this.editor.inputs.currentPagePoint, + ); + const endHandle = handles.find((h) => h.id === "end")!; + const change = util.onHandleDrag?.(this.editor.getShape(shape)!, { + handle: { ...endHandle, x: point.x, y: point.y }, + isPrecise: false, + initial: initial, + }); + + if (change) { + this.editor.updateShapes([change]); + } + } + + // Cache the current shape after those changes + this.shape = this.editor.getShape(shape.id); + } + + public preciseTimeout = -1; + public didTimeout = false; + public startPreciseTimeout() { + this.preciseTimeout = this.editor.timers.setTimeout(() => { + if (!this.getIsActive()) return; + this.didTimeout = true; + }, 320); + } + public clearPreciseTimeout() { + clearTimeout(this.preciseTimeout); + } +} \ No newline at end of file diff --git a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx b/apps/obsidian/src/components/canvas/DiscourseToolPanel.tsx similarity index 75% rename from apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx rename to apps/obsidian/src/components/canvas/DiscourseToolPanel.tsx index 25d9f37c1..15015b060 100644 --- a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx +++ b/apps/obsidian/src/components/canvas/DiscourseToolPanel.tsx @@ -14,8 +14,13 @@ import { getNodeTypeById } from "~/utils/utils"; import { useEffect } from "react"; import { setDiscourseNodeToolContext } from "./DiscourseNodeTool"; import { ExistingNodeSearch } from "./ExistingNodeSearch"; +import { + setDiscourseRelationToolContext, + clearDiscourseRelationToolContext, +} from "./DiscourseRelationTool"; +import { TOOL_ARROW_ICON_URL } from "~/constants"; -export const DiscourseNodePanel = ({ +export const DiscourseToolPanel = ({ plugin, canvasFile, }: { @@ -29,6 +34,9 @@ export const DiscourseNodePanel = ({ const [focusedNodeTypeId, setFocusedNodeTypeId] = React.useState< string | undefined >(undefined); + const [focusedRelationTypeId, setFocusedRelationTypeId] = React.useState< + string | undefined + >(undefined); type DragState = | { name: "idle" } @@ -185,6 +193,7 @@ export const DiscourseNodePanel = ({ ); const nodeTypes = plugin.settings.nodeTypes; + const relationTypes = plugin.settings.relationTypes; useEffect(() => { if (!focusedNodeTypeId) return; @@ -196,7 +205,18 @@ export const DiscourseNodePanel = ({ ? getNodeTypeById(plugin, focusedNodeTypeId) : null; - const displayNodeTypes = focusedNodeType ? [focusedNodeType] : nodeTypes; + // Only show the focused item, hide everything else when something is focused + const displayNodeTypes = focusedNodeTypeId + ? [focusedNodeType!] + : focusedRelationTypeId + ? [] + : nodeTypes; + + const displayRelationTypes = focusedRelationTypeId + ? [relationTypes.find((rel) => rel.id === focusedRelationTypeId)!] + : focusedNodeTypeId + ? [] + : relationTypes; useEffect(() => { const cursor = focusedNodeTypeId ? "cross" : "default"; @@ -208,13 +228,40 @@ export const DiscourseNodePanel = ({ const handleItemClick = (id: string) => { if (didDragRef.current) return; - if (focusedNodeTypeId) { - setFocusedNodeTypeId(undefined); - return; + + // Clear other focus and toggle this one + setFocusedRelationTypeId(undefined); + clearDiscourseRelationToolContext(); + const shouldUnfocus = focusedNodeTypeId === id; + setFocusedNodeTypeId(shouldUnfocus ? undefined : id); + + if (!shouldUnfocus) { + setDiscourseNodeToolContext({ plugin, canvasFile, nodeTypeId: id }); + editor.setCurrentTool("discourse-node"); + } + }; + + const handleCreateRelationClick = (relationTypeId: string) => { + // Clear other focus and toggle this one + setFocusedNodeTypeId(undefined); + const shouldUnfocus = focusedRelationTypeId === relationTypeId; + setFocusedRelationTypeId(shouldUnfocus ? undefined : relationTypeId); + + if (shouldUnfocus) { + clearDiscourseRelationToolContext(); + editor.setCurrentTool("select"); + } else { + setDiscourseRelationToolContext({ + plugin, + canvasFile, + relationTypeId, + onRelationComplete: () => { + setFocusedRelationTypeId(undefined); + editor.setCurrentTool("select"); + }, + }); + editor.setCurrentTool("discourse-relation"); } - setFocusedNodeTypeId(id); - setDiscourseNodeToolContext({ plugin, canvasFile, nodeTypeId: id }); - editor.setCurrentTool("discourse-node"); }; return ( @@ -226,7 +273,10 @@ export const DiscourseNodePanel = ({ nodeTypeId={focusedNodeTypeId} />
-
+
{displayNodeTypes.map((nodeType) => ( handleItemClick(nodeType.id)} /> ))} + {displayRelationTypes.map((rel) => ( + handleCreateRelationClick(rel.id)} + /> + ))}
{state.name === "dragging" @@ -294,3 +351,27 @@ type NodeTypeButtonProps = { didDragRef: React.MutableRefObject; onClickNoDrag: () => void; }; + +const RelationTypeButton = ({ + relationType, + onClick, +}: { + relationType: { id: string; label: string }; + onClick: () => void; +}) => { + return ( + + ); +}; diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 54fdefbea..a996a8994 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -12,6 +12,7 @@ import { useIsToolSelected, useTools, defaultBindingUtils, + TLPointerEventInfo, } from "tldraw"; import "tldraw/tldraw.css"; import { @@ -31,10 +32,15 @@ import { TFile } from "obsidian"; import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; import { createDiscourseNodeUtil } from "~/components/canvas/shapes/DiscourseNodeShape"; import { DiscourseNodeTool } from "./DiscourseNodeTool"; -import { DiscourseNodePanel } from "./DiscourseNodePanel"; +import { DiscourseToolPanel } from "./DiscourseToolPanel"; import { usePlugin } from "~/components/PluginContext"; import { createDiscourseRelationUtil } from "~/components/canvas/shapes/DiscourseRelationShape"; -import { DiscourseRelationBindingUtil } from "~/components/canvas/shapes/DiscourseRelationBinding"; +import { DiscourseRelationTool } from "./DiscourseRelationTool"; +import { + DiscourseRelationBindingUtil, + BaseRelationBindingUtil, +} from "~/components/canvas/shapes/DiscourseRelationBinding"; +import ToastListener from "./ToastListener"; interface TldrawPreviewProps { store: TLStore; @@ -42,6 +48,8 @@ interface TldrawPreviewProps { assetStore: ObsidianTLAssetStore; } +// No longer needed - using tldraw's event system instead + export const TldrawPreviewComponent = ({ store, file, @@ -50,6 +58,7 @@ export const TldrawPreviewComponent = ({ const containerRef = useRef(null); const [currentStore, setCurrentStore] = useState(store); const [isReady, setIsReady] = useState(false); + const isCreatingRelationRef = useRef(false); const saveTimeoutRef = useRef(); const lastSavedDataRef = useRef(""); const editorRef = useRef(); @@ -69,7 +78,7 @@ export const TldrawPreviewComponent = ({ }), ]; - const customTools = [DiscourseNodeTool]; + const customTools = [DiscourseNodeTool, DiscourseRelationTool]; const iconUrl = `data:image/svg+xml;utf8,${encodeURIComponent(WHITE_LOGO_SVG)}`; @@ -163,6 +172,25 @@ export const TldrawPreviewComponent = ({ const handleMount = (editor: Editor) => { editorRef.current = editor; + + editor.on("event", (event) => { + const e = event as TLPointerEventInfo; + if (e.type === "pointer" && e.name === "pointer_down") { + const currentTool = editor.getCurrentTool(); + const currentToolId = currentTool.id; + + if (currentToolId === "discourse-relation") { + isCreatingRelationRef.current = true; + } + } + + if (e.type === "pointer" && e.name === "pointer_up") { + if (isCreatingRelationRef.current) { + BaseRelationBindingUtil.checkAndReifyRelation(editor); + isCreatingRelationRef.current = false; + } + } + }); }; return ( @@ -200,6 +228,15 @@ export const TldrawPreviewComponent = ({ editor.setCurrentTool("discourse-node"); }, }; + tools["discourse-relation"] = { + id: "discourse-relation", + label: "Discourse Relation", + readonlyOk: false, + icon: "tool-arrow", + onSelect: () => { + editor.setCurrentTool("discourse-relation"); + }, + }; return tools; }, }} @@ -209,13 +246,17 @@ export const TldrawPreviewComponent = ({ const isDiscourseNodeSelected = useIsToolSelected( tools["discourse-node"], ); + const isDiscourseRelationSelected = useIsToolSelected( + tools["discourse-relation"], + ); - if (!isDiscourseNodeSelected) { + if (!isDiscourseNodeSelected && !isDiscourseRelationSelected) { return ; } - return ; + return ; }, + OnTheCanvas: () => , Toolbar: (props) => { const tools = useTools(); const isDiscourseNodeSelected = useIsToolSelected( diff --git a/apps/obsidian/src/components/canvas/ToastListener.tsx b/apps/obsidian/src/components/canvas/ToastListener.tsx new file mode 100644 index 000000000..fd4b85e46 --- /dev/null +++ b/apps/obsidian/src/components/canvas/ToastListener.tsx @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useToasts, TLUiToast } from "tldraw"; + +export const dispatchToastEvent = (toast: TLUiToast) => { + document.dispatchEvent( + new CustomEvent("show-toast", { detail: toast }), + ); +}; + +const ToastListener = () => { + // this warning comes from the useToasts hook + // eslint-disable-next-line @typescript-eslint/unbound-method + const { addToast } = useToasts(); + + useEffect(() => { + const handleToastEvent = ((event: CustomEvent) => { + const { + id, + icon, + title, + description, + actions, + keepOpen, + closeLabel, + severity, + } = event.detail; + addToast({ + id, + icon, + title, + description, + actions, + keepOpen, + closeLabel, + severity, + }); + }) as EventListener; + + document.addEventListener("show-toast", handleToastEvent); + + return () => { + document.removeEventListener("show-toast", handleToastEvent); + }; + }, [addToast]); + + return null; +}; + +export default ToastListener; diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx index cdadad9f2..5682b33a8 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -44,7 +44,6 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { src: T.string.nullable(), title: T.string.optional(), nodeTypeId: T.string.nullable().optional(), - nodeTypeName: T.string.optional(), }; getDefaultProps(): DiscourseNodeShape["props"] { diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx index 76fe215c4..747354226 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx @@ -25,7 +25,10 @@ import { approximately, BindingOnShapeDeleteOptions, } from "tldraw"; -import { DiscourseRelationShape } from "./DiscourseRelationShape"; +import { + DiscourseRelationShape, + DiscourseRelationUtil, +} from "./DiscourseRelationShape"; import { assert, getArrowBindings, @@ -62,6 +65,7 @@ export type RelationInfo = export type RelationBinding = TLBaseBinding; export class BaseRelationBindingUtil extends BindingUtil { static override props = arrowBindingProps; + private static reifiedArrows = new Set(); override getDefaultProps(): Partial { return { @@ -76,10 +80,10 @@ export class BaseRelationBindingUtil extends BindingUtil { override onAfterCreate({ binding, }: BindingOnCreateOptions): void { - arrowDidUpdate( - this.editor, - this.editor.getShape(binding.fromId) as DiscourseRelationShape, - ); + const arrow = this.editor.getShape( + binding.fromId, + ) as DiscourseRelationShape; + arrowDidUpdate(this.editor, arrow); } // when the binding itself changes @@ -113,6 +117,8 @@ export class BaseRelationBindingUtil extends BindingUtil { const arrow = this.editor.getShape(binding.fromId); if (!arrow) return; // this.editor.deleteShape(arrow.id); // we don't want to keep the arrow + // Clean up tracking when arrow becomes unbound + BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); updateArrowTerminal({ editor: this.editor, @@ -127,7 +133,40 @@ export class BaseRelationBindingUtil extends BindingUtil { const arrow = this.editor.getShape(binding.fromId); // if toShape is deleted, delete the arrow // we don't want any unbound arrows hanging around - if (arrow) this.editor.deleteShape(arrow.id); + if (arrow) { + BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); + this.editor.deleteShape(arrow.id); + } + } + + /** + * Check selected relation shapes for completed bindings + * Called from mouseup event handler + */ + static checkAndReifyRelation(editor: Editor): void { + const selectedShapes = editor.getSelectedShapes(); + const relationShapes = selectedShapes.filter( + (shape) => shape.type === "discourse-relation", + ) as DiscourseRelationShape[]; + + relationShapes.forEach((arrow) => { + const bindings = getArrowBindings(editor, arrow); + if ( + bindings.start && + bindings.end && + !BaseRelationBindingUtil.reifiedArrows.has(arrow.id) + ) { + BaseRelationBindingUtil.reifiedArrows.add(arrow.id); + const util = editor.getShapeUtil(arrow); + if (util instanceof DiscourseRelationUtil) { + util.reifyRelationInFrontmatter(arrow, bindings).catch((error) => { + console.error("Failed to reify relation in frontmatter:", error); + // Remove from reified set on error so it can be retried + BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); + }); + } + } + }); } } diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx index 9a3df812b..a161a0d85 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx @@ -31,7 +31,7 @@ import { TEXT_PROPS, TextLabel, } from "tldraw"; -import { Notice, type App, type TFile } from "obsidian"; +import { type App, type TFile } from "obsidian"; import DiscourseGraphPlugin from "~/index"; import { ARROW_HANDLES, @@ -58,6 +58,9 @@ import { updateArrowTerminal, } from "~/components/canvas/utils/relationUtils"; import { RelationBindings } from "./DiscourseRelationBinding"; +import { DiscourseNodeShape, DiscourseNodeUtil } from "./DiscourseNodeShape"; +import { addRelationToFrontmatter } from "~/components/canvas/utils/frontmatterUtils"; +import { showToast } from "~/components/canvas/utils/toastUtils"; export enum ArrowHandles { start = "start", @@ -375,9 +378,13 @@ export class DiscourseRelationUtil extends ShapeUtil { (rt) => rt.id === shape.props.relationTypeId, ); - // Show error notice and delete the entire relation shape + // Show error toast and delete the entire relation shape const errorMessage = `Cannot connect "${sourceNodeType?.name}" to "${targetNodeType?.name}" with "${relationType?.label}" relation`; - new Notice(errorMessage, 3000); + showToast({ + severity: "error", + title: "Invalid Connection", + description: errorMessage, + }); // Remove binding and return without creating connection removeArrowBinding(this.editor, shape, handleId); @@ -941,6 +948,7 @@ export class DiscourseRelationUtil extends ShapeUtil { ]); } } + override toSvg(shape: DiscourseRelationShape, ctx: SvgExportContext) { ctx.addExportDef(getFillDefForExport(shape.props.fill)); if (shape.props.text) @@ -1075,6 +1083,86 @@ export class DiscourseRelationUtil extends ShapeUtil { return reverseConnection; } + + /** + * Reifies the relation in the frontmatter of both connected files. + * This creates the bidirectional links that make the relation persistent. + */ + async reifyRelationInFrontmatter( + shape: DiscourseRelationShape, + bindings: RelationBindings, + ): Promise { + if (!bindings.start || !bindings.end || !shape.props.relationTypeId) { + return; + } + + try { + const startNode = this.editor.getShape(bindings.start.toId); + const endNode = this.editor.getShape(bindings.end.toId); + + if ( + !startNode || + !endNode || + startNode.type !== "discourse-node" || + endNode.type !== "discourse-node" + ) { + return; + } + + const startNodeUtil = this.editor.getShapeUtil(startNode); + const endNodeUtil = this.editor.getShapeUtil(endNode); + + // Get the files associated with both nodes + const sourceFile = await (startNodeUtil as DiscourseNodeUtil).getFile( + startNode as DiscourseNodeShape, + { + app: this.options.app, + canvasFile: this.options.canvasFile, + }, + ); + const targetFile = await (endNodeUtil as DiscourseNodeUtil).getFile( + endNode as DiscourseNodeShape, + { + app: this.options.app, + canvasFile: this.options.canvasFile, + }, + ); + + if (!sourceFile || !targetFile) { + console.warn("Could not resolve files for relation nodes"); + return; + } + + // Add the bidirectional relation to frontmatter + await addRelationToFrontmatter({ + app: this.options.app, + plugin: this.options.plugin, + sourceFile, + targetFile, + relationTypeId: shape.props.relationTypeId, + }); + + // Show success notice + const relationType = this.options.plugin.settings.relationTypes.find( + (rt) => rt.id === shape.props.relationTypeId, + ); + + if (relationType) { + showToast({ + severity: "success", + title: "Relation Created", + description: `Added ${relationType.label} relation between ${sourceFile.basename} and ${targetFile.basename}`, + }); + } + } catch (error) { + console.error("Failed to reify relation in frontmatter:", error); + showToast({ + severity: "error", + title: "Failed to Save Relation", + description: "Could not save relation to files", + }); + } + } } export const createDiscourseRelationUtil = ( diff --git a/apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts b/apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts new file mode 100644 index 000000000..fd600baf8 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts @@ -0,0 +1,52 @@ +import type { App, FrontMatterCache, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; + +/** + * Adds bidirectional relation links to the frontmatter of both files. + * This follows the same pattern as RelationshipSection.tsx + */ +export const addRelationToFrontmatter = async ({ + app, + plugin, + sourceFile, + targetFile, + relationTypeId, +}: { + app: App; + plugin: DiscourseGraphPlugin; + sourceFile: TFile; + targetFile: TFile; + relationTypeId: string; +}): Promise => { + const relationType = plugin.settings.relationTypes.find( + (r) => r.id === relationTypeId, + ); + + if (!relationType) { + console.error(`Relation type ${relationTypeId} not found`); + return; + } + + try { + const appendLinkToFrontmatter = async (file: TFile, link: string) => { + await app.fileManager.processFrontMatter(file, (fm: FrontMatterCache) => { + const existingLinks = Array.isArray(fm[relationType.id]) + ? (fm[relationType.id] as string[]) + : []; + + // Check if the link already exists to avoid duplicates + const linkToAdd = `[[${link}]]`; + if (!existingLinks.includes(linkToAdd)) { + fm[relationType.id] = [...existingLinks, linkToAdd]; + } + }); + }; + + // Add bidirectional links + await appendLinkToFrontmatter(sourceFile, targetFile.basename); + await appendLinkToFrontmatter(targetFile, sourceFile.basename); + } catch (error) { + console.error("Failed to add relation to frontmatter:", error); + throw error; + } +}; diff --git a/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts index fdadc6355..01200e4e1 100644 --- a/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts +++ b/apps/obsidian/src/components/canvas/utils/nodeCreationFlow.ts @@ -1,10 +1,11 @@ -import { Notice, TFile } from "obsidian"; +import { TFile } from "obsidian"; import { Editor, createShapeId } from "tldraw"; import DiscourseGraphPlugin from "~/index"; import { DiscourseNode } from "~/types"; import { CreateNodeModal } from "~/components/CreateNodeModal"; import { createDiscourseNode } from "~/utils/createNode"; import { addWikilinkBlockrefForFile } from "~/components/canvas/stores/assetStore"; +import { showToast } from "./toastUtils"; export type CreateNodeAtArgs = { plugin: DiscourseGraphPlugin; @@ -60,7 +61,11 @@ export const openCreateDiscourseNodeAt = (args: CreateNodeAtArgs): void => { tldrawEditor.setSelectedShapes([shapeId]); } catch (error) { console.error("Error creating discourse node:", error); - new Notice(`Failed to create discourse node: ${JSON.stringify(error)}`); + showToast({ + severity: "error", + title: "Failed to Create Node", + description: `Could not create discourse node: ${error instanceof Error ? error.message : "Unknown error"}`, + }); } }, }); diff --git a/apps/obsidian/src/components/canvas/utils/toastUtils.ts b/apps/obsidian/src/components/canvas/utils/toastUtils.ts new file mode 100644 index 000000000..06d6fc2e0 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/toastUtils.ts @@ -0,0 +1,21 @@ +import { TLUiToast } from "tldraw"; +import { dispatchToastEvent } from "~/components/canvas/ToastListener"; + +export const showToast = ({ + severity, + title, + description, +}: { + severity: TLUiToast["severity"]; + title: string; + description?: string; +}) => { + const toast: TLUiToast = { + id: `${severity}-${Date.now()}`, + title, + description, + severity, + keepOpen: false, + }; + dispatchToastEvent(toast); +}; \ No newline at end of file diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index 2803721b1..ccc19a707 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -76,4 +76,7 @@ export const VIEW_TYPE_TLDRAW_DG_PREVIEW = "tldraw-dg-preview"; export const TLDRAW_VERSION = "3.14.1"; export const DEFAULT_SAVE_DELAY = 500; // in ms export const WHITE_LOGO_SVG = - ''; \ No newline at end of file + ''; + +export const TOOL_ARROW_ICON_URL = + "https://discoursegraphs.com/apps/assets/tool-arrow-icon.svg"; \ No newline at end of file diff --git a/apps/roam/src/components/canvas/DiscourseToolPanel.tsx b/apps/roam/src/components/canvas/DiscourseToolPanel.tsx index f94c3da23..df1cd0959 100644 --- a/apps/roam/src/components/canvas/DiscourseToolPanel.tsx +++ b/apps/roam/src/components/canvas/DiscourseToolPanel.tsx @@ -44,6 +44,10 @@ type DragState = currentPosition: Vec; }; +const TOOL_ARROW_ICON_URL = + "https://discoursegraphs.com/apps/assets/tool-arrow-icon.svg"; +const NODE_COLOR_ICON_URL = + "https://discoursegraphs.com/apps/assets/node-color-icon.svg"; const DiscourseGraphPanel = ({ nodes, relations, @@ -290,7 +294,7 @@ const DiscourseGraphPanel = ({
{currentRelationTool} @@ -364,8 +368,8 @@ const DiscourseGraphPanel = ({ style={{ mask: item.type === "node" - ? `url("https://cdn.tldraw.com/2.3.0/icons/icon/color.svg") center 100% / 100% no-repeat` - : `url("https://cdn.tldraw.com/2.3.0/icons/icon/tool-arrow.svg") center 100% / 100% no-repeat`, + ? `url("${NODE_COLOR_ICON_URL}") center 100% / 100% no-repeat` + : `url("${TOOL_ARROW_ICON_URL}") center 100% / 100% no-repeat`, backgroundColor: item.color, }} /> diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index 058c5f103..d5250386f 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -178,7 +178,7 @@ export const SettingsDialog = ({ \ No newline at end of file diff --git a/apps/website/public/apps/assets/tool-arrow-icon.svg b/apps/website/public/apps/assets/tool-arrow-icon.svg new file mode 100644 index 000000000..81056b403 --- /dev/null +++ b/apps/website/public/apps/assets/tool-arrow-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file