// DesignCanvas.jsx — Figma-ish design canvas wrapper // Warm gray grid bg + Sections + Artboards + PostIt notes. // Artboards are reorderable (grip-drag), deletable, labels/titles are // inline-editable, and any artboard can be opened in a fullscreen focus // overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar // via the host bridge. No assets, no deps. // // Usage: // // // // // // 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', }; // One-time CSS injection (classes are dc-prefixed so they don't collide with // the hosted design's own styles). if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { const s = document.createElement('style'); s.id = 'dc-styles'; s.textContent = [ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', '.dc-card{transition:box-shadow .15s,transform .15s}', '.dc-card *{scrollbar-width:none}', '.dc-card *::-webkit-scrollbar{display:none}', // Per-artboard header: grip + label on the left, delete/expand on the // right. Single flex row; when the artboard's on-screen width is too // narrow for both the label yields (ellipsis, then hidden entirely below // ~4ch via the container query) and the buttons stay on the row. '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', ' display:flex;align-items:center;container-type:inline-size}', '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', '.dc-grip:hover{background:rgba(0,0,0,.08)}', '.dc-grip:active{cursor:grabbing}', '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', // Below ~4ch of label room: hide the label entirely, and drop the grip to // hover-only (same reveal rule as .dc-btns) so a narrow header is clean // until the card is moused. '@container (max-width: 110px){', ' .dc-labeltext{display:none}', ' .dc-grip{opacity:0}', ' [data-dc-slot]:hover .dc-grip{opacity:1}', '}', '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}', '.dc-expand,.dc-delete{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', ' font:inherit;transition:background .12s,color .12s}', '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}', '.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}', '.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;', ' font-size:12px;font-weight:500}', '.dc-delete.dc-confirm:hover{background:#b5563a}', // Chrome (titles / labels / buttons) counter-scales against the viewport // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by // DCViewport on every transform update and inherits to all descendants — // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use // it the same way. // // The header uses transform:scale (out-of-flow, so layout impact doesn't // matter) with its world-space width set to card-width / inv-zoom so that // after counter-scaling its on-screen width exactly matches the card's — // that's what lets the container query + text-overflow behave against the // card's visible edge at every zoom level. // // The section head uses CSS zoom instead of transform so its layout box // grows with the counter-scale, pushing the card row down — otherwise the // constant-screen-size title would overflow into the (shrinking) world- // space gap and overlap the artboard headers at low zoom. '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', ].join('\n'); document.head.appendChild(s); } const DCCtx = React.createContext(null); // ───────────────────────────────────────────────────────────── // DesignCanvas — stateful wrapper around the pan/zoom viewport. // Owns runtime state (per-section order, renamed titles/labels, hidden // artboards, focused artboard). Order/titles/labels/hidden persist to a // .design-canvas.state.json // sidecar next to the HTML. Reads go via plain fetch() so the saved // arrangement is visible anywhere the HTML + sidecar are served together // (omelette preview, direct link, downloaded zip). Writes go through the // host's window.omelette bridge — editing requires the omelette runtime. // Focus is ephemeral. // ───────────────────────────────────────────────────────────── const DC_STATE_FILE = '.design-canvas.state.json'; function DesignCanvas({ children, minScale, maxScale, style }) { const [state, setState] = React.useState({ sections: {}, focus: null }); // Hold rendering until the sidecar read settles so the saved order/titles // appear on first paint (no source-order flash). didRead gates writes until // the read settles so the empty initial state can't clobber a slow read; // skipNextWrite suppresses the one echo-write that would otherwise follow // hydration. const [ready, setReady] = React.useState(false); const didRead = React.useRef(false); const skipNextWrite = React.useRef(false); React.useEffect(() => { let off = false; fetch('./' + DC_STATE_FILE) .then((r) => (r.ok ? r.json() : null)) .then((saved) => { if (off || !saved || !saved.sections) return; skipNextWrite.current = true; setState((s) => ({ ...s, sections: saved.sections })); }) .catch(() => {}) .finally(() => { didRead.current = true; if (!off) setReady(true); }); const t = setTimeout(() => { if (!off) setReady(true); }, 150); return () => { off = true; clearTimeout(t); }; }, []); React.useEffect(() => { if (!didRead.current) return; if (skipNextWrite.current) { skipNextWrite.current = false; return; } const t = setTimeout(() => { window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); }, 250); return () => clearTimeout(t); }, [state.sections]); // Build registries synchronously from children so FocusOverlay can read // them in the same render. Only direct DCSection > DCArtboard children are // walked — wrapping them in other elements opts out of focus/reorder. const registry = {}; // slotId -> { sectionId, artboard } const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } const sectionOrder = []; React.Children.forEach(children, (sec) => { if (!sec || sec.type !== DCSection) return; const sid = sec.props.id ?? sec.props.title; if (!sid) return; sectionOrder.push(sid); const persisted = state.sections[sid] || {}; const abs = []; React.Children.forEach(sec.props.children, (ab) => { if (!ab || ab.type !== DCArtboard) return; const aid = ab.props.id ?? ab.props.label; if (aid) abs.push([aid, ab]); }); // hidden is scoped to one source revision — when the agent regenerates // (artboard-ID set changes), prior deletes don't apply to new content. const srcKey = abs.map(([k]) => k).join('\x1f'); const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; const srcIds = []; abs.forEach(([aid, ab]) => { if (hidden.includes(aid)) return; registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; srcIds.push(aid); }); const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); sectionMeta[sid] = { title: persisted.title ?? sec.props.title, subtitle: sec.props.subtitle, slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], }; }); const api = React.useMemo(() => ({ state, section: (id) => state.sections[id] || {}, patchSection: (id, p) => setState((s) => ({ ...s, sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, })), setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), }), [state]); // Esc exits focus; any outside pointerdown commits an in-progress rename. React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; const onPd = (e) => { const ae = document.activeElement; if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); }; document.addEventListener('keydown', onKey); document.addEventListener('pointerdown', onPd, true); return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onPd, true); }; }, [api]); return ( {ready && children} {state.focus && registry[state.focus] && ( )} ); } // ───────────────────────────────────────────────────────────── // DCViewport — transform-based pan/zoom (internal) // // 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 DCViewport({ 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 }); // Persist viewport across reloads so the user lands back where they were // after an agent edit or browser refresh. The sandbox origin is already // per-project; pathname keeps multiple canvas files in one project apart. const tfKey = 'dc-viewport:' + location.pathname; const saveT = React.useRef(0); const lastPostedScale = React.useRef(); const apply = React.useCallback(() => { const { x, y, scale } = tf.current; const el = worldRef.current; if (!el) return; el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). el.style.setProperty('--dc-inv-zoom', String(1 / scale)); // Keep the host toolbar's % readout in sync with the canvas scale. Pan // ticks leave scale unchanged — skip the cross-frame post for those. if (lastPostedScale.current !== scale) { lastPostedScale.current = scale; window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); } clearTimeout(saveT.current); saveT.current = setTimeout(() => { try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} }, 200); }, [tfKey]); React.useLayoutEffect(() => { const flush = () => { clearTimeout(saveT.current); try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} }; try { const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; apply(); } } catch {} // Flush on pagehide and unmount so a reload within the 200ms debounce // window doesn't drop the last pan/zoom. window.addEventListener('pagehide', flush); return () => { window.removeEventListener('pagehide', flush); flush(); }; }, []); 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 on canvas // background (anything that isn't an artboard or an inline editor). let drag = null; const onPointerDown = (e) => { const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); 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 = ''; }; // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the // visible midpoint stays fixed — matching the host's iframe-zoom feel. const onHostMsg = (e) => { const d = e.data; if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { const r = vp.getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); } else if (d && d.type === '__dc_probe') { // Host's [readyGen] reset asks whether a canvas is present; it // fires on the iframe's native 'load', which for canvases with // images/fonts is after our mount-time announce, so re-announce. // Clear the pan-tick guard so apply() re-posts the current scale // even if it's unchanged — the host just reset dcScale to 1. window.parent.postMessage({ type: '__dc_present' }, '*'); lastPostedScale.current = undefined; apply(); } }; window.addEventListener('message', onHostMsg); // Announce canvas mode so the host toolbar proxies its % control here // instead of scaling the iframe element (which would just shrink the // viewport window of an infinite canvas). The apply() that follows emits // the initial __dc_zoom so the toolbar % is correct before first pinch. // lastPostedScale reset mirrors the __dc_probe handler: the layout // effect's restore-path apply() may already have posted the restored // scale (before __dc_present), so clear the guard to re-post it in order. window.parent.postMessage({ type: '__dc_present' }, '*'); lastPostedScale.current = undefined; apply(); 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 () => { window.removeEventListener('message', onHostMsg); 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 (
{children}
); } // ───────────────────────────────────────────────────────────── // DCSection — editable title + h-row of artboards in persisted order // ───────────────────────────────────────────────────────────── function DCSection({ id, title, subtitle, children, gap = 48 }) { const ctx = React.useContext(DCCtx); const sid = id ?? title; const all = React.Children.toArray(children); const artboards = all.filter((c) => c && c.type === DCArtboard); const rest = all.filter((c) => !(c && c.type === DCArtboard)); const sec = (ctx && sid && ctx.section(sid)) || {}; // Must match DesignCanvas's srcKey computation exactly (it filters falsy // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); const srcKey = allIds.join('\x1f'); const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; const srcOrder = allIds.filter((k) => !hidden.includes(k)); const order = React.useMemo(() => { const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; }, [sec.order, srcOrder.join('|')]); const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); // marginBottom counter-scales so the on-screen gap between sections stays // constant — otherwise at low zoom the (world-space) gap collapses while // the screen-constant sectionhead below it doesn't, and the title reads as // belonging to the section above. paddingBottom below is just enough for // the 24px artboard-header (abs-positioned above each card) plus ~8px, so // the title sits tight against its own row at every zoom. return (
ctx && sid && ctx.patchSection(sid, { title: v })} style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> {subtitle &&
{subtitle}
}
{order.map((k) => ( ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], srcKey, }))} onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> ))}
{rest}
); } // DCArtboard — marker; rendered by DCArtboardFrame via DCSection. function DCArtboard() { return null; } function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; const id = rawId ?? rawLabel; const ref = React.useRef(null); const delRef = React.useRef(null); const [confirming, setConfirming] = React.useState(false); // Two-click delete: first click arms the button (turns into an inline // "Delete?" pill), second click commits. Any pointerdown outside the // button disarms. React.useEffect(() => { if (!confirming) return; const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); }; document.addEventListener('pointerdown', off, true); return () => document.removeEventListener('pointerdown', off, true); }, [confirming]); // Live drag-reorder: dragged card sticks to cursor; siblings slide into // their would-be slots in real time via transforms. DOM order only // changes on drop. const onGripDown = (e) => { e.preventDefault(); e.stopPropagation(); const me = ref.current; // translateX is applied in local (pre-scale) space but pointer deltas and // getBoundingClientRect().left are screen-space — divide by the viewport's // current scale so the dragged card tracks the cursor at any zoom level. const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); const slotXs = homes.map((h) => h.x); const startIdx = order.indexOf(id); const startX = e.clientX; let liveOrder = order.slice(); me.classList.add('dc-dragging'); const layout = () => { for (const h of homes) { if (h.id === id) continue; const slot = liveOrder.indexOf(h.id); h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; } }; const move = (ev) => { const dx = ev.clientX - startX; me.style.transform = `translateX(${dx / scale}px)`; const cur = homes[startIdx].x + dx; let nearest = 0, best = Infinity; for (let i = 0; i < slotXs.length; i++) { const d = Math.abs(slotXs[i] - cur); if (d < best) { best = d; nearest = i; } } if (liveOrder.indexOf(id) !== nearest) { liveOrder = order.filter((k) => k !== id); liveOrder.splice(nearest, 0, id); layout(); } }; const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); const finalSlot = liveOrder.indexOf(id); me.classList.remove('dc-dragging'); me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; // After the settle transition, kill transitions + clear transforms + // commit the reorder in the same frame so there's no visual snap-back. setTimeout(() => { for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); requestAnimationFrame(() => requestAnimationFrame(() => { for (const h of homes) h.el.style.transition = ''; })); }, 180); }; document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); }; return (
e.stopPropagation()}>
e.stopPropagation()} style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
{children ||
{id}
}
); } // Inline rename — commits on blur or Enter. function DCEditable({ value, onChange, style, tag = 'span', onClick }) { const T = tag; return ( e.stopPropagation()} onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} style={style}>{value} ); } // ───────────────────────────────────────────────────────────── // Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across // sections, Esc or backdrop click to exit. // ───────────────────────────────────────────────────────────── function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { const ctx = React.useContext(DCCtx); const { sectionId, artboard } = entry; const sec = ctx.section(sectionId); const meta = sectionMeta[sectionId]; const peers = meta.slotIds; const aid = artboard.props.id ?? artboard.props.label; const idx = peers.indexOf(aid); const secIdx = sectionOrder.indexOf(sectionId); const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; const goSection = (d) => { // Sections whose artboards are all deleted have slotIds:[] — step past // them to the next non-empty section so ↑/↓ doesn't dead-end. const n = sectionOrder.length; for (let i = 1; i < n; i++) { const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; if (first) { ctx.setFocus(`${ns}/${first}`); return; } } }; React.useEffect(() => { const k = (e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } }; document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }); const { width = 260, height = 480, children } = artboard.props; const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); const [ddOpen, setDd] = React.useState(false); const Arrow = ({ dir, onClick }) => ( ); // Portal to body so position:fixed is the real viewport regardless of any // transform on DesignCanvas's ancestors (including the canvas zoom itself). return ReactDOM.createPortal(
ctx.setFocus(null)} onWheel={(e) => e.preventDefault()} style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', fontFamily: DC.font, color: '#fff' }}> {/* top bar: section dropdown (left) · close (right) */}
e.stopPropagation()} style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
{ddOpen && (
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( ))}
)}
{/* card centered, label + index below — only the card itself stops propagation so any backdrop click (including the margins around the card) exits focus */}
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
{children ||
{aid}
}
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> {(sec.labels || {})[aid] ?? artboard.props.label} {idx + 1} / {peers.length}
go(-1)} /> go(1)} /> {/* dots */}
e.stopPropagation()} style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> {peers.map((p, i) => (
, document.body, ); } // ───────────────────────────────────────────────────────────── // Post-it — absolute-positioned sticky note // ───────────────────────────────────────────────────────────── function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { return (
{children}
); } Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });