// Canvas: pan/zoom stage that holds nodes + edges and handles connect-drag const { useRef: useRefC, useState: useStateC, useEffect: useEffectC, useMemo: useMemoC } = React; function Canvas({ nodes, edges, groups, writings, selection, setSelection, updateNode, deleteNode, addEdge, updateEdge, updateWriting, deleteWriting, swapEdgeZ, flash, composingGroupId, setComposingGroupId, toggleGroupMember, updateGroup, setView, view, canvasRef, bgPattern, bgColor, title, subtitle, showTitleOnCanvas, onEditEdge, }){ groups = groups || []; writings = writings || []; const stageRef = useRefC(null); const [panning, setPanning] = useStateC(false); const panStart = useRefC(null); const [draggingNodeId, setDraggingNodeId] = useStateC(null); const [connectFrom, setConnectFrom] = useStateC(null); const [connectPos, setConnectPos] = useStateC(null); const [draggingEdgeId, setDraggingEdgeId] = useStateC(null); const edgeDragStart = useRefC(null); const [shiftHeld, setShiftHeld] = useStateC(false); // Writing-specific transient state. Mirrors the existing connectFrom / // edgeDragStart patterns so behavior stays consistent with edges. const [draggingWritingId, setDraggingWritingId] = useStateC(null); const [writingConnectFrom, setWritingConnectFrom] = useStateC(null); const [writingConnectPos, setWritingConnectPos] = useStateC(null); const [draggingWritingLineId, setDraggingWritingLineId] = useStateC(null); const writingLineDragStart = useRefC(null); // Measured bboxes reported by Writing components — used by writingShrink to // start the connection line exactly outside the visible text. Estimation is // a fallback for the very first frame before the first measurement lands. const [writingBox, setWritingBox] = useStateC({}); const onMeasureWriting = React.useCallback((id, box) => { setWritingBox(prev => { const cur = prev[id]; if (cur && cur.w === box.w && cur.h === box.h) return prev; return { ...prev, [id]: box }; }); }, []); // Track shift key so we can show a contextual "click another arrow to swap z-order" hint. // Ignore shift while a text input is focused so capital-letter typing doesn't flash the guide. useEffectC(()=>{ const isInputFocused = (target) => { const tag = (target?.tagName || '').toLowerCase(); return tag === 'input' || tag === 'textarea'; }; const onDown = (e)=>{ if (e.key !== 'Shift') return; if (isInputFocused(e.target)) return; setShiftHeld(true); }; const onUp = (e)=>{ if (e.key === 'Shift') setShiftHeld(false); }; const onBlur = ()=> setShiftHeld(false); window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp); window.addEventListener('blur', onBlur); return ()=>{ window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); window.removeEventListener('blur', onBlur); }; }, []); // Clamp pan so content never fully leaves the viewport const clampView = (tx, ty, zoom) => { const el = stageRef.current; if (!el || nodes.length === 0) return { tx, ty }; const rect = el.getBoundingClientRect(); const margin = 200; // always keep at least 200px of content area visible // content bounding box in world coords let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach(n => { minX = Math.min(minX, n.x - 60); minY = Math.min(minY, n.y - 60); maxX = Math.max(maxX, n.x + 60); maxY = Math.max(maxY, n.y + 60); }); // in screen coords: worldX * zoom + tx = screenX // We want: at least some part of [minX..maxX] visible in [0..rect.width] // maxX * zoom + tx >= margin => tx >= margin - maxX * zoom // minX * zoom + tx <= rect.width - margin => tx <= rect.width - margin - minX * zoom const txMin = margin - maxX * zoom; const txMax = rect.width - margin - minX * zoom; const tyMin = margin - maxY * zoom; const tyMax = rect.height - margin - minY * zoom; return { tx: Math.max(txMin, Math.min(txMax, tx)), ty: Math.max(tyMin, Math.min(tyMax, ty)), }; }; // Screen -> world coords const screenToWorld = (sx, sy) => { const rect = stageRef.current.getBoundingClientRect(); return { x: (sx - rect.left - view.tx) / view.zoom, y: (sy - rect.top - view.ty) / view.zoom, }; }; const handleEdgeDragStart = (id, e) => { setDraggingEdgeId(id); const edge = edges.find(ed=>ed.id===id); if (!edge) return; const a = nodes.find(n=>n.id===edge.from); const b = nodes.find(n=>n.id===edge.to); if (!a || !b) return; const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len; const ny = dx / len; const i = edges.indexOf(edge); const baseOffset = i >= 0 ? edgeOffsets[i] : 0; const currentOffset = edge.customOffset !== undefined ? edge.customOffset : baseOffset; edgeDragStart.current = { startX: e.clientX, startY: e.clientY, startOffset: currentOffset, nx, ny, }; e.target.setPointerCapture(e.pointerId); }; // Resolve one of a writing's links to a world-space target point. Returns // null if the target node/edge is gone. Edge targets pick up the bezier // midpoint with the same offset logic as the Edge component, so the line // tracks the visible center of the curve even after the edge is bent. const resolveLinkTarget = (link) => { if (!link) return null; if (link.type === 'node') { const n = nodes.find(nn => nn.id === link.id); return n ? { x: n.x, y: n.y, type: 'node' } : null; } if (link.type === 'edge') { const i = edges.findIndex(ee => ee.id === link.id); if (i < 0) return null; const ed = edges[i]; const a = nodes.find(nn => nn.id === ed.from); const b = nodes.find(nn => nn.id === ed.to); if (!a || !b) return null; const off = ed.customOffset !== undefined ? ed.customOffset : edgeOffsets[i]; const { cx, cy } = window.buildCurve({x:a.x,y:a.y}, {x:b.x,y:b.y}, off); return { x: 0.25*a.x + 0.5*cx + 0.25*b.x, y: 0.25*a.y + 0.5*cy + 0.25*b.y, type: 'edge', }; } return null; }; // The line starts OUTSIDE the writing's text (so it doesn't overlap the // visible glyphs) but extends ALL THE WAY to the target's center. Visual // tidiness comes from z-order instead of geometry: the writing-lines svg // is drawn beneath the edges/nodes layers, so node avatars and edge labels // naturally cover the inside of the line. const writingShrink = (w, dirX, dirY) => { const measured = writingBox[w.id]; let halfW, halfH; if (measured) { // Tight margin — the measured box already includes the writing's CSS // padding, so 4px outside that is enough to keep the dotted line clear // of the visible text strokes. halfW = measured.w / 2 + 4; halfH = measured.h / 2 + 4; } else { // Fallback: rough character-width estimate. 0.7em is intentionally // generous so a horizontal line never crosses CJK glyphs even before // the real measurement arrives. const fs = w.fontSize || 28; const lines = (w.text || '').split('\n'); const maxChars = Math.max(1, ...lines.map(l => l.length || 1)); halfW = (maxChars * fs * 0.7) / 2 + 12; halfH = (lines.length * fs * 1.25) / 2 + 6; } // Rotate the outward unit vector into the writing's local frame. const rot = -((w.rotation || 0) * Math.PI / 180); const c = Math.cos(rot), s = Math.sin(rot); const rx = dirX * c - dirY * s; const ry = dirX * s + dirY * c; let dist = Infinity; if (Math.abs(rx) > 1e-6) dist = Math.min(dist, halfW / Math.abs(rx)); if (Math.abs(ry) > 1e-6) dist = Math.min(dist, halfH / Math.abs(ry)); return Number.isFinite(dist) ? dist : Math.max(halfW, halfH); }; // Build a curved path from outside the writing all the way to the target // center. The wl-path is drawn under edges/nodes so the avatar and edge // label hide the inner portion automatically. Returns null when the writing // is essentially on top of the target (line would have nowhere to go). const buildWritingLinePath = (w, link) => { const target = resolveLinkTarget(link); if (!target) return null; const cx = w.x, cy = w.y; let dx = target.x - cx, dy = target.y - cy; const L = Math.hypot(dx, dy) || 1; const ux = dx / L, uy = dy / L; const startShrink = writingShrink(w, ux, uy); if (startShrink >= L - 4) return null; const p1 = { x: cx + ux * startShrink, y: cy + uy * startShrink }; const p2 = { x: target.x, y: target.y }; const { d } = window.buildCurve(p1, p2, link.lineCurve || 0); return { d, p1, p2 }; }; const handleWritingLineDragStart = (writingId, linkIdx, e) => { e.stopPropagation(); const w = writings.find(x => x.id === writingId); if (!w) return; const link = (w.links || [])[linkIdx]; if (!link) return; const target = resolveLinkTarget(link); if (!target) return; setDraggingWritingLineId({ writingId, linkIdx }); setSelection({ type: 'writing', id: writingId }); const dx = target.x - w.x; const dy = target.y - w.y; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len; const ny = dx / len; writingLineDragStart.current = { startX: e.clientX, startY: e.clientY, startCurve: link.lineCurve || 0, nx, ny, }; e.target.setPointerCapture(e.pointerId); }; const doStartWritingConnect = (id, e) => { setWritingConnectFrom(id); const w = writings.find(x => x.id === id); if (w) setWritingConnectPos({ x: w.x, y: w.y }); }; const onStageDown = (e) => { if (e.target.closest('.node') || e.target.closest('.edge-label') || e.target.closest('.edge-hit') || e.target.closest('.pan-ui') || e.target.closest('.hint') || e.target.closest('.compose-banner') || e.target.closest('.group-labels-svg') || e.target.closest('.writing') || e.target.closest('.wl-hit')) return; setSelection(null); // start panning setPanning(true); panStart.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty }; e.currentTarget.setPointerCapture(e.pointerId); }; const onStageMove = (e) => { if (draggingEdgeId && edgeDragStart.current) { const state = edgeDragStart.current; const dxScreen = (e.clientX - state.startX) / view.zoom; const dyScreen = (e.clientY - state.startY) / view.zoom; const diff = dxScreen * state.nx + dyScreen * state.ny; const newOffset = state.startOffset + diff; if (updateEdge) updateEdge(draggingEdgeId, { customOffset: newOffset }, { skipHistory: true }); return; } if (draggingWritingLineId && writingLineDragStart.current) { const st = writingLineDragStart.current; const dxScreen = (e.clientX - st.startX) / view.zoom; const dyScreen = (e.clientY - st.startY) / view.zoom; const diff = dxScreen * st.nx + dyScreen * st.ny; const { writingId, linkIdx } = draggingWritingLineId; const w = writings.find(x => x.id === writingId); if (w && updateWriting) { const links = (w.links || []).map((l, i) => i === linkIdx ? { ...l, lineCurve: st.startCurve + diff } : l); updateWriting(writingId, { links }, { skipHistory: true }); } return; } if (panning && panStart.current){ const rawTx = panStart.current.tx + (e.clientX - panStart.current.x); const rawTy = panStart.current.ty + (e.clientY - panStart.current.y); const clamped = clampView(rawTx, rawTy, view.zoom); setView(v => ({ ...v, tx: clamped.tx, ty: clamped.ty })); } if (connectFrom){ setConnectPos(screenToWorld(e.clientX, e.clientY)); } if (writingConnectFrom){ setWritingConnectPos(screenToWorld(e.clientX, e.clientY)); } }; const onStageUp = (e) => { if (draggingEdgeId) { setDraggingEdgeId(null); edgeDragStart.current = null; } if (draggingWritingLineId) { // Commit a final history entry now that the user released the line. const { writingId } = draggingWritingLineId; const w = writings.find(x => x.id === writingId); if (w && updateWriting) updateWriting(writingId, { links: w.links || [] }); setDraggingWritingLineId(null); writingLineDragStart.current = null; } setPanning(false); panStart.current = null; if (writingConnectFrom){ // Hit-test the release point against nodes (world-space proximity) and // edges (DOM elementFromPoint via .edge-group[data-edge-id]). First match // wins; nodes take priority since their hit-radius is generous. let target = null; const rect = stageRef.current?.getBoundingClientRect(); if (rect) { const wx = (e.clientX - rect.left - view.tx) / view.zoom; const wy = (e.clientY - rect.top - view.ty) / view.zoom; const hitR = 60; const n = nodes.find(nn => Math.hypot(nn.x - wx, nn.y - wy) <= hitR); if (n) target = { type: 'node', id: n.id }; } if (!target) { const el = document.elementFromPoint(e.clientX, e.clientY); if (el) { const eg = el.closest('.edge-group'); if (eg && eg.dataset && eg.dataset.edgeId) { target = { type: 'edge', id: eg.dataset.edgeId }; } } } if (target && updateWriting) { const w = writings.find(x => x.id === writingConnectFrom); if (w) { const links = w.links || []; // Avoid stacking duplicate links onto the same target. const exists = links.some(l => l.type === target.type && l.id === target.id); if (!exists) { updateWriting(writingConnectFrom, { links: [...links, { type: target.type, id: target.id, lineCurve: 0 }] }); } } } setWritingConnectFrom(null); setWritingConnectPos(null); } if (connectFrom){ // iPad / touch fix: Safari's implicit pointer capture keeps pointerup // on the source +handle, so the target avatar's onPointerUp never fires. // For touch/pen, hit-test the release point against node centers in world // space. Mouse input keeps the original path (handleFinishUp on avatar). if (e.pointerType && e.pointerType !== 'mouse') { const rect = stageRef.current?.getBoundingClientRect(); if (rect) { const worldX = (e.clientX - rect.left - view.tx) / view.zoom; const worldY = (e.clientY - rect.top - view.ty) / view.zoom; const hitR = 60; const target = nodes.find(n => { if (n.id === connectFrom) return false; return Math.hypot(n.x - worldX, n.y - worldY) <= hitR; }); if (target) addEdge(connectFrom, target.id); } } setConnectFrom(null); setConnectPos(null); } }; const onWheel = (e) => { e.preventDefault(); const rect = stageRef.current.getBoundingClientRect(); const sx = e.clientX - rect.left; const sy = e.clientY - rect.top; const delta = -e.deltaY * 0.0015; const nextZoom = Math.max(0.25, Math.min(2.5, view.zoom * (1+delta))); // keep cursor point steady const wx = (sx - view.tx) / view.zoom; const wy = (sy - view.ty) / view.zoom; const tx = sx - wx*nextZoom; const ty = sy - wy*nextZoom; const clamped = clampView(tx, ty, nextZoom); setView({ zoom: nextZoom, tx: clamped.tx, ty: clamped.ty }); }; useEffectC(()=>{ const el = stageRef.current; if(!el) return; const wheel = (e)=> onWheel(e); el.addEventListener('wheel', wheel, { passive:false }); return ()=> el.removeEventListener('wheel', wheel); }); // Edge offset map: if multiple edges between same pair, stagger them const edgeOffsets = useMemoC(()=>{ const counts = {}; return edges.map(e => { const key = [e.from, e.to].sort().join('--'); const idx = (counts[key] = (counts[key]||0)); counts[key]++; const dir = e.from < e.to ? 1 : -1; const base = 0; // spread: 0, +28, -28, +56, -56 ... const step = 32; const spread = idx===0 ? base : (idx%2? 1: -1) * Math.ceil(idx/2) * step; return spread * dir; }); }, [edges]); const doStartConnect = (nodeId) => { setConnectFrom(nodeId); const n = nodes.find(n=>n.id===nodeId); if (n) setConnectPos({x:n.x,y:n.y}); }; const doFinishConnect = (nodeId) => { if (connectFrom && connectFrom !== nodeId){ addEdge(connectFrom, nodeId); } setConnectFrom(null); setConnectPos(null); }; return (