// Draggable annotation text — sits above nodes/edges. Can attach to a node // or an edge with a curved line whose color follows the writing's text color. function Writing({ writing, selected, dragging, worldZoom, onSelect, onDrag, onDragEnd, onStartConnect, onDelete, onTextChange, onMeasure, }){ const moveState = React.useRef(null); const rootRef = React.useRef(null); const [editing, setEditing] = React.useState(false); // Report the writing's actual unrotated bbox up to Canvas so the connection // line can start exactly at the visible text edge — character-width estimates // (esp. for CJK) are unreliable enough to put the line under the text. React.useLayoutEffect(() => { if (!rootRef.current || !onMeasure) return; const el = rootRef.current; const measure = () => { onMeasure(writing.id, { w: el.offsetWidth, h: el.offsetHeight }); }; measure(); // Re-measure once webfonts are ready — Yusei Magic loads asynchronously and // the first paint uses fallback metrics. if (document.fonts && document.fonts.ready && document.fonts.ready.then) { document.fonts.ready.then(measure).catch(() => {}); } }, [writing.text, writing.fontSize, editing, onMeasure]); const onPointerDown = (e) => { if (e.target.closest('.connect-handle')) return; if (e.target.closest('.delete-btn')) return; if (e.target.closest('.w-edit-area')) { // While editing, let the textarea handle native selection but stop bubble. e.stopPropagation(); return; } e.stopPropagation(); onSelect(writing.id); if (editing) return; const startX = e.clientX, startY = e.clientY; const ox = writing.x, oy = writing.y; moveState.current = { startX, startY, ox, oy, moved: false }; e.currentTarget.setPointerCapture(e.pointerId); }; const onPointerMove = (e) => { if (!moveState.current) return; const { startX, startY, ox, oy } = moveState.current; const dx = (e.clientX - startX) / worldZoom; const dy = (e.clientY - startY) / worldZoom; if (Math.abs(dx) + Math.abs(dy) > 2) moveState.current.moved = true; onDrag(writing.id, ox + dx, oy + dy); }; const onPointerUp = (e) => { if (!moveState.current) return; const moved = moveState.current.moved; moveState.current = null; onDragEnd(writing.id, moved); try { e.currentTarget.releasePointerCapture(e.pointerId); } catch(_){} }; const handleConnectDown = (e) => { e.stopPropagation(); e.preventDefault(); onStartConnect(writing.id, e); }; const onDoubleClick = (e) => { e.stopPropagation(); setEditing(true); }; const className = 'writing' + (selected ? ' selected' : '') + (dragging ? ' dragging' : ''); const rotation = writing.rotation || 0; const style = { left: writing.x, top: writing.y, color: writing.color || '#3a2c3a', fontSize: (writing.fontSize || 28) + 'px', transform: `translate(-50%, -50%) rotate(${rotation}deg)`, }; return (
{editing ? (