diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index d19bfea5e..6b8c72169 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -55,6 +55,7 @@ import { } from "tldraw"; import "tldraw/tldraw.css"; import tldrawStyles from "./tldrawStyles"; +import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; import getDiscourseRelations, { DiscourseRelation, @@ -666,6 +667,7 @@ const TldrawCanvasShared = ({ const editorComponents: TLEditorComponents = { ...defaultEditorComponents, OnTheCanvas: ToastListener, + InFrontOfTheCanvas: DragHandleOverlay, }; const customUiComponents: TLUiComponents = createUiComponents({ allNodes, diff --git a/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx new file mode 100644 index 000000000..433cd249d --- /dev/null +++ b/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -0,0 +1,449 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + TLShapeId, + createShapeId, + useEditor, + useValue, +} from "tldraw"; +import { + BaseDiscourseNodeUtil, + DiscourseNodeShape, +} from "~/components/canvas/DiscourseNodeUtil"; +import { + BaseDiscourseRelationUtil, + DiscourseRelationShape, + getRelationColor, +} from "~/components/canvas/DiscourseRelationShape/DiscourseRelationUtil"; +import { + createOrUpdateArrowBinding, +} from "~/components/canvas/DiscourseRelationShape/helpers"; +import { discourseContext } from "~/components/canvas/Tldraw"; +import { dispatchToastEvent } from "~/components/canvas/ToastListener"; +import { RelationTypeDropdown } from "./RelationTypeDropdown"; + +const HANDLE_RADIUS = 5; +const HANDLE_HIT_AREA = 12; +const HANDLE_PADDING = 8; + +type HandlePosition = { + x: number; + y: number; + anchor: { x: number; y: number }; +}; + +/** Pending connection: source + target nodes identified, waiting for relation type pick */ +type PendingConnection = { + sourceId: TLShapeId; + targetId: TLShapeId; + /** Viewport coords for dropdown positioning (midpoint between nodes) */ + dropdownPos: { x: number; y: number }; +}; + +const getEdgeMidpoints = (bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; +}): HandlePosition[] => [ + { + x: (bounds.minX + bounds.maxX) / 2, + y: bounds.minY - HANDLE_PADDING, + anchor: { x: 0.5, y: 0 }, + }, + { + x: bounds.maxX + HANDLE_PADDING, + y: (bounds.minY + bounds.maxY) / 2, + anchor: { x: 1, y: 0.5 }, + }, + { + x: (bounds.minX + bounds.maxX) / 2, + y: bounds.maxY + HANDLE_PADDING, + anchor: { x: 0.5, y: 1 }, + }, + { + x: bounds.minX - HANDLE_PADDING, + y: (bounds.minY + bounds.maxY) / 2, + anchor: { x: 0, y: 0.5 }, + }, +]; + +const isDiscourseNode = ( + editor: ReturnType, + shapeType: string, +): boolean => { + try { + const util = editor.getShapeUtil(shapeType); + return util instanceof BaseDiscourseNodeUtil; + } catch { + return false; + } +}; + +const hasValidRelationTypes = ( + sourceNodeType: string, + targetNodeType: string, +): boolean => { + const allRelations = Object.values(discourseContext.relations).flat(); + return allRelations.some( + (r) => + (r.source === sourceNodeType && r.destination === targetNodeType) || + (r.source === targetNodeType && r.destination === sourceNodeType), + ); +}; + +export const DragHandleOverlay = () => { + const editor = useEditor(); + + // Drag state: track the drag line in viewport coords (no tldraw shapes) + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>( + null, + ); + const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null); + const [hoveredTarget, setHoveredTarget] = useState(null); + + // After a successful drop, show the relation type dropdown + const [pending, setPending] = useState(null); + + const sourceNodeRef = useRef(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + dragCleanupRef.current?.(); + }; + }, []); + + // Track the single selected discourse node + const selectedNode = useValue( + "dragHandleSelectedNode", + () => { + if (isDragging || pending) return sourceNodeRef.current; + const shape = editor.getOnlySelectedShape(); + if (shape && isDiscourseNode(editor, shape.type)) { + return shape as DiscourseNodeShape; + } + return null; + }, + [editor, isDragging, pending], + ); + + // Compute handle positions in viewport space + const handlePositions = useValue< + { left: number; top: number; anchor: { x: number; y: number } }[] | null + >( + "dragHandlePositions", + () => { + if (!selectedNode || pending || 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, pending, isDragging], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent, _anchor: { x: number; y: number }) => { + if (!selectedNode) return; + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + + sourceNodeRef.current = selectedNode; + setIsDragging(true); + + const startVp = { x: e.clientX, y: e.clientY }; + // Get container bounding rect to convert client coords to overlay-relative + const containerRect = editor.getContainer().getBoundingClientRect(); + setDragStart({ + x: startVp.x - containerRect.left, + y: startVp.y - containerRect.top, + }); + setDragEnd({ + x: startVp.x - containerRect.left, + y: startVp.y - containerRect.top, + }); + + const containerEl = editor.getContainer(); + + const onPointerMove = (moveEvent: PointerEvent) => { + const rect = containerEl.getBoundingClientRect(); + setDragEnd({ + x: moveEvent.clientX - rect.left, + y: moveEvent.clientY - rect.top, + }); + + // Check for target node under cursor + const pagePoint = editor.screenToPage({ + x: moveEvent.clientX, + y: moveEvent.clientY, + }); + const target = editor.getShapeAtPoint(pagePoint, { + hitInside: true, + hitFrameInside: true, + margin: 0, + filter: (s) => + isDiscourseNode(editor, s.type) && + s.id !== selectedNode.id && + !s.isLocked, + }); + setHoveredTarget(target?.id ?? null); + editor.setHintingShapes(target ? [target.id] : []); + }; + + const onPointerUp = (upEvent: PointerEvent) => { + containerEl.removeEventListener("pointermove", onPointerMove); + containerEl.removeEventListener("pointerup", onPointerUp); + dragCleanupRef.current = null; + editor.setHintingShapes([]); + setIsDragging(false); + setDragStart(null); + setDragEnd(null); + setHoveredTarget(null); + + // Check what we dropped on + const pagePoint = editor.screenToPage({ + x: upEvent.clientX, + y: upEvent.clientY, + }); + const target = editor.getShapeAtPoint(pagePoint, { + hitInside: true, + hitFrameInside: true, + margin: 0, + filter: (s) => + isDiscourseNode(editor, s.type) && + s.id !== selectedNode.id && + !s.isLocked, + }); + + if (!target) { + dispatchToastEvent({ + id: "tldraw-drag-handle-warning", + title: "Drop on a discourse node to create a relation", + severity: "warning", + }); + sourceNodeRef.current = null; + return; + } + + // Validate that relation types exist between these node types + if (!hasValidRelationTypes(selectedNode.type, target.type)) { + dispatchToastEvent({ + id: "tldraw-no-valid-relation", + title: "No relation types are defined between these node types", + severity: "warning", + }); + sourceNodeRef.current = null; + return; + } + + // Compute dropdown position: midpoint between source and target in viewport space + const sourceBounds = editor.getShapePageBounds(selectedNode.id); + const targetBounds = editor.getShapePageBounds(target.id); + if (!sourceBounds || !targetBounds) { + sourceNodeRef.current = null; + return; + } + const midPage = { + x: (sourceBounds.midX + targetBounds.midX) / 2, + y: (sourceBounds.midY + targetBounds.midY) / 2, + }; + const midVp = editor.pageToViewport(midPage); + + setPending({ + sourceId: selectedNode.id, + targetId: target.id, + dropdownPos: midVp, + }); + }; + + containerEl.addEventListener("pointermove", onPointerMove); + containerEl.addEventListener("pointerup", onPointerUp); + dragCleanupRef.current = () => { + containerEl.removeEventListener("pointermove", onPointerMove); + containerEl.removeEventListener("pointerup", onPointerUp); + dragCleanupRef.current = null; + }; + }, + [selectedNode, editor], + ); + + const handleDropdownSelect = useCallback( + (relationId: string) => { + if (!pending) return; + + const allRelations = Object.values(discourseContext.relations).flat(); + const selectedRelation = allRelations.find((r) => r.id === relationId); + if (!selectedRelation) { + setPending(null); + sourceNodeRef.current = null; + return; + } + + const color = getRelationColor(selectedRelation.label); + + // Get source bounds for arrow positioning + const sourceBounds = editor.getShapePageBounds(pending.sourceId); + if (!sourceBounds) { + setPending(null); + sourceNodeRef.current = null; + return; + } + + // Create the real relation shape with the correct type + const arrowId = createShapeId(); + editor.createShape({ + id: arrowId, + type: relationId, + x: sourceBounds.midX, + y: sourceBounds.midY, + props: { + color, + labelColor: color, + text: selectedRelation.label, + dash: "draw", + size: "m", + fill: "none", + bend: 0, + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + arrowheadStart: "none", + arrowheadEnd: "arrow", + labelPosition: 0.5, + font: "draw", + scale: 1, + }, + }); + + const newArrow = editor.getShape(arrowId); + if (!newArrow) { + setPending(null); + sourceNodeRef.current = null; + return; + } + + // Bind start and end + createOrUpdateArrowBinding(editor, newArrow, pending.sourceId, { + terminal: "start", + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + isExact: false, + }); + createOrUpdateArrowBinding(editor, newArrow, pending.targetId, { + terminal: "end", + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + isExact: false, + }); + + // Persist via handleCreateRelationsInRoam + const util = editor.getShapeUtil(newArrow); + if ( + util instanceof BaseDiscourseRelationUtil && + typeof (util as any).handleCreateRelationsInRoam === "function" + ) { + void (util as any).handleCreateRelationsInRoam({ + arrow: editor.getShape(arrowId) ?? newArrow, + targetId: pending.targetId, + }); + } + + editor.select(arrowId); + setPending(null); + sourceNodeRef.current = null; + }, + [editor, pending], + ); + + const handleDropdownDismiss = useCallback(() => { + setPending(null); + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + }, [editor]); + + const showHandles = !!handlePositions && !pending && !isDragging; + + 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, + }} + > +
+
+ ))} + + {/* SVG drag line while dragging */} + {isDragging && dragStart && dragEnd && ( + + + {/* Arrowhead */} + + + )} + + {/* Relation type dropdown */} + {pending && ( + + )} +
+ ); +}; diff --git a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx new file mode 100644 index 000000000..13aad53de --- /dev/null +++ b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -0,0 +1,200 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { + TLShapeId, + useEditor, + DefaultColorThemePalette, +} from "tldraw"; +import { discourseContext } from "~/components/canvas/Tldraw"; +import { BaseDiscourseNodeUtil } from "~/components/canvas/DiscourseNodeUtil"; +import { getRelationColor } from "~/components/canvas/DiscourseRelationShape/DiscourseRelationUtil"; + +type RelationTypeDropdownProps = { + sourceId: TLShapeId; + targetId: TLShapeId; + dropdownPos: { x: number; y: number }; + onSelect: (relationId: string) => void; + onDismiss: () => void; +}; + +export const RelationTypeDropdown = ({ + sourceId, + targetId, + dropdownPos, + onSelect, + onDismiss, +}: RelationTypeDropdownProps) => { + const editor = useEditor(); + const dropdownRef = useRef(null); + + // Get valid relation types based on source/target node types + const validRelationTypes = useMemo(() => { + const startNode = editor.getShape(sourceId); + const endNode = editor.getShape(targetId); + if (!startNode || !endNode) return []; + + const startNodeType = startNode.type; + const endNodeType = endNode.type; + + // Verify both are discourse nodes + try { + const startUtil = editor.getShapeUtil(startNode); + const endUtil = editor.getShapeUtil(endNode); + if ( + !(startUtil instanceof BaseDiscourseNodeUtil) || + !(endUtil instanceof BaseDiscourseNodeUtil) + ) + return []; + } catch { + return []; + } + + const colorPalette = DefaultColorThemePalette.lightMode; + const validTypes: { id: string; label: string; color: string }[] = []; + const allRelations = Object.values(discourseContext.relations).flat(); + const seenLabels = new Set(); + + for (const relation of allRelations) { + const matches = + (relation.source === startNodeType && + relation.destination === endNodeType) || + (relation.source === endNodeType && + relation.destination === startNodeType); + + if (matches && !seenLabels.has(relation.label)) { + seenLabels.add(relation.label); + const tldrawColor = getRelationColor(relation.label); + const hexColor = colorPalette[tldrawColor]?.solid ?? "#333"; + validTypes.push({ + id: relation.id, + label: relation.label, + color: hexColor, + }); + } + } + + return validTypes; + }, [editor, sourceId, targetId]); + + // Handle click outside + useEffect(() => { + const handlePointerDown = (e: PointerEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + onDismiss(); + } + }; + + 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( + (relationId: string) => { + onSelect(relationId); + }, + [onSelect], + ); + + if (validRelationTypes.length === 0) return null; + + return ( +
e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
+
+ Relation Type +
+ {validRelationTypes.map((rt) => ( + + ))} +
+
+ ); +};