/ design-canvas.jsx
design-canvas.jsx
1 2 // DesignCanvas.jsx — Figma-ish design canvas wrapper 3 // Warm gray grid bg + Sections + Artboards + PostIt notes. 4 // No assets, no deps. 5 6 const DC = { 7 bg: '#f0eee9', 8 grid: 'rgba(0,0,0,0.06)', 9 label: 'rgba(60,50,40,0.7)', 10 title: 'rgba(40,30,20,0.85)', 11 subtitle: 'rgba(60,50,40,0.6)', 12 postitBg: '#fef4a8', 13 postitText: '#5a4a2a', 14 font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', 15 }; 16 17 // ───────────────────────────────────────────────────────────── 18 // Main canvas — transform-based pan/zoom viewport 19 // 20 // Input mapping (Figma-style): 21 // • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) 22 // • trackpad scroll → pan (two-finger) 23 // • mouse wheel → zoom (notched; distinguished from trackpad scroll) 24 // • middle-drag / primary-drag-on-bg → pan 25 // 26 // Transform state lives in a ref and is written straight to the DOM 27 // (translate3d + will-change) so wheel ticks don't go through React — 28 // keeps pans at 60fps on dense canvases. 29 // ───────────────────────────────────────────────────────────── 30 function DesignCanvas({ children, minScale = 0.1, maxScale = 8, style = {} }) { 31 const vpRef = React.useRef(null); 32 const worldRef = React.useRef(null); 33 const tf = React.useRef({ x: 0, y: 0, scale: 1 }); 34 35 const apply = React.useCallback(() => { 36 const { x, y, scale } = tf.current; 37 const el = worldRef.current; 38 if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; 39 }, []); 40 41 React.useEffect(() => { 42 const vp = vpRef.current; 43 if (!vp) return; 44 45 const zoomAt = (cx, cy, factor) => { 46 const r = vp.getBoundingClientRect(); 47 const px = cx - r.left, py = cy - r.top; 48 const t = tf.current; 49 const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); 50 const k = next / t.scale; 51 // keep the world point under the cursor fixed 52 t.x = px - (px - t.x) * k; 53 t.y = py - (py - t.y) * k; 54 t.scale = next; 55 apply(); 56 }; 57 58 // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends 59 // line-mode deltas (Firefox) or large integer pixel deltas with no X 60 // component (Chrome/Safari, typically multiples of 100/120). Trackpad 61 // two-finger scroll sends small/fractional pixel deltas, often with 62 // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. 63 const isMouseWheel = (e) => 64 e.deltaMode !== 0 || 65 (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); 66 67 const onWheel = (e) => { 68 e.preventDefault(); 69 if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels 70 if (e.ctrlKey) { 71 // trackpad pinch (or explicit ctrl+wheel) 72 zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); 73 } else if (isMouseWheel(e)) { 74 // notched mouse wheel — fixed-ratio step per click 75 zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); 76 } else { 77 // trackpad two-finger scroll — pan 78 tf.current.x -= e.deltaX; 79 tf.current.y -= e.deltaY; 80 apply(); 81 } 82 }; 83 84 // Safari sends native gesture* events for trackpad pinch with a smooth 85 // e.scale; preferring these over the ctrl+wheel fallback gives a much 86 // better feel there. No-ops on other browsers. Safari also fires 87 // ctrlKey wheel events during the same pinch — isGesturing makes 88 // onWheel drop those entirely so they neither zoom nor pan. 89 let gsBase = 1; 90 let isGesturing = false; 91 const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; 92 const onGestureChange = (e) => { 93 e.preventDefault(); 94 zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); 95 }; 96 const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; 97 98 // Drag-pan: middle button anywhere, or primary button starting on the 99 // canvas background (not inside an artboard). 100 let drag = null; 101 const onPointerDown = (e) => { 102 const onBg = e.target === vp || e.target === worldRef.current; 103 if (!(e.button === 1 || (e.button === 0 && onBg))) return; 104 e.preventDefault(); 105 vp.setPointerCapture(e.pointerId); 106 drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; 107 vp.style.cursor = 'grabbing'; 108 }; 109 const onPointerMove = (e) => { 110 if (!drag || e.pointerId !== drag.id) return; 111 tf.current.x += e.clientX - drag.lx; 112 tf.current.y += e.clientY - drag.ly; 113 drag.lx = e.clientX; drag.ly = e.clientY; 114 apply(); 115 }; 116 const onPointerUp = (e) => { 117 if (!drag || e.pointerId !== drag.id) return; 118 vp.releasePointerCapture(e.pointerId); 119 drag = null; 120 vp.style.cursor = ''; 121 }; 122 123 vp.addEventListener('wheel', onWheel, { passive: false }); 124 vp.addEventListener('gesturestart', onGestureStart, { passive: false }); 125 vp.addEventListener('gesturechange', onGestureChange, { passive: false }); 126 vp.addEventListener('gestureend', onGestureEnd, { passive: false }); 127 vp.addEventListener('pointerdown', onPointerDown); 128 vp.addEventListener('pointermove', onPointerMove); 129 vp.addEventListener('pointerup', onPointerUp); 130 vp.addEventListener('pointercancel', onPointerUp); 131 return () => { 132 vp.removeEventListener('wheel', onWheel); 133 vp.removeEventListener('gesturestart', onGestureStart); 134 vp.removeEventListener('gesturechange', onGestureChange); 135 vp.removeEventListener('gestureend', onGestureEnd); 136 vp.removeEventListener('pointerdown', onPointerDown); 137 vp.removeEventListener('pointermove', onPointerMove); 138 vp.removeEventListener('pointerup', onPointerUp); 139 vp.removeEventListener('pointercancel', onPointerUp); 140 }; 141 }, [apply, minScale, maxScale]); 142 143 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")`; 144 return ( 145 <div 146 ref={vpRef} 147 className="design-canvas" 148 style={{ 149 height: '100vh', width: '100vw', 150 background: DC.bg, 151 overflow: 'hidden', 152 overscrollBehavior: 'none', 153 touchAction: 'none', 154 position: 'relative', 155 fontFamily: DC.font, 156 boxSizing: 'border-box', 157 ...style, 158 }} 159 > 160 <div 161 ref={worldRef} 162 style={{ 163 position: 'absolute', top: 0, left: 0, 164 transformOrigin: '0 0', 165 willChange: 'transform', 166 width: 'max-content', minWidth: '100%', 167 minHeight: '100%', 168 padding: '60px 0 80px', 169 backgroundImage: gridSvg, 170 backgroundSize: '120px 120px', 171 }} 172 > 173 {children} 174 </div> 175 </div> 176 ); 177 } 178 179 // ───────────────────────────────────────────────────────────── 180 // Section — title + subtitle + h-stack of artboards (no wrap) 181 // ───────────────────────────────────────────────────────────── 182 function DCSection({ title, subtitle, children, gap = 48 }) { 183 return ( 184 <div style={{ marginBottom: 80, position: 'relative' }}> 185 <div style={{ padding: '0 60px 36px' }}> 186 <div style={{ 187 fontSize: 22, fontWeight: 600, color: DC.title, 188 letterSpacing: -0.3, marginBottom: 4, 189 }}>{title}</div> 190 {subtitle && ( 191 <div style={{ 192 fontSize: 14, fontWeight: 400, color: DC.subtitle, 193 }}>{subtitle}</div> 194 )} 195 </div> 196 {/* h-stack — clips offscreen, never wraps */} 197 <div style={{ 198 display: 'flex', gap, padding: '0 60px', 199 alignItems: 'flex-start', width: 'max-content', 200 }}> 201 {children} 202 </div> 203 </div> 204 ); 205 } 206 207 // ───────────────────────────────────────────────────────────── 208 // Artboard — labeled card 209 // ───────────────────────────────────────────────────────────── 210 function DCArtboard({ label, children, width, height, style = {} }) { 211 return ( 212 <div style={{ position: 'relative', flexShrink: 0 }}> 213 {label && ( 214 <div style={{ 215 position: 'absolute', bottom: '100%', left: 0, 216 paddingBottom: 8, 217 fontSize: 12, fontWeight: 500, color: DC.label, 218 whiteSpace: 'nowrap', 219 }}>{label}</div> 220 )} 221 <div style={{ 222 borderRadius: 2, 223 boxShadow: '0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)', 224 overflow: 'hidden', 225 width, height, 226 background: '#fff', 227 ...style, 228 }}> 229 {children} 230 </div> 231 </div> 232 ); 233 } 234 235 // ───────────────────────────────────────────────────────────── 236 // Post-it — absolute-positioned sticky note 237 // ───────────────────────────────────────────────────────────── 238 function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { 239 return ( 240 <div style={{ 241 position: 'absolute', top, left, right, bottom, width, 242 background: DC.postitBg, padding: '14px 16px', 243 fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive', 244 fontSize: 14, lineHeight: 1.4, color: DC.postitText, 245 boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)', 246 transform: `rotate(${rotate}deg)`, 247 zIndex: 5, 248 }}>{children}</div> 249 ); 250 } 251 252 Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); 253