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..c1a471d77 --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -0,0 +1,565 @@ +import { useCallback, useEffect, 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 { updateRelationType } from "~/utils/relationsStore"; +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 [editingArrowId, setEditingArrowId] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const sourceNodeRef = useRef(null); + // Tracks the arrow id we just finished editing, so the useEffect doesn't re-open the dropdown + const justEditedRef = 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], + ); + + // Track when user selects an existing persisted relation arrow + const selectedRelationArrow = useValue( + "selectedRelationArrow", + () => { + const shape = editor.getOnlySelectedShape(); + if ( + shape?.type === "discourse-relation" && + (shape.meta as Record)?.relationInstanceId + ) { + return shape as DiscourseRelationShape; + } + return null; + }, + [editor], + ); + + // Clear justEditedRef when the user selects a different shape (not just transient null) + const currentSelectedId = useValue( + "currentSelectedId", + () => editor.getOnlySelectedShape()?.id ?? null, + [editor], + ); + useEffect(() => { + if ( + justEditedRef.current && + currentSelectedId !== justEditedRef.current + ) { + justEditedRef.current = null; + } + }, [currentSelectedId]); + + // Open edit dropdown when a persisted relation arrow is selected + useEffect(() => { + if (selectedRelationArrow && !pendingArrowId && !isDragging) { + if (justEditedRef.current === selectedRelationArrow.id) return; + setEditingArrowId(selectedRelationArrow.id); + } else if (!selectedRelationArrow) { + setEditingArrowId(null); + } + }, [selectedRelationArrow, pendingArrowId, isDragging]); + + 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); + } + } + + // Prevent the edit-flow useEffect from re-opening the dropdown + // (reifyRelationInFrontmatter will set meta.relationInstanceId on this arrow) + justEditedRef.current = pendingArrowId; + 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 handleEditSelect = useCallback( + (relationTypeId: string) => { + if (!editingArrowId) return; + + const shape = editor.getShape(editingArrowId); + if (!shape) { + setEditingArrowId(null); + return; + } + + // Same type re-selected — just dismiss + if (shape.props.relationTypeId === relationTypeId) { + setEditingArrowId(null); + return; + } + + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relationTypeId, + ); + if (!relationType) { + setEditingArrowId(null); + return; + } + + const relationInstanceId = ( + shape.meta as Record + )?.relationInstanceId; + if (typeof relationInstanceId === "string") { + void updateRelationType(plugin, relationInstanceId, relationTypeId); + } + + // Update arrow visual props + editor.updateShapes([ + { + id: editingArrowId, + type: "discourse-relation", + props: { + relationTypeId, + color: relationType.color, + }, + }, + ]); + + // Update text label for direction + const updatedShape = + editor.getShape(editingArrowId); + if (updatedShape) { + const bindings = getArrowBindings(editor, updatedShape); + const util = editor.getShapeUtil(updatedShape); + if (util instanceof DiscourseRelationUtil) { + util.updateRelationTextForDirection(updatedShape, bindings); + } + } + + justEditedRef.current = editingArrowId; + setEditingArrowId(null); + }, + [editor, editingArrowId, plugin], + ); + + const handleEditDismiss = useCallback(() => { + justEditedRef.current = editingArrowId; + setEditingArrowId(null); + }, [editingArrowId]); + + const showHandles = !!handlePositions && !pendingArrowId && !editingArrowId; + + 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 — new arrow */} + {pendingArrowId && ( + + )} + + {/* Relation type dropdown — edit existing arrow */} + {editingArrowId && !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) => ( + + ))} +
+
+ ); +}; diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 659b57650..41f4951b7 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -161,6 +161,19 @@ export const addRelation = async ( return { id, alreadyExisted: false }; }; +export const updateRelationType = async ( + plugin: DiscourseGraphPlugin, + relationInstanceId: string, + newType: string, +): Promise => { + const data = await loadRelations(plugin); + const relation = data.relations[relationInstanceId]; + if (!relation) return false; + relation.type = newType; + await saveRelations(plugin, data); + return true; +}; + export const removeRelationById = async ( plugin: DiscourseGraphPlugin, relationInstanceId: string,