// 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, notes, showNotes, updateNote, showFlow, branchNodeMode, addBranchNodeOnEdge, addBranchNodeOnGroup, selection, setSelection, updateNode, deleteNode, addEdge, updateEdge, swapEdgeZ, flash, composingGroupId, setComposingGroupId, toggleGroupMember, updateGroup, setView, view, canvasRef, bgPattern, bgColor, title, subtitle, showTitleOnCanvas, onEditEdge, }){ groups = groups || []; notes = notes || []; 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); // In branch mode, track which group's blob the pointer is currently over so // we can highlight a valid drop target. Reset to null when over empty space // or when branch mode exits. const [branchHoverGroupId, setBranchHoverGroupId] = useStateC(null); // 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); }; const onStageDown = (e) => { // Branch-node placement mode: click on an edge → snap to the nearest // point on that edge's bezier and attach the branch there. The branch // stays magnet-locked to that arrow (slides but never detaches by drag). // Shift-click falls through so the existing z-order-swap interaction still // works on branch-related arrows without having to exit branch mode. if (branchNodeMode && !e.shiftKey) { const rect = stageRef.current?.getBoundingClientRect(); const worldX = rect ? (e.clientX - rect.left - view.tx) / view.zoom : 0; const worldY = rect ? (e.clientY - rect.top - view.ty) / view.zoom : 0; // Priority 1: edge-attach (if click hits an arrow's hit-area). const hit = e.target.closest('.edge-hit'); if (hit) { const edgeId = hit.getAttribute('data-edge-id'); const edge = edges.find(ed => ed.id === edgeId); if (edge && rect && addBranchNodeOnEdge) { const from = nodes.find(n => n.id === edge.from); const to = nodes.find(n => n.id === edge.to); if (from && to) { const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0); const pt = closestPointOnBezier(geom.p1, {x:geom.cx, y:geom.cy}, geom.p2, worldX, worldY); addBranchNodeOnEdge(edge.id, pt.t, pt.x, pt.y); } } e.stopPropagation(); return; } // Priority 2: group-attach (if click is inside a group's *visible blob // shape*). Walk groups in render order; if shapes overlap, the last // (top-most) one wins. The branch becomes a regular member of that // group, so the blob naturally extends around it without any projection. let target = null; groups.forEach(g => { const members = (g.nodeIds || []) .map(nid => nodes.find(n => n.id === nid)) .filter(Boolean); if (members.length === 0) return; if (!isPointInBlobShape(worldX, worldY, members)) return; target = g; }); if (target && addBranchNodeOnGroup) { addBranchNodeOnGroup(target.id, worldX, worldY); e.stopPropagation(); return; } // Clicked empty space while in branch mode — gentle hint, no pan. if (flash) flash('矢印か、ゾーンエリアの上をクリックしてね'); e.stopPropagation(); return; } 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')) 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 (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)); } // Branch-mode blob hover: highlight the blob the pointer is currently // over so the user gets visual feedback for valid drop targets. Cheap // enough to recompute every move (groups are typically <10). if (branchNodeMode){ const w = screenToWorld(e.clientX, e.clientY); let hit = null; for (const g of groups){ const members = (g.nodeIds || []) .map(nid => nodes.find(n => n.id === nid)) .filter(Boolean); if (members.length > 0 && isPointInBlobShape(w.x, w.y, members)) { hit = g.id; // last wins → matches click priority } } if (hit !== branchHoverGroupId) setBranchHoverGroupId(hit); } else if (branchHoverGroupId){ setBranchHoverGroupId(null); } }; const onStageUp = (e) => { if (draggingEdgeId) { setDraggingEdgeId(null); edgeDragStart.current = null; } setPanning(false); panStart.current = 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]); // Keyed by edge.id for lookups outside the main edges.map iteration. const edgeOffsetById = useMemoC(()=>{ const m = {}; edges.forEach((e,i)=>{ m[e.id] = edgeOffsets[i]; }); return m; }, [edges, edgeOffsets]); // Resolve branch-node positions: only edge-attached branches need // re-positioning each frame (snap to bezier at parameter t). Group-member // branches are regular members with free (x, y), included naturally in the // metaball blob as the user drags them around. const resolvedNodes = useMemoC(()=>{ return nodes.map(n => { if (n.variant !== 'branch' || !n.attachedEdgeId) return n; const edge = edges.find(e => e.id === n.attachedEdgeId); if (!edge) return n; const from = nodes.find(nn => nn.id === edge.from); const to = nodes.find(nn => nn.id === edge.to); if (!from || !to) return n; const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0); const pt = pointOnBezier(geom.p1, {x:geom.cx, y:geom.cy}, geom.p2, n.t ?? 0.5); return { ...n, x: pt.x, y: pt.y }; }); }, [nodes, edges, edgeOffsetById]); const nodeById = React.useMemo( () => Object.fromEntries(resolvedNodes.map(n => [n.id, n])), [resolvedNodes] ); // Resolve every note's anchor point — node center for nodeId notes, edge // midpoint for targetEdgeId notes. NoteBox / NoteConnector consume {x,y} // directly without caring which kind of target it is. const noteAnchors = useMemoC(()=>{ const map = {}; notes.forEach(n => { if (n.targetEdgeId) { const edge = edges.find(e => e.id === n.targetEdgeId); if (!edge) return; const from = nodeById[edge.from]; const to = nodeById[edge.to]; if (!from || !to) return; const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0); const pt = pointOnBezier(geom.p1, {x:geom.cx, y:geom.cy}, geom.p2, 0.5); map[n.id] = { x: pt.x, y: pt.y }; } else if (n.nodeId) { const node = nodeById[n.nodeId]; if (node) map[n.id] = { x: node.x, y: node.y }; } }); return map; }, [notes, edges, nodeById, edgeOffsetById]); 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 (