import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { canvasFont, clearShadow, fillAccentWash, paintAccentRail, paintBadgeChip, paintGlassGlow, paintGlassHighlight, paintNodeShadow, } from "./canvas-chrome"; import { collectSubtreeIds, ghostPos, isDescendantOrSelf, type DragGhost, } from "./drag-subtree"; import { HoverCard } from "./HoverCard"; import { getCachedImage } from "./image-cache"; import { drawNodeShape, nodeBadges, nodeBoxSize, wrapTextLines, } from "./node-geometry"; import { drawPropSheet, NodePropOverlay } from "./node-props"; import { branchPalette, readSystemTheme } from "./system-theme"; import { useAppStore } from "./store"; import { useCanvasUi } from "./useCanvasUi"; const NODE_W = 200; type DragMode = "none" | "pan" | "node" | "link"; type ToolMode = "select" | "pan" | "link"; /** branch = drag whole subtree; node = drag only the grabbed node */ type DragScope = "branch" | "node"; const MINI_W = 168; const MINI_H = 118; export function TopologyCanvas() { const { U } = useCanvasUi(); const canvasRef = useRef(null); const miniRef = useRef(null); const hostRef = useRef(null); const nodes = useAppStore((s) => s.nodes); const edges = useAppStore((s) => s.edges); const positions = useAppStore((s) => s.positions); const selectedIds = useAppStore((s) => s.selectedIds); const highlightIds = useAppStore((s) => s.highlightIds); const highlightEdgeIds = useAppStore((s) => s.highlightEdgeIds); const highlightStyle = useAppStore((s) => s.highlightStyle); const canvas = useAppStore((s) => s.canvas); const applyOps = useAppStore((s) => s.applyOps); const setPrefs = useAppStore((s) => s.setPrefs); const patchLocal = useAppStore((s) => s.patchLocal); const setSelection = useAppStore((s) => s.setSelection); const undo = useAppStore((s) => s.undo); const lastChangeSetId = useAppStore((s) => s.lastChangeSetId); const embed = typeof window !== "undefined" && (() => { try { const q = new URLSearchParams(window.location.search); return q.get("embed") === "1" || q.get("embed") === "true"; } catch { return false; } })(); const requestBackToRoster = useCallback(() => { try { window.parent?.postMessage({ type: "dsh-mind-map:back" }, "*"); } catch { /* ignore */ } }, []); const toggleFullscreen = useCallback(async () => { const el = hostRef.current; if (!el) return; try { if (document.fullscreenElement) { await document.exitFullscreen(); return; } await el.requestFullscreen(); } catch { // iframe: notify parent Tab fullscreen when needed try { window.parent?.postMessage({ type: "dsh-mind-map:fullscreen" }, "*"); } catch { /* ignore */ } } }, []); useEffect(() => { const onFs = () => setFullscreen(Boolean(document.fullscreenElement)); document.addEventListener("fullscreenchange", onFs); return () => document.removeEventListener("fullscreenchange", onFs); }, []); const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); const fitViewport = useAppStore((s) => s.fitViewport); const consumeFitViewport = useAppStore((s) => s.consumeFitViewport); useEffect(() => { if (!fitViewport) return; const vp = consumeFitViewport(); if (vp) setViewport(vp); }, [fitViewport, consumeFitViewport]); const [toolMode, setToolMode] = useState("select"); const [dragScope, setDragScope] = useState("node"); const [locked, setLocked] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [themeTick, setThemeTick] = useState(0); const [imageTick, setImageTick] = useState(0); const [editing, setEditing] = useState<{ id: string; x: number; y: number; text: string; } | null>(null); const [hoverId, setHoverId] = useState(null); const [linkPreview, setLinkPreview] = useState<{ fromId: string; x: number; y: number; } | null>(null); const [ghost, setGhost] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [dropForbidden, setDropForbidden] = useState(false); const [hostSize, setHostSize] = useState({ w: 1, h: 1 }); const [dragCursor, setDragCursor] = useState(null); const dragRef = useRef<{ mode: DragMode; lastX: number; lastY: number; space: boolean; nodeId: string | null; startX: number; startY: number; moved: boolean; alt: boolean; forcePin: boolean; /** World coords at pointer-down (for grab-point preserving delta). */ startWorldX: number; startWorldY: number; subtreeIds: string[]; }>({ mode: "none", lastX: 0, lastY: 0, space: false, nodeId: null, startX: 0, startY: 0, moved: false, alt: false, forcePin: false, startWorldX: 0, startWorldY: 0, subtreeIds: [], }); const ghostRef = useRef(null); ghostRef.current = ghost; const miniDragRef = useRef(false); const theme = useMemo(() => readSystemTheme(), [themeTick]); const palette = useMemo(() => branchPalette(theme.brand, 8), [theme.brand]); useEffect(() => { const onScheme = () => setThemeTick((n) => n + 1); const mq = window.matchMedia("(prefers-color-scheme: light)"); mq.addEventListener?.("change", onScheme); return () => mq.removeEventListener?.("change", onScheme); }, []); const dens = canvas?.prefs.density === "compact"; const cardView = canvas?.prefs.nodeViewMode !== "bubble"; const boxOf = useCallback( (n: (typeof nodes)[number]) => nodeBoxSize(n, dens, { asCard: cardView }), [dens, cardView], ); const nw = dens ? 160 : NODE_W; const branchColoring = canvas?.prefs.branchColoring !== false; const branchColor = useMemo(() => { const map = new Map(); if (!branchColoring) return map; const roots = nodes.filter((n) => { if (n.deletedAt) return false; return !edges.some( (e) => e.kind === "hierarchy" && e.isPrimaryParent && e.to === n.id, ); }); let i = 0; for (const r of roots) { map.set(r.id, palette[i % palette.length]!); i++; } const walk = (id: string, color: string) => { for (const e of edges) { if (e.kind === "hierarchy" && e.isPrimaryParent && e.from === id) { map.set(e.to, color); walk(e.to, color); } } }; for (const [id, color] of map) walk(id, color); return map; }, [nodes, edges, palette, branchColoring]); const toWorld = useCallback( (clientX: number, clientY: number) => { const host = hostRef.current!; const rect = host.getBoundingClientRect(); return { x: (clientX - rect.left - rect.width / 2 - viewport.x) / viewport.zoom, y: (clientY - rect.top - rect.height / 2 - viewport.y) / viewport.zoom, }; }, [viewport], ); const posOf = useCallback( (id: string) => ghostPos(id, positions[id], ghost), [positions, ghost], ); const hitTest = useCallback( (wx: number, wy: number, exclude?: Set) => { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]!; if (n.deletedAt) continue; if (exclude?.has(n.id)) continue; const p = posOf(n.id); if (!p) continue; const { w, h } = boxOf(n); if (wx >= p.x - w / 2 && wx <= p.x + w / 2 && wy >= p.y - h / 2 && wy <= p.y + h / 2) { return n.id; } } return null; }, [nodes, posOf, dens], ); const worldBounds = useMemo(() => { let minX = 0; let maxX = 0; let minY = 0; let maxY = 0; let any = false; for (const n of nodes) { if (n.deletedAt) continue; const p = positions[n.id]; if (!p) continue; if (!any) { minX = p.x; maxX = p.x; minY = p.y; maxY = p.y; any = true; } else { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); } } if (!any) return { minX: -200, maxX: 200, minY: -120, maxY: 120 }; const pad = 80; return { minX: minX - pad, maxX: maxX + pad, minY: minY - pad, maxY: maxY + pad, }; }, [nodes, positions]); const setZoomAroundCenter = useCallback((nextZoom: number) => { setViewport((v) => ({ ...v, zoom: Math.min(4, Math.max(0.02, nextZoom)), })); }, []); const fitView = useCallback(() => { const { minX, maxX, minY, maxY } = worldBounds; const bw = Math.max(40, maxX - minX); const bh = Math.max(40, maxY - minY); const w = hostSize.w || 1; const h = hostSize.h || 1; const zoom = Math.min( 2.5, Math.max(0.12, Math.min((w * 0.86) / bw, (h * 0.86) / bh)), ); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; setViewport({ x: -cx * zoom, y: -cy * zoom, zoom }); }, [worldBounds, hostSize]); const panFromMini = useCallback( (clientX: number, clientY: number) => { const el = miniRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const { minX, maxX, minY, maxY } = worldBounds; const bw = Math.max(1, maxX - minX); const bh = Math.max(1, maxY - minY); const wx = minX + ((clientX - rect.left) / rect.width) * bw; const wy = minY + ((clientY - rect.top) / rect.height) * bh; setViewport((v) => ({ ...v, x: -wx * v.zoom, y: -wy * v.zoom, })); }, [worldBounds], ); const draw = useCallback(() => { const el = canvasRef.current; if (!el) return; const ctx = el.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = el.clientWidth; const h = el.clientHeight; ctx.clearRect(0, 0, w, h); ctx.fillStyle = theme.bgBase; ctx.fillRect(0, 0, w, h); ctx.save(); ctx.translate(w / 2 + viewport.x, h / 2 + viewport.y); ctx.scale(viewport.zoom, viewport.zoom); ctx.strokeStyle = theme.border; ctx.lineWidth = 1 / viewport.zoom; const grid = 48; const startX = Math.floor((-w / 2 - viewport.x) / viewport.zoom / grid) * grid - grid * 2; const endX = Math.ceil((w / 2 - viewport.x) / viewport.zoom / grid) * grid + grid * 2; const startY = Math.floor((-h / 2 - viewport.y) / viewport.zoom / grid) * grid - grid * 2; const endY = Math.ceil((h / 2 - viewport.y) / viewport.zoom / grid) * grid + grid * 2; ctx.globalAlpha = 0.35; ctx.beginPath(); for (let x = startX; x <= endX; x += grid) { ctx.moveTo(x, startY); ctx.lineTo(x, endY); } for (let y = startY; y <= endY; y += grid) { ctx.moveTo(startX, y); ctx.lineTo(endX, y); } ctx.stroke(); ctx.globalAlpha = 1; for (const e of edges) { if (canvas?.prefs.showRelationEdges === false && e.kind === "relation") continue; const a = posOf(e.from); const b = posOf(e.to); if (!a || !b) continue; const fromN = nodes.find((n) => n.id === e.from); const toN = nodes.find((n) => n.id === e.to); const aw = fromN ? boxOf(fromN).w : nw; const bw = toN ? boxOf(toN).w : nw; ctx.beginPath(); const x1 = a.x + (b.x >= a.x ? aw / 2 : -aw / 2); const y1 = a.y; const x2 = b.x + (b.x >= a.x ? -bw / 2 : bw / 2); const y2 = b.y; const cx = (x1 + x2) / 2; ctx.moveTo(x1, y1); ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2); const weight = Math.max(1, Math.min(5, e.weight ?? 1)); const style = e.lineStyle ?? (e.kind === "relation" ? "dashed" : "solid"); if (style === "dotted") { ctx.setLineDash([2 / viewport.zoom, 4 / viewport.zoom]); } else if (style === "dashed") { ctx.setLineDash([6 / viewport.zoom, 4 / viewport.zoom]); } else { ctx.setLineDash([]); } const isEdgeFlash = highlightEdgeIds.includes(e.id); const flashEdgeColor = highlightStyle?.edgeColor ?? theme.brand; const flashEdgeWidth = highlightStyle?.edgeWidth ?? 3.6; ctx.strokeStyle = isEdgeFlash ? flashEdgeColor : e.kind === "relation" ? theme.labelSecondary : branchColor.get(e.to) ?? theme.brand; ctx.lineWidth = (isEdgeFlash ? flashEdgeWidth : 1.2 + (weight - 1) * 0.7) / viewport.zoom; ctx.stroke(); ctx.setLineDash([]); // Arrowheads: direction forward|both|none (relation default forward) const dir = e.direction ?? (e.kind === "relation" ? "forward" : "none"); if (dir === "forward" || dir === "both") { const arrowAng = Math.atan2(0, x2 - cx || (x2 >= a.x ? 1 : -1)); const size = 8 / viewport.zoom; const fill = isEdgeFlash ? flashEdgeColor : e.kind === "relation" ? theme.labelSecondary : branchColor.get(e.to) ?? theme.brand; ctx.beginPath(); ctx.fillStyle = fill; ctx.moveTo(x2, y2); ctx.lineTo( x2 - size * Math.cos(arrowAng - 0.45), y2 - size * Math.sin(arrowAng - 0.45), ); ctx.lineTo( x2 - size * Math.cos(arrowAng + 0.45), y2 - size * Math.sin(arrowAng + 0.45), ); ctx.closePath(); ctx.fill(); } if (dir === "both") { const arrowAng = Math.atan2(0, x1 - cx || (a.x >= b.x ? 1 : -1)); const size = 8 / viewport.zoom; const fill = isEdgeFlash ? flashEdgeColor : e.kind === "relation" ? theme.labelSecondary : branchColor.get(e.to) ?? theme.brand; ctx.beginPath(); ctx.fillStyle = fill; ctx.moveTo(x1, y1); ctx.lineTo( x1 - size * Math.cos(arrowAng - 0.45), y1 - size * Math.sin(arrowAng - 0.45), ); ctx.lineTo( x1 - size * Math.cos(arrowAng + 0.45), y1 - size * Math.sin(arrowAng + 0.45), ); ctx.closePath(); ctx.fill(); } if (e.label && viewport.zoom >= 0.25) { ctx.fillStyle = theme.labelSecondary; ctx.font = `11px sans-serif`; ctx.fillText(e.label, cx, (y1 + y2) / 2 - 6); } } if (linkPreview) { const a = posOf(linkPreview.fromId); if (a) { ctx.beginPath(); ctx.setLineDash([4 / viewport.zoom, 4 / viewport.zoom]); ctx.strokeStyle = theme.brand; ctx.moveTo(a.x, a.y); ctx.lineTo(linkPreview.x, linkPreview.y); ctx.stroke(); ctx.setLineDash([]); } } const badgeMode = canvas?.prefs.nodeBadges ?? "icons"; for (const n of nodes) { if (n.deletedAt) continue; const p = posOf(n.id); if (!p) continue; const inGhost = Boolean(ghost?.ids.includes(n.id)); const { w: boxW, h: boxH } = boxOf(n); const x = p.x - boxW / 2; const y = p.y - boxH / 2; const fill = n.accentColor ?? branchColor.get(n.id) ?? theme.bubble; const isSel = selectedIds.includes(n.id); const isFlash = highlightIds.includes(n.id); const flashNodeColor = highlightStyle?.nodeColor ?? theme.brand; const isHover = hoverId === n.id; const isDrop = dropTargetId === n.id; const isForbidden = dropForbidden && isDrop; if (inGhost) ctx.globalAlpha = 0.7; if (isFlash && highlightStyle?.nodeGlow !== false) { ctx.shadowColor = flashNodeColor; ctx.shadowBlur = 14 / viewport.zoom; } else if (cardView) { // Accent bloom pass (low alpha), then glass body with drop shadow ctx.save(); paintGlassGlow(ctx, fill, viewport.zoom, isSel); ctx.globalAlpha = (inGhost ? 0.7 : 1) * (isSel ? 0.28 : 0.16); ctx.fillStyle = fill; drawNodeShape( ctx, "card", x, y, boxW, boxH, dens ? 14 : 16, ); ctx.fill(); ctx.restore(); if (inGhost) ctx.globalAlpha = 0.7; paintNodeShadow(ctx, viewport.zoom, { selected: isSel }); } else { paintNodeShadow(ctx, viewport.zoom, { selected: isSel }); } ctx.fillStyle = cardView ? theme.glassFill : theme.bgLayer1; const drawPreset = cardView ? "card" : n.stylePreset; const cornerR = cardView ? dens ? 14 : 16 : n.stylePreset === "title" ? boxH / 2 : 12; drawNodeShape(ctx, drawPreset, x, y, boxW, boxH, cornerR); ctx.fill(); clearShadow(ctx); ctx.globalAlpha = inGhost ? 0.7 : 1; fillAccentWash( ctx, fill, cardView ? 0.06 : n.stylePreset === "title" ? 0.22 : 0.14, ); if (cardView) { paintGlassHighlight( ctx, x, y, boxW, boxH, cornerR, theme.glassHighlight, ); } if ( !cardView && n.stylePreset !== "decision" && n.stylePreset !== "title" && n.stylePreset !== "note" ) { paintAccentRail(ctx, x, y, boxH, fill, 12); } if (!cardView && n.stylePreset === "note") { ctx.setLineDash([4 / viewport.zoom, 3 / viewport.zoom]); } else { ctx.setLineDash([]); } // Card: thin glass border; soft accent only on hover/select (not thick colored frame) if (cardView) { ctx.strokeStyle = isForbidden ? theme.warn : isDrop || isFlash ? theme.brand : isSel || isHover ? fill : theme.glassBorder; ctx.lineWidth = (isDrop || isFlash || isSel || isHover ? 1.6 : 1) / viewport.zoom; } else { ctx.strokeStyle = isForbidden ? theme.warn : isDrop ? theme.brand : n.stylePreset === "risk" ? theme.warn : isFlash ? flashNodeColor : isSel || isHover ? theme.brand : fill; ctx.lineWidth = (isDrop || isFlash || isSel || n.stylePreset === "title" || n.stylePreset === "decision" ? 2.4 : 1.35) / viewport.zoom; } if (!cardView && n.stylePreset === "muted") ctx.globalAlpha = inGhost ? 0.4 : 0.55; clearShadow(ctx); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = inGhost ? 0.7 : 1; if (cardView) { const selected = selectedIds.includes(n.id); if (!selected) { drawPropSheet( ctx, n, x, y, theme, viewport.zoom, dens, fill, () => setImageTick((t) => t + 1), ); } else { // Match glass header so overlay mount doesn't flash empty ctx.fillStyle = theme.glassHeader; ctx.fillRect(x, y, boxW, 40); ctx.beginPath(); ctx.arc(x + 16, y + 20, 4, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.fillStyle = theme.labelPrimary; ctx.font = canvasFont(14, 600); ctx.textBaseline = "middle"; const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 44, 1); ctx.fillText(ph[0] ?? "?", x + 28, y + 20); } } else { const padX = 14; const contentLeft = padX + (n.stylePreset === "decision" || n.stylePreset === "title" || n.stylePreset === "note" ? 0 : 4); let contentTop = y + 12; if (n.imageUrl) { const ih = dens ? 64 : 78; const iw = boxW - contentLeft - 12; const img = getCachedImage(n.imageUrl, () => setImageTick((t) => t + 1), ); ctx.save(); ctx.beginPath(); drawNodeShape(ctx, "default", x + contentLeft, contentTop, iw, ih, 8); ctx.clip(); if (img && img.naturalWidth > 0) { // Contain: fit image inside box, no crop const scale = Math.min( iw / img.naturalWidth, ih / img.naturalHeight, ); const dw = img.naturalWidth * scale; const dh = img.naturalHeight * scale; ctx.fillStyle = "rgba(0,0,0,0.22)"; ctx.fillRect(x + contentLeft, contentTop, iw, ih); ctx.drawImage( img, x + contentLeft + (iw - dw) / 2, contentTop + (ih - dh) / 2, dw, dh, ); } else { ctx.fillStyle = theme.border; ctx.fillRect(x + contentLeft, contentTop, iw, ih); } ctx.restore(); contentTop += ih + 8; } ctx.fillStyle = n.stylePreset === "muted" ? theme.labelSecondary : theme.labelPrimary; const fontSize = dens ? 12 : n.stylePreset === "title" ? 15 : 13; const weight = n.stylePreset === "title" || n.stylePreset === "decision" ? 600 : 500; ctx.font = canvasFont(fontSize, weight as 500 | 600); ctx.textBaseline = "top"; const maxW = boxW - contentLeft - 12; const maxLines = dens ? 2 : n.stylePreset === "title" ? 2 : 2; const lines = wrapTextLines(ctx, n.text || U.unnamed, maxW, maxLines); const lineH = fontSize + 3; const textBlockH = lines.length * lineH; const badges = badgeMode !== "off" && viewport.zoom >= 0.35 ? badgeMode === "tags" ? (n.tags ?? []).slice(0, 2).map((t) => `#${t}`) : nodeBadges(n).slice(0, 4) : []; const badgeH = badges.length ? 20 : 0; let titleY = contentTop; if (!n.imageUrl) { const available = boxH - 16 - badgeH; titleY = y + Math.max(10, (available - textBlockH) / 2); } lines.forEach((line, i) => { ctx.fillText(line, x + contentLeft, titleY + i * lineH); }); if (badges.length) { let bx = x + contentLeft; const by = y + boxH - 22; for (const b of badges) { bx += paintBadgeChip(ctx, b, bx, by, theme, viewport.zoom); } } } ctx.globalAlpha = 1; } ctx.restore(); }, [ theme, viewport, edges, posOf, nodes, branchColor, selectedIds, highlightIds, highlightEdgeIds, highlightStyle, hoverId, linkPreview, ghost, dropTargetId, dropForbidden, nw, dens, cardView, boxOf, canvas?.prefs.showRelationEdges, canvas?.prefs.nodeBadges, imageTick, U, ]); const drawMini = useCallback(() => { const el = miniRef.current; if (!el) return; const ctx = el.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; el.width = Math.floor(MINI_W * dpr); el.height = Math.floor(MINI_H * dpr); el.style.width = `${MINI_W}px`; el.style.height = `${MINI_H}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, MINI_W, MINI_H); ctx.fillStyle = theme.bgLayer1; ctx.fillRect(0, 0, MINI_W, MINI_H); const { minX, maxX, minY, maxY } = worldBounds; const bw = Math.max(1, maxX - minX); const bh = Math.max(1, maxY - minY); const sx = MINI_W / bw; const sy = MINI_H / bh; const scale = Math.min(sx, sy); const ox = (MINI_W - bw * scale) / 2; const oy = (MINI_H - bh * scale) / 2; const toMini = (wx: number, wy: number) => ({ x: ox + (wx - minX) * scale, y: oy + (wy - minY) * scale, }); ctx.strokeStyle = theme.border; ctx.lineWidth = 1; for (const e of edges) { if (e.kind !== "hierarchy") continue; const a = posOf(e.from); const b = posOf(e.to); if (!a || !b) continue; const p1 = toMini(a.x, a.y); const p2 = toMini(b.x, b.y); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); } for (const n of nodes) { if (n.deletedAt) continue; const p = posOf(n.id); if (!p) continue; const m = toMini(p.x, p.y); ctx.fillStyle = branchColor.get(n.id) ?? theme.brand; ctx.beginPath(); ctx.arc(m.x, m.y, 2.2, 0, Math.PI * 2); ctx.fill(); } const vw = hostSize.w / viewport.zoom; const vh = hostSize.h / viewport.zoom; const vcx = -viewport.x / viewport.zoom; const vcy = -viewport.y / viewport.zoom; const tl = toMini(vcx - vw / 2, vcy - vh / 2); const br = toMini(vcx + vw / 2, vcy + vh / 2); ctx.strokeStyle = theme.brand; ctx.lineWidth = 1.5; ctx.strokeRect(tl.x, tl.y, Math.max(4, br.x - tl.x), Math.max(4, br.y - tl.y)); }, [ theme, worldBounds, edges, posOf, nodes, branchColor, hostSize, viewport, ]); useEffect(() => { const el = canvasRef.current; const host = hostRef.current; if (!el || !host) return; const resize = () => { const rect = host.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; el.width = Math.floor(rect.width * dpr); el.height = Math.floor(rect.height * dpr); el.style.width = `${rect.width}px`; el.style.height = `${rect.height}px`; setHostSize({ w: rect.width, h: rect.height }); draw(); drawMini(); }; resize(); const ro = new ResizeObserver(resize); ro.observe(host); return () => ro.disconnect(); }, [draw, drawMini]); useEffect(() => { draw(); drawMini(); }, [draw, drawMini]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement | null)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; if (!e.metaKey && !e.ctrlKey && !e.altKey) { const k = e.key.toLowerCase(); if (k === "v") setToolMode("select"); if (k === "h") setToolMode("pan"); if (k === "c") setToolMode("link"); if (k === "b") setDragScope("branch"); if (k === "n") setDragScope("node"); if (k === "l") setLocked((v) => !v); if (k === "f") fitView(); if (k === "=" || k === "+") setZoomAroundCenter(viewport.zoom * 1.15); if (k === "-" || k === "_") setZoomAroundCenter(viewport.zoom / 1.15); } if (e.code === "Space") dragRef.current.space = true; if (e.key === "Alt") dragRef.current.alt = true; if (e.key === "Escape" && dragRef.current.mode === "node") { e.preventDefault(); dragRef.current.mode = "none"; dragRef.current.nodeId = null; dragRef.current.moved = false; setGhost(null); setDropTargetId(null); setDropForbidden(false); setDragCursor(null); return; } if (locked) return; if (e.key === "Tab" && !locked) { e.preventDefault(); const parent = selectedIds[0]; void applyOps([ parent ? { type: "create_node", text: U.newNode, parentId: parent } : { type: "create_node", text: U.newNode }, ]); } if (e.key === "ArrowLeft" && selectedIds[0] && !editing) { e.preventDefault(); void applyOps([ { type: "create_node", text: U.leftChild, parentId: selectedIds[0], sidePref: -1, }, ]); } if (e.key === "ArrowRight" && selectedIds[0] && !editing) { e.preventDefault(); void applyOps([ { type: "create_node", text: U.rightChild, parentId: selectedIds[0], sidePref: 1, }, ]); } if ((e.key === "Delete" || e.key === "Backspace") && !editing) { const id = selectedIds[0]; if (id) { void applyOps([{ type: "delete_node", nodeId: id }]); } } if (e.key === "Enter" && selectedIds[0] && !editing) { e.preventDefault(); const parentOfSel = edges.find( (x) => x.kind === "hierarchy" && x.isPrimaryParent && x.to === selectedIds[0], )?.from ?? null; void applyOps([ parentOfSel ? { type: "create_node", text: U.sibling, parentId: parentOfSel, } : { type: "create_node", text: U.newNode }, ]); } }; const onKeyUp = (e: KeyboardEvent) => { if (e.code === "Space") dragRef.current.space = false; if (e.key === "Alt") dragRef.current.alt = false; }; window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); }; }, [ applyOps, canvas, selectedIds, editing, edges, locked, fitView, setZoomAroundCenter, viewport.zoom, U, ]); const cursor = dragCursor ?? (toolMode === "pan" || dragRef.current.space ? "grab" : toolMode === "link" ? "crosshair" : "default"); return (
{ e.preventDefault(); const factor = e.deltaY > 0 ? 0.9 : 1.1; setViewport((v) => ({ ...v, zoom: Math.min(4, Math.max(0.02, v.zoom * factor)), })); }} onPointerDown={(e) => { const t = e.target as HTMLElement | null; if ( t?.closest?.( ".canvas-toolbar, .canvas-controls, .canvas-minimap, .edit-overlay, .node-handles, .hover-card, .inspector", ) ) { return; } const world = toWorld(e.clientX, e.clientY); const hit = hitTest(world.x, world.y); dragRef.current.lastX = e.clientX; dragRef.current.lastY = e.clientY; dragRef.current.startX = e.clientX; dragRef.current.startY = e.clientY; dragRef.current.moved = false; dragRef.current.alt = e.altKey; dragRef.current.forcePin = e.ctrlKey || e.metaKey; dragRef.current.startWorldX = world.x; dragRef.current.startWorldY = world.y; dragRef.current.subtreeIds = []; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); const wantPan = toolMode === "pan" || dragRef.current.space || e.button === 1 || e.button === 2 || (!hit && toolMode !== "link"); if (wantPan) { dragRef.current.mode = "pan"; dragRef.current.nodeId = null; // Blank click clears selection (still allows pan-drag). if (!hit) setSelection([]); setDragCursor("grabbing"); return; } if (locked) { if (hit) setSelection([hit]); else setSelection([]); dragRef.current.mode = "none"; return; } if (hit && (toolMode === "link" || e.altKey)) { dragRef.current.mode = "link"; dragRef.current.nodeId = hit; setSelection([hit]); setLinkPreview({ fromId: hit, x: world.x, y: world.y }); return; } if (hit && toolMode === "select") { dragRef.current.mode = "node"; dragRef.current.nodeId = hit; // Shift temporarily forces single-node; otherwise use toolbar scope const single = e.shiftKey || dragScope === "node"; dragRef.current.subtreeIds = single ? [hit] : collectSubtreeIds(hit, edges); setSelection([hit]); return; } dragRef.current.mode = "pan"; setDragCursor("grabbing"); }} onPointerMove={(e) => { let world = toWorld(e.clientX, e.clientY); const dx = e.clientX - dragRef.current.startX; const dy = e.clientY - dragRef.current.startY; if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true; if (dragRef.current.mode === "pan") { const pdx = e.clientX - dragRef.current.lastX; const pdy = e.clientY - dragRef.current.lastY; dragRef.current.lastX = e.clientX; dragRef.current.lastY = e.clientY; setViewport((v) => ({ ...v, x: v.x + pdx, y: v.y + pdy })); return; } if (dragRef.current.mode === "link" && dragRef.current.nodeId) { setLinkPreview({ fromId: dragRef.current.nodeId, x: world.x, y: world.y, }); return; } if (dragRef.current.mode === "node" && dragRef.current.nodeId && dragRef.current.moved) { const rootId = dragRef.current.nodeId; // Edge auto-pan near viewport borders const host = hostRef.current; if (host) { const rect = host.getBoundingClientRect(); const margin = 48; let panX = 0; let panY = 0; if (e.clientX < rect.left + margin) panX = 12; else if (e.clientX > rect.right - margin) panX = -12; if (e.clientY < rect.top + margin) panY = 12; else if (e.clientY > rect.bottom - margin) panY = -12; if (panX || panY) { setViewport((v) => ({ ...v, x: v.x + panX, y: v.y + panY })); // Adjust start world so grab point stays consistent after pan dragRef.current.startWorldX += panX / viewport.zoom; dragRef.current.startWorldY += panY / viewport.zoom; world = toWorld(e.clientX, e.clientY); } } // Preserve grab point: delta = pointer movement in world space const gdx = world.x - dragRef.current.startWorldX; const gdy = world.y - dragRef.current.startWorldY; const ids = dragRef.current.subtreeIds.length > 0 ? dragRef.current.subtreeIds : collectSubtreeIds(rootId, edges); setGhost({ rootId, dx: gdx, dy: gdy, ids }); setDragCursor("grabbing"); dragRef.current.forcePin = e.ctrlKey || e.metaKey; if (dragRef.current.forcePin) { setDropTargetId(null); setDropForbidden(false); setHoverId(null); return; } const exclude = new Set(ids); const hit = hitTest(world.x, world.y, exclude); setHoverId(hit); if (hit && hit !== rootId) { const forbidden = isDescendantOrSelf(hit, rootId, edges); setDropTargetId(hit); setDropForbidden(forbidden); } else { setDropTargetId(null); setDropForbidden(false); } } }} onPointerUp={(e) => { const world = toWorld(e.clientX, e.clientY); const mode = dragRef.current.mode; const fromId = dragRef.current.nodeId; const moved = dragRef.current.moved; const g = ghostRef.current; const forcePin = dragRef.current.forcePin || e.ctrlKey || e.metaKey; if (!locked && mode === "link" && fromId) { const hit = hitTest(world.x, world.y); if (hit && hit !== fromId) { void applyOps([ { type: "link", from: fromId, to: hit, kind: "relation", label: U.relationLabel, }, ]); } setLinkPreview(null); } else if (!locked && mode === "node" && fromId && moved && g) { const exclude = new Set(g.ids); const hit = forcePin || dropForbidden ? null : hitTest(world.x, world.y, exclude); const canReparent = hit && hit !== fromId && !isDescendantOrSelf(hit, fromId, edges); if (canReparent && hit) { const sidePref: -1 | 0 | 1 = (positions[hit]?.x ?? 0) <= 0 ? -1 : 1; const nextPos = { ...positions }; for (const id of g.ids) { const base = positions[id]; if (base) nextPos[id] = { x: base.x + g.dx, y: base.y + g.dy }; } patchLocal({ positions: nextPos }); setGhost(null); void applyOps([ { type: "move_node", nodeId: fromId, newParentId: hit, sidePref, }, ]); } else { // Free place -> pin every dragged id (branch = whole subtree, node = one) const nextPos = { ...positions }; const pinOps: { type: "set_pinned"; nodeId: string; pinned: true; pos: { x: number; y: number }; }[] = []; const nextNodes = nodes.map((n) => { if (!exclude.has(n.id)) return n; const base = positions[n.id]; if (!base) return n; const pos = { x: base.x + g.dx, y: base.y + g.dy }; nextPos[n.id] = pos; pinOps.push({ type: "set_pinned", nodeId: n.id, pinned: true, pos, }); return { ...n, pinned: true, pos }; }); patchLocal({ positions: nextPos, nodes: nextNodes }); setGhost(null); void applyOps(pinOps, "drag pin"); } } else if (mode === "node" && fromId && !moved) { setSelection([fromId]); } dragRef.current.mode = "none"; dragRef.current.nodeId = null; dragRef.current.forcePin = false; dragRef.current.subtreeIds = []; setLinkPreview(null); setGhost(null); setDropTargetId(null); setDropForbidden(false); setDragCursor(null); }} onContextMenu={(e) => e.preventDefault()} onDoubleClick={(e) => { if (locked) return; const world = toWorld(e.clientX, e.clientY); const hit = hitTest(world.x, world.y); if (hit) { const n = nodes.find((x) => x.id === hit)!; const host = hostRef.current!.getBoundingClientRect(); const p = posOf(hit)!; setEditing({ id: hit, text: n.text, x: host.width / 2 + viewport.x + p.x * viewport.zoom - 70, y: host.height / 2 + viewport.y + p.y * viewport.zoom - 16, }); } else if (toolMode === "select") { void applyOps([ { type: "create_node", text: U.newNode, ...(selectedIds[0] ? { parentId: selectedIds[0] } : {}), sidePref: world.x < 0 ? -1 : 1, }, ]); } }} >
e.stopPropagation()} > {embed ? ( <>
) : null}
{( [ ["select", U.toolSelect, "V"], ["pan", U.toolPan, "H"], ["link", U.toolLink, "C"], ] as const ).map(([mode, label, key]) => ( ))}
{Math.round(viewport.zoom * 100)}%
e.stopPropagation()} >
{ e.stopPropagation(); miniDragRef.current = true; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); panFromMini(e.clientX, e.clientY); }} onPointerMove={(e) => { if (!miniDragRef.current) return; panFromMini(e.clientX, e.clientY); }} onPointerUp={() => { miniDragRef.current = false; }} onPointerCancel={() => { miniDragRef.current = false; }} >
{editing && ( setEditing({ ...editing, text: e.target.value })} onBlur={() => { void applyOps([ { type: "update_text", nodeId: editing.id, text: editing.text }, ]); setEditing(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") setEditing(null); }} /> )} {!locked && selectedIds[0] && !ghost && positions[selectedIds[0]] && (() => { const id = selectedIds[0]!; const n = nodes.find((x) => x.id === id); if (!n || n.deletedAt) return null; const p = positions[id]!; const { w: boxW } = boxOf(n); const cx = hostSize.w / 2 + viewport.x + p.x * viewport.zoom; const cy = hostSize.h / 2 + viewport.y + p.y * viewport.zoom; const hw = (boxW / 2) * viewport.zoom; // Center-to-center: parent half + child half + breathing room const edgeGap = dens ? 100 : 140; const addChild = (side: -1 | 1) => { const text = side < 0 ? U.leftChild : U.rightChild; const childW = dens ? 160 : 200; void applyOps([ { type: "create_node", text, parentId: id, sidePref: side, pos: { x: p.x + side * (boxW / 2 + childW / 2 + edgeGap), y: p.y, }, }, ]); }; return (
e.stopPropagation()}>
); })()} {canvas?.prefs.showHoverCard !== false && hoverId && !ghost && !cardView && (() => { const n = nodes.find((x) => x.id === hoverId); if (!n) return null; const p = n ? positions[n.id] : null; if (!p) return null; const { h: boxH } = boxOf(n); const x = hostSize.w / 2 + viewport.x + p.x * viewport.zoom + 12; const y = hostSize.h / 2 + viewport.y + p.y * viewport.zoom + (boxH / 2) * viewport.zoom + 8; return ; })()} {!locked && !ghost && cardView && selectedIds[0] && viewport.zoom >= 0.35 && (() => { const id = selectedIds[0]!; const n = nodes.find((x) => x.id === id); if (!n || n.deletedAt) return null; const p = positions[id]; if (!p) return null; const { w } = boxOf(n); const sx = hostSize.w / 2 + viewport.x + p.x * viewport.zoom; const sy = hostSize.h / 2 + viewport.y + p.y * viewport.zoom; return ( ); })()}
{locked ? U.hintLocked : dragScope === "node" ? U.hintNodeDrag : U.hintBranchDrag}
); } function FullscreenIcon({ exit }: { exit?: boolean }) { if (exit) { return ( ); } return ( ); } function ZoomInIcon() { return ( ); } function ZoomOutIcon() { return ( ); } function FitIcon() { return ( ); } function LockIcon({ locked }: { locked: boolean }) { if (locked) { return ( ); } return ( ); }