From 0ebe9ce75a7c99f9b450f6e20d60b9043a5cba11 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 23 Mar 2026 17:13:51 -0400 Subject: [PATCH 1/7] drag handle works --- .../components/canvas/TldrawViewComponent.tsx | 7 +- .../canvas/overlays/DragHandleOverlay.tsx | 442 ++++++++++++++++++ .../canvas/overlays/RelationTypeDropdown.tsx | 230 +++++++++ 3 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx create mode 100644 apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index d196a04b6..81911608b 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -46,6 +46,8 @@ import { } from "~/components/canvas/shapes/DiscourseRelationBinding"; import ToastListener from "./ToastListener"; import { RelationsOverlay } from "./overlays/RelationOverlay"; +import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; +import { showToast } from "./utils/toastUtils"; import { WHITE_LOGO_SVG } from "~/icons"; import { CustomContextMenu } from "./CustomContextMenu"; import { @@ -474,7 +476,10 @@ export const TldrawPreviewComponent = ({ ); }, InFrontOfTheCanvas: () => ( - + <> + + + ), }} /> diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx new file mode 100644 index 000000000..ee8b2158d --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -0,0 +1,442 @@ +import { useCallback, useRef, useState } from "react"; +import { TFile } from "obsidian"; +import { + TLArrowBindingProps, + TLShapeId, + createShapeId, + useEditor, + useValue, +} from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { + DiscourseRelationShape, + DiscourseRelationUtil, +} from "~/components/canvas/shapes/DiscourseRelationShape"; +import { + createOrUpdateArrowBinding, + getArrowBindings, +} from "~/components/canvas/utils/relationUtils"; +import { DEFAULT_TLDRAW_COLOR } from "~/utils/tldrawColors"; +import { showToast } from "~/components/canvas/utils/toastUtils"; +import { RelationTypeDropdown } from "./RelationTypeDropdown"; + +type DragHandleOverlayProps = { + plugin: DiscourseGraphPlugin; + file: TFile; +}; + +type HandlePosition = { + x: number; + y: number; + anchor: { x: number; y: number }; +}; + +const HANDLE_RADIUS = 5; +const HANDLE_HIT_AREA = 12; +const HANDLE_PADDING = 8; // px offset outward from the node edge + +const getEdgeMidpoints = (bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; +}): HandlePosition[] => { + return [ + // Top + { + x: (bounds.minX + bounds.maxX) / 2, + y: bounds.minY - HANDLE_PADDING, + anchor: { x: 0.5, y: 0 }, + }, + // Right + { + x: bounds.maxX + HANDLE_PADDING, + y: (bounds.minY + bounds.maxY) / 2, + anchor: { x: 1, y: 0.5 }, + }, + // Bottom + { + x: (bounds.minX + bounds.maxX) / 2, + y: bounds.maxY + HANDLE_PADDING, + anchor: { x: 0.5, y: 1 }, + }, + // Left + { + x: bounds.minX - HANDLE_PADDING, + y: (bounds.minY + bounds.maxY) / 2, + anchor: { x: 0, y: 0.5 }, + }, + ]; +}; + +export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { + const editor = useEditor(); + const [pendingArrowId, setPendingArrowId] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const sourceNodeRef = useRef(null); + + // Track the single selected discourse node — mirrors RelationsOverlay pattern + const selectedNode = useValue( + "dragHandleSelectedNode", + () => { + const shape = editor.getOnlySelectedShape(); + if (shape && shape.type === "discourse-node") { + return shape as DiscourseNodeShape; + } + return null; + }, + [editor], + ); + + const handlePositions = useValue< + { left: number; top: number; anchor: { x: number; y: number } }[] | null + >( + "dragHandlePositions", + () => { + if (!selectedNode || pendingArrowId || isDragging) return null; + const bounds = editor.getShapePageBounds(selectedNode.id); + if (!bounds) return null; + const midpoints = getEdgeMidpoints(bounds); + return midpoints.map((mp) => { + const vp = editor.pageToViewport({ x: mp.x, y: mp.y }); + return { left: vp.x, top: vp.y, anchor: mp.anchor }; + }); + }, + [editor, selectedNode?.id, pendingArrowId, isDragging], + ); + + const cleanupArrow = useCallback( + (arrowId: TLShapeId) => { + if (editor.getShape(arrowId)) { + editor.deleteShapes([arrowId]); + } + }, + [editor], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent, anchor: { x: number; y: number }) => { + if (!selectedNode) return; + e.preventDefault(); + e.stopPropagation(); + + setIsDragging(true); + sourceNodeRef.current = selectedNode; + + const arrowId = createShapeId(); + + // Get the source node's page bounds for start position + const sourceBounds = editor.getShapePageBounds(selectedNode.id); + if (!sourceBounds) return; + + const startX = sourceBounds.minX + anchor.x * sourceBounds.width; + const startY = sourceBounds.minY + anchor.y * sourceBounds.height; + + // Create the arrow shape at the source node's position + editor.createShape({ + id: arrowId, + type: "discourse-relation", + x: startX, + y: startY, + props: { + color: DEFAULT_TLDRAW_COLOR, + relationTypeId: "", + text: "", + dash: "draw", + size: "m", + fill: "none", + labelColor: "black", + bend: 0, + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + arrowheadStart: "none", + arrowheadEnd: "arrow", + labelPosition: 0.5, + font: "draw", + scale: 1, + kind: "arc", + elbowMidPoint: 0, + }, + }); + + const createdShape = editor.getShape(arrowId); + if (!createdShape) return; + + // Bind the start handle to the source node + createOrUpdateArrowBinding(editor, createdShape, selectedNode.id, { + terminal: "start", + normalizedAnchor: anchor, + isPrecise: false, + isExact: false, + snap: "none", + }); + + // Select the arrow and start dragging the end handle + editor.select(arrowId); + + // Use tldraw's built-in handle dragging by setting the tool state + // We need to track the pointer to update the end handle + const containerEl = editor.getContainer(); + const onPointerMove = (moveEvent: PointerEvent) => { + const point = editor.screenToPage({ + x: moveEvent.clientX, + y: moveEvent.clientY, + }); + + // Update the arrow's end position + const currentShape = editor.getShape(arrowId); + if (!currentShape) return; + + const dx = point.x - currentShape.x; + const dy = point.y - currentShape.y; + + // Check for a target shape under the cursor + const target = editor.getShapeAtPoint(point, { + hitInside: true, + hitFrameInside: true, + margin: 0, + filter: (targetShape) => { + return ( + targetShape.type === "discourse-node" && + targetShape.id !== selectedNode.id && + !targetShape.isLocked + ); + }, + }); + + if (target) { + // Bind end to target + createOrUpdateArrowBinding(editor, currentShape, target.id, { + terminal: "end", + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + isExact: false, + snap: "none", + }); + editor.setHintingShapes([target.id]); + } else { + // Update free end position + // Remove any existing end binding + const bindings = getArrowBindings(editor, currentShape); + if (bindings.end) { + editor.deleteBindings( + editor + .getBindingsFromShape(currentShape.id, "discourse-relation") + .filter( + (b) => (b.props as TLArrowBindingProps).terminal === "end", + ), + ); + } + editor.updateShapes([ + { + id: arrowId, + type: "discourse-relation", + props: { end: { x: dx, y: dy } }, + }, + ]); + editor.setHintingShapes([]); + } + }; + + const onPointerUp = () => { + containerEl.removeEventListener("pointermove", onPointerMove); + containerEl.removeEventListener("pointerup", onPointerUp); + editor.setHintingShapes([]); + setIsDragging(false); + + const finalShape = editor.getShape(arrowId); + if (!finalShape) return; + + const bindings = getArrowBindings(editor, finalShape); + + // Validate: both ends bound to different discourse nodes + if ( + bindings.start && + bindings.end && + bindings.start.toId !== bindings.end.toId + ) { + const endTarget = editor.getShape(bindings.end.toId); + if (endTarget && endTarget.type === "discourse-node") { + // Check if any relation types are valid for this node pair + const startNodeTypeId = ( + editor.getShape(bindings.start.toId) as { + props?: { nodeTypeId?: string }; + } + )?.props?.nodeTypeId; + const endNodeTypeId = ( + endTarget as { props?: { nodeTypeId?: string } } + )?.props?.nodeTypeId; + + const hasValidRelationType = + startNodeTypeId && + endNodeTypeId && + plugin.settings.discourseRelations.some( + (r) => + plugin.settings.relationTypes.some( + (rt) => rt.id === r.relationshipTypeId, + ) && + ((r.sourceId === startNodeTypeId && + r.destinationId === endNodeTypeId) || + (r.sourceId === endNodeTypeId && + r.destinationId === startNodeTypeId)), + ); + + if (!hasValidRelationType) { + cleanupArrow(arrowId); + showToast({ + severity: "warning", + title: "Relation", + description: + "No relation types are defined between these node types", + targetCanvasId: file.path, + }); + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + return; + } + + // Success - show dropdown to pick relation type + setPendingArrowId(arrowId); + editor.select(arrowId); + return; + } + } + + // Failure - clean up the arrow and show notice + cleanupArrow(arrowId); + showToast({ + severity: "warning", + title: "Relation", + description: !bindings.end + ? "Drop on a discourse node to create a relation" + : "Target must be a different discourse node", + targetCanvasId: file.path, + }); + // Re-select the source node + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + }; + + containerEl.addEventListener("pointermove", onPointerMove); + containerEl.addEventListener("pointerup", onPointerUp); + }, + [selectedNode, editor, cleanupArrow, file.path], + ); + + const handleDropdownSelect = useCallback( + (relationTypeId: string) => { + if (!pendingArrowId) return; + + const shape = editor.getShape(pendingArrowId); + if (!shape) { + setPendingArrowId(null); + return; + } + + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relationTypeId, + ); + if (!relationType) { + cleanupArrow(pendingArrowId); + setPendingArrowId(null); + return; + } + + // Update arrow props with relation type info + editor.updateShapes([ + { + id: pendingArrowId, + type: "discourse-relation", + props: { + relationTypeId, + color: relationType.color, + }, + }, + ]); + + // Get updated shape and bindings for text direction + const updatedShape = + editor.getShape(pendingArrowId); + if (updatedShape) { + const bindings = getArrowBindings(editor, updatedShape); + + // Update text based on direction + const util = editor.getShapeUtil(updatedShape); + if (util instanceof DiscourseRelationUtil) { + util.updateRelationTextForDirection(updatedShape, bindings); + // Persist to frontmatter + void util.reifyRelationInFrontmatter(updatedShape, bindings); + } + } + + setPendingArrowId(null); + sourceNodeRef.current = null; + }, + [editor, pendingArrowId, plugin, cleanupArrow], + ); + + const handleDropdownDismiss = useCallback(() => { + if (pendingArrowId) { + cleanupArrow(pendingArrowId); + setPendingArrowId(null); + } + // Re-select source node + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + }, [editor, pendingArrowId, cleanupArrow]); + + const showHandles = !!handlePositions && !pendingArrowId; + + return ( +
+ {/* Drag handle dots */} + {showHandles && + handlePositions.map((pos, i) => ( +
handlePointerDown(e, pos.anchor)} + style={{ + position: "absolute", + left: `${pos.left}px`, + top: `${pos.top}px`, + transform: "translate(-50%, -50%)", + width: `${HANDLE_HIT_AREA * 2}px`, + height: `${HANDLE_HIT_AREA * 2}px`, + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "crosshair", + pointerEvents: "all", + zIndex: 20, + }} + > +
+
+ ))} + + {/* Relation type dropdown */} + {pendingArrowId && ( + + )} +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx new file mode 100644 index 000000000..e319d3e6c --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { TLShapeId, useEditor, useValue } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape"; +import { + getArrowBindings, + getArrowInfo, +} from "~/components/canvas/utils/relationUtils"; +import { COLOR_PALETTE } from "~/utils/tldrawColors"; + +type RelationTypeDropdownProps = { + arrowId: TLShapeId; + plugin: DiscourseGraphPlugin; + onSelect: (relationTypeId: string) => void; + onDismiss: () => void; +}; + +export const RelationTypeDropdown = ({ + arrowId, + plugin, + onSelect, + onDismiss, +}: RelationTypeDropdownProps) => { + const editor = useEditor(); + const dropdownRef = useRef(null); + + const arrow = useValue( + "dropdownArrow", + () => editor.getShape(arrowId) ?? null, + [editor, arrowId], + ); + + // Auto-dismiss if arrow is deleted + useEffect(() => { + if (!arrow) { + onDismiss(); + } + }, [arrow, onDismiss]); + + // Get valid relation types based on source/target node types + const validRelationTypes = useMemo(() => { + if (!arrow) return []; + + const bindings = getArrowBindings(editor, arrow); + if (!bindings.start || !bindings.end) return []; + + const startNode = editor.getShape(bindings.start.toId); + const endNode = editor.getShape(bindings.end.toId); + + if (!startNode || !endNode) return []; + + const startNodeTypeId = (startNode as { props?: { nodeTypeId?: string } }) + ?.props?.nodeTypeId; + const endNodeTypeId = (endNode as { props?: { nodeTypeId?: string } }) + ?.props?.nodeTypeId; + + if (!startNodeTypeId || !endNodeTypeId) return []; + + // Find relation types that are valid for this node type pair + const validTypes: { + id: string; + label: string; + color: string; + }[] = []; + + for (const relationType of plugin.settings.relationTypes) { + // Check if there's a discourse relation that matches this pair + const isValid = plugin.settings.discourseRelations.some( + (relation) => + relation.relationshipTypeId === relationType.id && + ((relation.sourceId === startNodeTypeId && + relation.destinationId === endNodeTypeId) || + (relation.sourceId === endNodeTypeId && + relation.destinationId === startNodeTypeId)), + ); + + if (isValid) { + validTypes.push({ + id: relationType.id, + label: relationType.label, + color: COLOR_PALETTE[relationType.color] ?? COLOR_PALETTE["black"]!, + }); + } + } + + return validTypes; + }, [arrow, editor, plugin]); + + // Position dropdown at arrow midpoint + const dropdownPosition = useValue<{ left: number; top: number } | null>( + "dropdownPosition", + () => { + if (!arrow) return null; + + const info = getArrowInfo(editor, arrow); + if (!info) return null; + + // Get the midpoint in page space + const pageTransform = editor.getShapePageTransform(arrow.id); + const midInPage = pageTransform.applyToPoint(info.middle); + + const vp = editor.pageToViewport(midInPage); + return { left: vp.x, top: vp.y }; + }, + [editor, arrow?.id], + ); + + // Handle click outside + useEffect(() => { + const handlePointerDown = (e: PointerEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + onDismiss(); + } + }; + + // Delay to avoid immediately triggering from the pointer up that opened this + const timer = setTimeout(() => { + window.addEventListener("pointerdown", handlePointerDown, true); + }, 100); + + return () => { + clearTimeout(timer); + window.removeEventListener("pointerdown", handlePointerDown, true); + }; + }, [onDismiss]); + + // Handle Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onDismiss(); + } + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [onDismiss]); + + const handleSelect = useCallback( + (relationTypeId: string) => { + onSelect(relationTypeId); + }, + [onSelect], + ); + + if (!dropdownPosition || !arrow) return null; + + return ( +
e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
+
+ Relation Type +
+ {validRelationTypes.map((rt) => ( + + ))} +
+
+ ); +}; From a61f515d70593a4d477e6ad1b2fa260d8d6eee78 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Mar 2026 23:51:47 -0400 Subject: [PATCH 2/7] fix: reset isDragging on early returns and use e.currentTarget for hover Co-Authored-By: Claude Opus 4.6 --- .../src/components/canvas/overlays/DragHandleOverlay.tsx | 4 ++-- .../src/components/canvas/overlays/RelationTypeDropdown.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index ee8b2158d..ba3f65449 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -128,7 +128,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { // Get the source node's page bounds for start position const sourceBounds = editor.getShapePageBounds(selectedNode.id); - if (!sourceBounds) return; + if (!sourceBounds) { setIsDragging(false); return; } const startX = sourceBounds.minX + anchor.x * sourceBounds.width; const startY = sourceBounds.minY + anchor.y * sourceBounds.height; @@ -161,7 +161,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { }); const createdShape = editor.getShape(arrowId); - if (!createdShape) return; + if (!createdShape) { setIsDragging(false); return; } // Bind the start handle to the source node createOrUpdateArrowBinding(editor, createdShape, selectedNode.id, { diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index e319d3e6c..c3879c46b 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -205,11 +205,11 @@ export const RelationTypeDropdown = ({ textAlign: "left", }} onMouseEnter={(e) => { - (e.target as HTMLElement).style.backgroundColor = + (e.currentTarget as HTMLElement).style.backgroundColor = "var(--color-hover, #f0f0f0)"; }} onMouseLeave={(e) => { - (e.target as HTMLElement).style.backgroundColor = "transparent"; + (e.currentTarget as HTMLElement).style.backgroundColor = "transparent"; }} > Date: Fri, 27 Mar 2026 15:03:02 -0400 Subject: [PATCH 3/7] format and lint --- .../components/canvas/TldrawViewComponent.tsx | 1 - .../canvas/overlays/DragHandleOverlay.tsx | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 81911608b..c893e7b34 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -47,7 +47,6 @@ import { import ToastListener from "./ToastListener"; import { RelationsOverlay } from "./overlays/RelationOverlay"; import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; -import { showToast } from "./utils/toastUtils"; import { WHITE_LOGO_SVG } from "~/icons"; import { CustomContextMenu } from "./CustomContextMenu"; import { diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index ba3f65449..090220434 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -128,7 +128,10 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { // Get the source node's page bounds for start position const sourceBounds = editor.getShapePageBounds(selectedNode.id); - if (!sourceBounds) { setIsDragging(false); return; } + if (!sourceBounds) { + setIsDragging(false); + return; + } const startX = sourceBounds.minX + anchor.x * sourceBounds.width; const startY = sourceBounds.minY + anchor.y * sourceBounds.height; @@ -161,7 +164,10 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { }); const createdShape = editor.getShape(arrowId); - if (!createdShape) { setIsDragging(false); return; } + if (!createdShape) { + setIsDragging(false); + return; + } // Bind the start handle to the source node createOrUpdateArrowBinding(editor, createdShape, selectedNode.id, { @@ -325,7 +331,14 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { containerEl.addEventListener("pointermove", onPointerMove); containerEl.addEventListener("pointerup", onPointerUp); }, - [selectedNode, editor, cleanupArrow, file.path], + [ + selectedNode, + editor, + cleanupArrow, + file.path, + plugin.settings.discourseRelations, + plugin.settings.relationTypes, + ], ); const handleDropdownSelect = useCallback( From 966d6e133c226e5b4fbfc6d0e56a733c21481d86 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 27 Mar 2026 15:08:00 -0400 Subject: [PATCH 4/7] format --- .../src/components/canvas/overlays/RelationTypeDropdown.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index c3879c46b..199180035 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -209,7 +209,8 @@ export const RelationTypeDropdown = ({ "var(--color-hover, #f0f0f0)"; }} onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.backgroundColor = "transparent"; + (e.currentTarget as HTMLElement).style.backgroundColor = + "transparent"; }} > Date: Fri, 27 Mar 2026 15:19:16 -0400 Subject: [PATCH 5/7] address PR comment + fix z-position --- .../canvas/overlays/DragHandleOverlay.tsx | 17 ++++++++++++++++- .../canvas/overlays/RelationOverlay.tsx | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index 090220434..2dbca2a99 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { TFile } from "obsidian"; import { TLArrowBindingProps, @@ -75,6 +75,14 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { const [pendingArrowId, setPendingArrowId] = useState(null); const [isDragging, setIsDragging] = useState(false); const sourceNodeRef = useRef(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + + // Clean up drag listeners on unmount + useEffect(() => { + return () => { + dragCleanupRef.current?.(); + }; + }, []); // Track the single selected discourse node — mirrors RelationsOverlay pattern const selectedNode = useValue( @@ -248,6 +256,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { const onPointerUp = () => { containerEl.removeEventListener("pointermove", onPointerMove); containerEl.removeEventListener("pointerup", onPointerUp); + dragCleanupRef.current = null; editor.setHintingShapes([]); setIsDragging(false); @@ -330,6 +339,12 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { containerEl.addEventListener("pointermove", onPointerMove); containerEl.addEventListener("pointerup", onPointerUp); + + dragCleanupRef.current = () => { + containerEl.removeEventListener("pointermove", onPointerMove); + containerEl.removeEventListener("pointerup", onPointerUp); + dragCleanupRef.current = null; + }; }, [ selectedNode, diff --git a/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx index 048e46b91..a3baff93d 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx @@ -87,7 +87,7 @@ export const RelationsOverlay = ({ plugin, file }: RelationsOverlayProps) => { maxHeight: "calc(100% - 24px)", pointerEvents: "all", overflow: "auto", - zIndex: 10, + zIndex: 25, }} onMouseDown={(e) => e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} From 5dae9f78d0ce734cffc2d3d2860aba91d32b4c18 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 27 Mar 2026 16:02:16 -0400 Subject: [PATCH 6/7] some updates naming etc --- .../canvas/overlays/DragHandleOverlay.tsx | 27 ++++++++++++------- .../shapes/DiscourseRelationBinding.tsx | 4 +-- .../canvas/shapes/DiscourseRelationShape.tsx | 6 ++--- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index 2dbca2a99..410826e82 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -34,38 +34,43 @@ type HandlePosition = { const HANDLE_RADIUS = 5; const HANDLE_HIT_AREA = 12; -const HANDLE_PADDING = 8; // px offset outward from the node edge +const HANDLE_PADDING = 8; // px offset in viewport space, outward from the node edge +/** Page-space edge midpoints and their outward direction vectors. */ const getEdgeMidpoints = (bounds: { minX: number; minY: number; maxX: number; maxY: number; -}): HandlePosition[] => { +}): (HandlePosition & { direction: { x: number; y: number } })[] => { return [ // Top { x: (bounds.minX + bounds.maxX) / 2, - y: bounds.minY - HANDLE_PADDING, + y: bounds.minY, anchor: { x: 0.5, y: 0 }, + direction: { x: 0, y: -1 }, }, // Right { - x: bounds.maxX + HANDLE_PADDING, + x: bounds.maxX, y: (bounds.minY + bounds.maxY) / 2, anchor: { x: 1, y: 0.5 }, + direction: { x: 1, y: 0 }, }, // Bottom { x: (bounds.minX + bounds.maxX) / 2, - y: bounds.maxY + HANDLE_PADDING, + y: bounds.maxY, anchor: { x: 0.5, y: 1 }, + direction: { x: 0, y: 1 }, }, // Left { - x: bounds.minX - HANDLE_PADDING, + x: bounds.minX, y: (bounds.minY + bounds.maxY) / 2, anchor: { x: 0, y: 0.5 }, + direction: { x: -1, y: 0 }, }, ]; }; @@ -108,7 +113,11 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { const midpoints = getEdgeMidpoints(bounds); return midpoints.map((mp) => { const vp = editor.pageToViewport({ x: mp.x, y: mp.y }); - return { left: vp.x, top: vp.y, anchor: mp.anchor }; + return { + left: vp.x + mp.direction.x * HANDLE_PADDING, + top: vp.y + mp.direction.y * HANDLE_PADDING, + anchor: mp.anchor, + }; }); }, [editor, selectedNode?.id, pendingArrowId, isDragging], @@ -397,8 +406,8 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { const util = editor.getShapeUtil(updatedShape); if (util instanceof DiscourseRelationUtil) { util.updateRelationTextForDirection(updatedShape, bindings); - // Persist to frontmatter - void util.reifyRelationInFrontmatter(updatedShape, bindings); + // Persist to relations JSON + void util.reifyRelation(updatedShape, bindings); } } diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx index e0161d24f..efa60dcb3 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx @@ -159,8 +159,8 @@ export class BaseRelationBindingUtil extends BindingUtil { 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); + util.reifyRelation(arrow, bindings).catch((error) => { + console.error("Failed to reify relation:", 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 e3b56bc24..27cf1c2df 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx @@ -1167,10 +1167,10 @@ export class DiscourseRelationUtil extends ShapeUtil { } /** - * Reifies the relation in the frontmatter of both connected files. + * Reifies the relation in the relations JSON of both connected files. * This creates the bidirectional links that make the relation persistent. */ - async reifyRelationInFrontmatter( + async reifyRelation( shape: DiscourseRelationShape, bindings: RelationBindings, ): Promise { @@ -1243,7 +1243,7 @@ export class DiscourseRelationUtil extends ShapeUtil { }); } } catch (error) { - console.error("Failed to reify relation in frontmatter:", error); + console.error("Failed to reify relation:", error); showToast({ severity: "error", title: "Failed to Save Relation", From 49471f4a0d3c21924f69631f6c5d6c5d2ebbb7dc Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 27 Mar 2026 21:20:30 -0400 Subject: [PATCH 7/7] address PR comments --- .../canvas/overlays/DragHandleOverlay.tsx | 17 ++-- .../canvas/overlays/RelationTypeDropdown.tsx | 96 +++---------------- .../canvas/shapes/DiscourseRelationShape.tsx | 48 +++------- .../canvas/utils/relationTypeUtils.ts | 90 +++++++++++++++++ 4 files changed, 121 insertions(+), 130 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index 410826e82..cb23d6bd1 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -19,6 +19,7 @@ import { } from "~/components/canvas/utils/relationUtils"; import { DEFAULT_TLDRAW_COLOR } from "~/utils/tldrawColors"; import { showToast } from "~/components/canvas/utils/toastUtils"; +import { hasValidRelationTypeForNodePair } from "~/components/canvas/utils/relationTypeUtils"; import { RelationTypeDropdown } from "./RelationTypeDropdown"; type DragHandleOverlayProps = { @@ -295,15 +296,10 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { const hasValidRelationType = startNodeTypeId && endNodeTypeId && - plugin.settings.discourseRelations.some( - (r) => - plugin.settings.relationTypes.some( - (rt) => rt.id === r.relationshipTypeId, - ) && - ((r.sourceId === startNodeTypeId && - r.destinationId === endNodeTypeId) || - (r.sourceId === endNodeTypeId && - r.destinationId === startNodeTypeId)), + hasValidRelationTypeForNodePair( + plugin.settings, + startNodeTypeId, + endNodeTypeId, ); if (!hasValidRelationType) { @@ -360,8 +356,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { editor, cleanupArrow, file.path, - plugin.settings.discourseRelations, - plugin.settings.relationTypes, + plugin.settings, ], ); diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index 199180035..7f5ca5f14 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -6,7 +6,7 @@ import { getArrowBindings, getArrowInfo, } from "~/components/canvas/utils/relationUtils"; -import { COLOR_PALETTE } from "~/utils/tldrawColors"; +import { getValidRelationTypesForNodePair } from "~/components/canvas/utils/relationTypeUtils"; type RelationTypeDropdownProps = { arrowId: TLShapeId; @@ -56,34 +56,11 @@ export const RelationTypeDropdown = ({ if (!startNodeTypeId || !endNodeTypeId) return []; - // Find relation types that are valid for this node type pair - const validTypes: { - id: string; - label: string; - color: string; - }[] = []; - - for (const relationType of plugin.settings.relationTypes) { - // Check if there's a discourse relation that matches this pair - const isValid = plugin.settings.discourseRelations.some( - (relation) => - relation.relationshipTypeId === relationType.id && - ((relation.sourceId === startNodeTypeId && - relation.destinationId === endNodeTypeId) || - (relation.sourceId === endNodeTypeId && - relation.destinationId === startNodeTypeId)), - ); - - if (isValid) { - validTypes.push({ - id: relationType.id, - label: relationType.label, - color: COLOR_PALETTE[relationType.color] ?? COLOR_PALETTE["black"]!, - }); - } - } - - return validTypes; + return getValidRelationTypesForNodePair( + plugin.settings, + startNodeTypeId, + endNodeTypeId, + ); }, [arrow, editor, plugin]); // Position dropdown at arrow midpoint @@ -150,77 +127,28 @@ export const RelationTypeDropdown = ({ return (
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > -
-
+
+
Relation Type
{validRelationTypes.map((rt) => ( diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx index 27cf1c2df..ca709f245 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx @@ -60,6 +60,7 @@ import { import { RelationBindings } from "./DiscourseRelationBinding"; import { DiscourseNodeShape, DiscourseNodeUtil } from "./DiscourseNodeShape"; import { addRelationToRelationsJson } from "~/components/canvas/utils/relationJsonUtils"; +import { getRelationDirection } from "~/components/canvas/utils/relationTypeUtils"; import { showToast } from "~/components/canvas/utils/toastUtils"; export enum ArrowHandles { @@ -1098,25 +1099,16 @@ export class DiscourseRelationUtil extends ShapeUtil { if (!relationType) return; - // Check if this is a direct connection (start -> end) - const isDirectConnection = plugin.settings.discourseRelations.some( - (relation) => - relation.relationshipTypeId === relationTypeId && - relation.sourceId === startNodeTypeId && - relation.destinationId === endNodeTypeId, - ); - - // Check if this is a reverse connection (end -> start, so we need complement) - const isReverseConnection = plugin.settings.discourseRelations.some( - (relation) => - relation.relationshipTypeId === relationTypeId && - relation.sourceId === endNodeTypeId && - relation.destinationId === startNodeTypeId, + const { direct, reverse } = getRelationDirection( + plugin.settings.discourseRelations, + relationTypeId, + startNodeTypeId, + endNodeTypeId, ); let newText = relationType.label; // Default to main label - if (isReverseConnection && !isDirectConnection) { + if (reverse && !direct) { // This is purely a reverse connection, use complement newText = relationType.complement; } @@ -1142,28 +1134,14 @@ export class DiscourseRelationUtil extends ShapeUtil { targetNodeTypeId: string, relationTypeId: string, ): boolean { - const plugin = this.options.plugin; - - // Check direct connection (source -> target) - const directConnection = plugin.settings.discourseRelations.some( - (relation) => - relation.relationshipTypeId === relationTypeId && - relation.sourceId === sourceNodeTypeId && - relation.destinationId === targetNodeTypeId, - ); - - if (directConnection) return true; - - // Check reverse connection (target -> source) - // This handles bidirectional relations where the complement is used - const reverseConnection = plugin.settings.discourseRelations.some( - (relation) => - relation.relationshipTypeId === relationTypeId && - relation.sourceId === targetNodeTypeId && - relation.destinationId === sourceNodeTypeId, + const { direct, reverse } = getRelationDirection( + this.options.plugin.settings.discourseRelations, + relationTypeId, + sourceNodeTypeId, + targetNodeTypeId, ); - return reverseConnection; + return direct || reverse; } /** diff --git a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts new file mode 100644 index 000000000..ee196f864 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts @@ -0,0 +1,90 @@ +import type { DiscourseRelation, DiscourseRelationType } from "~/types"; +import { COLOR_PALETTE } from "~/utils/tldrawColors"; + +type RelationTypeSettings = { + discourseRelations: DiscourseRelation[]; + relationTypes: DiscourseRelationType[]; +}; + +/** + * Checks the direction of a discourse relation between two node types. + * Returns whether the relation exists in the direct (source→target) + * and/or reverse (target→source) direction. + */ +export const getRelationDirection = ( + discourseRelations: DiscourseRelation[], + relationTypeId: string, + sourceNodeTypeId: string, + targetNodeTypeId: string, +): { direct: boolean; reverse: boolean } => { + let direct = false; + let reverse = false; + + for (const relation of discourseRelations) { + if (relation.relationshipTypeId !== relationTypeId) continue; + if ( + relation.sourceId === sourceNodeTypeId && + relation.destinationId === targetNodeTypeId + ) { + direct = true; + } + if ( + relation.sourceId === targetNodeTypeId && + relation.destinationId === sourceNodeTypeId + ) { + reverse = true; + } + if (direct && reverse) break; + } + + return { direct, reverse }; +}; + +/** + * Returns the list of valid relation types for a given pair of node types, + * checking both directions of the discourse relations. + */ +export const getValidRelationTypesForNodePair = ( + settings: RelationTypeSettings, + sourceNodeTypeId: string, + targetNodeTypeId: string, +): { id: string; label: string; color: string }[] => { + const validTypes: { id: string; label: string; color: string }[] = []; + + for (const relationType of settings.relationTypes) { + const { direct, reverse } = getRelationDirection( + settings.discourseRelations, + relationType.id, + sourceNodeTypeId, + targetNodeTypeId, + ); + + if (direct || reverse) { + validTypes.push({ + id: relationType.id, + label: relationType.label, + color: COLOR_PALETTE[relationType.color] ?? COLOR_PALETTE["black"]!, + }); + } + } + + return validTypes; +}; + +/** + * Checks whether any valid relation type exists between two node types. + */ +export const hasValidRelationTypeForNodePair = ( + settings: RelationTypeSettings, + sourceNodeTypeId: string, + targetNodeTypeId: string, +): boolean => { + return settings.discourseRelations.some( + (r) => + settings.relationTypes.some((rt) => rt.id === r.relationshipTypeId) && + ((r.sourceId === sourceNodeTypeId && + r.destinationId === targetNodeTypeId) || + (r.sourceId === targetNodeTypeId && + r.destinationId === sourceNodeTypeId)), + ); +};