// DesignCanvas.jsx — Figma-ish design canvas wrapper // Warm gray grid bg + Sections + Artboards + PostIt notes. // No assets, no deps. const DC = { bg: '#f0eee9', grid: 'rgba(0,0,0,0.06)', label: 'rgba(60,50,40,0.7)', title: 'rgba(40,30,20,0.85)', subtitle: 'rgba(60,50,40,0.6)', postitBg: '#fef4a8', postitText: '#5a4a2a', font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', }; // ───────────────────────────────────────────────────────────── // Main canvas — transform-based pan/zoom viewport // // Input mapping (Figma-style): // • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) // • trackpad scroll → pan (two-finger) // • mouse wheel → zoom (notched; distinguished from trackpad scroll) // • middle-drag / primary-drag-on-bg → pan // // Transform state lives in a ref and is written straight to the DOM // (translate3d + will-change) so wheel ticks don't go through React — // keeps pans at 60fps on dense canvases. // ───────────────────────────────────────────────────────────── function DesignCanvas({ children, minScale = 0.1, maxScale = 8, style = {} }) { const vpRef = React.useRef(null); const worldRef = React.useRef(null); const tf = React.useRef({ x: 0, y: 0, scale: 1 }); const apply = React.useCallback(() => { const { x, y, scale } = tf.current; const el = worldRef.current; if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; }, []); React.useEffect(() => { const vp = vpRef.current; if (!vp) return; const zoomAt = (cx, cy, factor) => { const r = vp.getBoundingClientRect(); const px = cx - r.left, py = cy - r.top; const t = tf.current; const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); const k = next / t.scale; // keep the world point under the cursor fixed t.x = px - (px - t.x) * k; t.y = py - (py - t.y) * k; t.scale = next; apply(); }; // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends // line-mode deltas (Firefox) or large integer pixel deltas with no X // component (Chrome/Safari, typically multiples of 100/120). Trackpad // two-finger scroll sends small/fractional pixel deltas, often with // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. const isMouseWheel = (e) => e.deltaMode !== 0 || (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); const onWheel = (e) => { e.preventDefault(); if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels if (e.ctrlKey) { // trackpad pinch (or explicit ctrl+wheel) zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); } else if (isMouseWheel(e)) { // notched mouse wheel — fixed-ratio step per click zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); } else { // trackpad two-finger scroll — pan tf.current.x -= e.deltaX; tf.current.y -= e.deltaY; apply(); } }; // Safari sends native gesture* events for trackpad pinch with a smooth // e.scale; preferring these over the ctrl+wheel fallback gives a much // better feel there. No-ops on other browsers. Safari also fires // ctrlKey wheel events during the same pinch — isGesturing makes // onWheel drop those entirely so they neither zoom nor pan. let gsBase = 1; let isGesturing = false; const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; const onGestureChange = (e) => { e.preventDefault(); zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); }; const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; // Drag-pan: middle button anywhere, or primary button starting on the // canvas background (not inside an artboard). let drag = null; const onPointerDown = (e) => { const onBg = e.target === vp || e.target === worldRef.current; if (!(e.button === 1 || (e.button === 0 && onBg))) return; e.preventDefault(); vp.setPointerCapture(e.pointerId); drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; vp.style.cursor = 'grabbing'; }; const onPointerMove = (e) => { if (!drag || e.pointerId !== drag.id) return; tf.current.x += e.clientX - drag.lx; tf.current.y += e.clientY - drag.ly; drag.lx = e.clientX; drag.ly = e.clientY; apply(); }; const onPointerUp = (e) => { if (!drag || e.pointerId !== drag.id) return; vp.releasePointerCapture(e.pointerId); drag = null; vp.style.cursor = ''; }; vp.addEventListener('wheel', onWheel, { passive: false }); vp.addEventListener('gesturestart', onGestureStart, { passive: false }); vp.addEventListener('gesturechange', onGestureChange, { passive: false }); vp.addEventListener('gestureend', onGestureEnd, { passive: false }); vp.addEventListener('pointerdown', onPointerDown); vp.addEventListener('pointermove', onPointerMove); vp.addEventListener('pointerup', onPointerUp); vp.addEventListener('pointercancel', onPointerUp); return () => { vp.removeEventListener('wheel', onWheel); vp.removeEventListener('gesturestart', onGestureStart); vp.removeEventListener('gesturechange', onGestureChange); vp.removeEventListener('gestureend', onGestureEnd); vp.removeEventListener('pointerdown', onPointerDown); vp.removeEventListener('pointermove', onPointerMove); vp.removeEventListener('pointerup', onPointerUp); vp.removeEventListener('pointercancel', onPointerUp); }; }, [apply, minScale, maxScale]); const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; return (