/ 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