/* BEGIN USAGE */
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Sections + Artboards + PostIt notes.
// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
// 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:
//
//
// …
// …
//
//
//
// Artboards are static design frames, not scroll regions — never use
// height: 100% + overflow: auto/scroll on inner elements; size each artboard
// to fit its content. Height behavior:
// • omit height → the card grows to fit its content (no vertical clipping);
// this is the right default for full pages / long screens.
// • height={N} → a fixed N-px frame that CLIPS overflow (overflow:hidden) —
// use only for deliberately cropped thumbnails (e.g. A/B tiles).
// width is always a fixed frame width (default 260); content wider than it is
// clipped, so set width to the real design width for full pages.
//
// When placing a device frame inside a DCArtboard, pass chromeless to
// suppress the card chrome and let the artboard size to its content:
// …
//
// On mobile (UA-detected) the canvas is browse-only: one-finger drag pans
// (starting on artboards too), two-finger pinch zooms, the zoom control is
// hidden, and drag-reorder / inline-rename are disabled. Tap on an artboard
// label still opens the focus overlay.
/* END USAGE */
const DC = {
bg: '#FCFCFC',
label: '#8F959E',
title: '#1F2329',
subtitle: '#646A73',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// ── 移动端判定:复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义
// (跨域产物 iframe 无法 import 该包,同 deck-stage 的先例)。刻意不用视口
// 断点——桌面工作台的预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
// 启动时一次性判定,不随 resize 抖动。
const DC_MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
const dcIsMobile = typeof navigator !== 'undefined' &&
(DC_MOBILE_UA_RE.test(navigator.userAgent) ||
(/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
// 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:4px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}',
'[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 #336DF4;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Focus-overlay content isn't inside .dc-card, so the scrollbar-hiding
// rules above don't reach it — re-hide scrollbars for the scaled,
// scrollable content shown in DCFocusOverlay.
'.dc-focus-content *{scrollbar-width:none}',
'.dc-focus-content *::-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(31,35,41,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-header,[data-dc-slot]:hover .dc-header .dc-editable{color:#646A73!important}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
// On touch devices (no hover) the per-artboard action bar (kebab + expand)
// can't be reveal-on-hover and tends to stick visible after a tap — hide it
// entirely on mobile. The grip + label stay for per-screen context.
'@media (hover: none){.dc-btns{display:none}}',
// Mobile (UA-detected, .dc-mobile on the viewport root) is browse-only:
// drag-reorder is disabled there, so the grip is dead weight — hide it.
'.dc-mobile .dc-grip{display:none}',
// Mobile is browse-only: kill text selection + iOS long-press callout
// inside the canvas. iOS interprets a drag over selectable TEXT as a
// selection gesture and steals the pointer (pointercancel) — touch-action
// only suppresses native pan/zoom, not selection — so text-dense
// artboards became un-pannable while image-heavy ones panned fine.
'.dc-mobile, .dc-mobile *{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}',
// Prototype content routinely carries inner scrollers (the device/window
// frames all have overflow:auto bodies). On iOS a touch starting inside a
// scrollable element is consumed by THAT scroller (native scroll attempt
// + pointercancel), so pans could never start on those artboards while
// plain-HTML cards panned fine. Mobile canvas is browse-only: kill native
// touch handling on everything inside the cards so gestures always reach
// the viewport state machine. Desktop (no .dc-mobile) keeps inner scroll.
'.dc-mobile [data-dc-slot] *{touch-action:none !important}',
// Pinch updates --dc-inv-zoom per frame (PC semantics: chrome size AND
// section spacing stay constant). The per-frame relayout that implies
// white-flashed on WKWebView when the whole world was one paint layer —
// every head resize invalidated and re-rasterized ALL content. Promote
// each card (and head) to its own compositing layer: relayout then only
// MOVES card layers (compositor, no repaint) and repaints just the tiny
// head layers. contain:paint bounds invalidation to the card box.
// contain:paint must live on .dc-card, NOT the slot: .dc-header is an
// abs-positioned slot child sitting entirely ABOVE the slot box
// (bottom:100%) — paint containment on the slot would clip it out of
// rendering AND hit-testing, killing the label tap that is mobile's only
// focus-overlay entry. will-change (the layer-promotion half of the
// white-flash fix) stays on the slot so header + card share one layer.
'.dc-mobile [data-dc-slot]{will-change:transform}',
'.dc-mobile .dc-card{contain:paint}',
'.dc-mobile .dc-sectionhead{will-change:transform}',
'[data-dc-slot]:hover .dc-card.chromeless>*, [data-dc-slot]:hover .dc-card:not(.chromeless){box-shadow:0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(31,35,41,0.05);color:#646A73}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;',
' box-shadow:0 4px 8px 0 rgba(0,0,0,0.03),0 3px 6px -6px rgba(0,0,0,0.05),0 6px 18px 6px rgba(0,0,0,0.03);padding:3px;min-width:120px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;',
' border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;',
' color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(31,35,41,0.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#F54A45}',
// 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;will-change:transform}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
// On-canvas zoom control (bottom-right pill). Stays screen-fixed because
// it renders outside worldRef. z-index 50 keeps it above world content but
// below the focus overlay (z-index 100). The dropdown reuses .dc-menu and
// only overrides its positioning to open upward (.dc-zoom-menu).
'.dc-zoom{position:absolute;bottom:16px;right:20px;z-index:50;display:flex;align-items:center;',
' background:#fff;border-radius:8px;padding:4px;font-family:inherit;',
' border:0.5px solid #DEE0E3;box-shadow:0 2px 4px -4px rgba(31,35,41,0.02),0 4px 8px 0 rgba(31,35,41,0.02),0 4px 16px 4px rgba(31,35,41,0.03)}',
'.dc-zoom-btn{height:24px;min-width:24px;border-radius:6px;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-zoom-btn:hover{background:#1f23290d}',
'.dc-zoom-btn:disabled{opacity:.35;cursor:default;background:transparent;color:rgba(60,50,40,.7)}',
'.dc-zoom-toggle{position:relative;display:flex}',
'.dc-zoom-trigger{padding:0 6px;gap:4px}',
'.dc-zoom-pct{min-width:36px;text-align:center;font-size:12px;font-weight:400;color:#1F2329}',
'.dc-zoom-menu{top:auto;bottom:100%;left:50%;transform:translateX(-50%);margin-top:0;margin-bottom:6px;min-width:114px}',
'.dc-zoom-menu button{display:flex;align-items:center;justify-content:space-between;gap:8px}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// Recursively unwrap React.Fragment so <>…> grouping doesn't hide
// DCSection/DCArtboard children from the type-based walks below.
function dcFlatten(children) {
const out = [];
React.Children.forEach(children, (c) => {
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
else out.push(c);
});
return out;
}
// ─────────────────────────────────────────────────────────────
// 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
// (miaoda preview, direct link, downloaded zip). Writes post the sidecar
// content up to the miaoda host via postMessage — editing requires the miaoda runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
// Persist a sidecar file back to the host.
function miaodaWriteFile(path, content) {
try {
window.parent.postMessage({ type: 'miaoda:bridge:write-file', path: path, content: content }, '*');
} catch (e) {
/* no parent — ignore */
}
return Promise.resolve();
}
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(() => {
miaodaWriteFile(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. Fragments are flattened; wrapping in other
// elements still opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
dcFlatten(children).forEach((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 = [];
dcFlatten(sec.props.children).forEach((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]);
// dcOverlay opt-in: render outside DCViewport so the component is free from
// worldRef's transform (pan/zoom). Unmarked children stay in the canvas.
const flat = dcFlatten(children);
const overlayChildren = flat.filter(c => c && c.type?.dcOverlay);
const worldChildren = flat.filter(c => !c || !c.type?.dcOverlay);
return (
{ready && worldChildren}
{ready && overlayChildren}
{state.focus && registry[state.focus] && (
)}
);
}
// ─────────────────────────────────────────────────────────────
// DCZoomControl — bottom-right zoom pill (zoom-in / % + presets / zoom-out)
//
// Renders outside worldRef so it stays screen-fixed. data-miaoda-chrome marks
// it as non-background so the viewport's pointerdown doesn't start a pan on it
// (the native vp listener can't be stopped by React's synthetic propagation,
// so the attribute — not stopPropagation — is what prevents the pan). Buttons
// step to the nearest 10% grid, clamped to the canvas scale range; the dropdown
// offers common presets and opens upward.
// ─────────────────────────────────────────────────────────────
const DC_ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
function DCZoomControl({ scalePct, minScale, maxScale, onZoomTo }) {
const [open, setOpen] = React.useState(false);
const rootRef = React.useRef(null);
const minPct = Math.round(minScale * 100);
const maxPct = Math.round(maxScale * 100);
const snapUp = Math.min(maxPct, (Math.floor(scalePct / 10) + 1) * 10);
const snapDown = Math.max(minPct, (Math.ceil(scalePct / 10) - 1) * 10);
React.useEffect(() => {
if (!open) return;
const onDocDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener('pointerdown', onDocDown);
return () => document.removeEventListener('pointerdown', onDocDown);
}, [open]);
return (
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • Ctrl/⌘ + wheel → zoom
// • trackpad two-finger / mouse wheel → pan
// • 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);
// Pre-fit fallback scale. A fresh canvas is reframed by the first-visit fit
// (below) once content mounts; localStorage restore overrides this on
// revisits. This 50% only shows in the gap before either runs.
const tf = React.useRef({ x: 0, y: 0, scale: 0.5 });
// 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();
// Imperative handle for the on-canvas zoom control to zoom around the
// viewport centre (same path as the host's set-zoom message). Assigned in
// the pointer/wheel effect below where zoomAt is in scope.
const zoomCenteredRef = React.useRef(null);
// Percentage shown by the zoom control. Synced from apply() on every scale
// change — React bails out when the rounded value is unchanged, so pan ticks
// (scale unchanged) cost nothing. Lazily seeded from the persisted transform
// so the first paint already shows the restored — or default 50% — zoom.
const [scalePct, setScalePct] = React.useState(() => {
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.scale)) {
return Math.round(Math.min(maxScale, Math.max(minScale, s.scale)) * 100);
}
} catch {}
return Math.round(tf.current.scale * 100);
});
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 on-canvas zoom control's % readout in sync. React bails out on
// an unchanged rounded value, so pan ticks (scale unchanged) don't render.
// Mobile renders no zoom control — a per-frame setState during pinch
// would re-render the whole subtree every frame (visible repaint flicker
// on phones) for a readout nobody consumes, so skip it there entirely.
if (!dcIsMobile) setScalePct(Math.round(scale * 100));
// 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: 'miaoda:canvas:zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
// Whether the mount-time restore found a saved viewport — when it did, the
// first-visit fit below is skipped so revisits keep the user's own view.
const restoredView = React.useRef(false);
const didFit = React.useRef(false);
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)) };
restoredView.current = true;
}
} catch {}
// Apply before first paint whether we restored or fell back to the default
// (scale 0.5) so the canvas never flashes at the wrong zoom. On a fresh
// canvas the first-visit fit (below) reframes once content mounts.
apply();
// 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(); };
}, []);
// First-visit framing: once the (ready-gated) content mounts, scale + centre
// the whole canvas so every artboard fits with a ≥64px safe margin on all
// sides. Skipped when a saved viewport was restored. Runs as a layout effect
// on the commit that first paints content, so there's no un-framed flash.
React.useLayoutEffect(() => {
if (didFit.current || restoredView.current) return;
const vp = vpRef.current, world = worldRef.current;
if (!vp || !world) return;
const slots = world.querySelectorAll('[data-dc-slot]');
if (!slots.length) return; // content not mounted yet — wait for the ready commit
const heads = world.querySelectorAll('.dc-sectionhead');
// Fit margin from the screen edges. Mobile screens are narrow — 64px a
// side eats a third of the width, so fit flush-ish at 16px there.
const PAD = dcIsMobile ? 16 : 64;
// Content bounding box in WORLD space at the currently applied transform.
// X extent from cards only (section-head blocks are full width and would
// over-report); Y from cards + titles so the top heading is kept in frame.
const measure = () => {
const vpr = vp.getBoundingClientRect();
const t = tf.current;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
slots.forEach((el) => {
const r = el.getBoundingClientRect();
minX = Math.min(minX, (r.left - vpr.left - t.x) / t.scale);
maxX = Math.max(maxX, (r.right - vpr.left - t.x) / t.scale);
minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
});
heads.forEach((el) => {
const r = el.getBoundingClientRect();
minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
});
return { vpr, minX, minY, w: maxX - minX, h: maxY - minY };
};
// Content SCREEN width is linear in scale (screenW = s·Wworld) and SCREEN
// height is affine (screenH = s·P + Q): titles and inter-section gaps
// counter-scale with zoom, so they hold a constant screen size while cards
// scale. Sample two scales to solve the coefficients, then pick the largest
// scale that keeps both axes within the viewport minus a PAD margin — exact,
// no iterate-to-converge.
const s1 = tf.current.scale;
const m1 = measure();
if (!(m1.w > 0) || !(m1.h > 0)) { didFit.current = true; return; }
const s2 = s1 * 0.7;
tf.current.scale = s2; apply();
const m2 = measure();
const { vpr } = m1;
const avW = vpr.width - PAD * 2, avH = vpr.height - PAD * 2;
const Wworld = m1.w; // screenW / s, scale-invariant
const P = (s1 * m1.h - s2 * m2.h) / (s1 - s2); // d(screenH)/d(scale)
const Q = s1 * m1.h - s1 * P; // constant screen-space part
const fitW = avW / Wworld;
const fitH = P > 0 ? (avH - Q) / P : Infinity;
const next = Math.max(minScale, Math.min(maxScale, Math.min(fitW, fitH)));
// Apply the solved scale, then centre using the extent measured AT that
// scale so margins come out symmetric (exactly PAD on the binding axis).
tf.current.scale = next; apply();
const mf = measure();
tf.current.x = (vpr.width - mf.w * next) / 2 - mf.minX * next;
tf.current.y = (vpr.height - mf.h * next) / 2 - mf.minY * next;
apply();
didFit.current = true;
});
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;
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
// marginBottom) reflow on every scale change, vertically shifting the
// world layout — so a world point mathematically pinned under the cursor
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
// Anchor the DOM element under the cursor instead: record its screen Y,
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
// drift the reflow introduced so it stays put on screen.
let marker = null, markerY0 = 0;
if (k !== 1) {
const hit = document.elementFromPoint(cx, cy);
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
if (marker) markerY0 = marker.getBoundingClientRect().top;
}
// 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();
if (marker) {
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
// departure after the --dc-inv-zoom reflow is the layout drift.
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
}
};
// On-canvas zoom control: zoom to a target scale around the viewport
// centre, matching the host set-zoom path (onHostMsg). Closes over the
// live zoomAt / vp / tf, so it always uses the current transform.
zoomCenteredRef.current = (targetScale) => {
const r = vp.getBoundingClientRect();
const clamped = Math.min(maxScale, Math.max(minScale, targetScale));
zoomAt(r.left + r.width / 2, r.top + r.height / 2, clamped / tf.current.scale);
};
// Zoom is gated behind a modifier only: the browser sets ctrlKey for a
// trackpad pinch, and Ctrl/⌘ + wheel is the explicit mouse-zoom gesture.
// Every unmodified scroll — trackpad two-finger OR mouse wheel — pans.
// This is Figma's mapping, and it sidesteps a classification that can't be
// done reliably: a fast vertical trackpad flick's peak frame is identical
// to a mouse-wheel notch (deltaMode 0, deltaX 0, large integer deltaY), so
// routing pan-vs-zoom from a single wheel event misfires — a quick two-
// finger swipe used to jump into zoom. Keying off the modifier removes the
// ambiguity at the source.
//
// isNotchedWheel now only picks the zoom *step size* inside the zoom
// branch (fixed ratio for discrete wheels / Firefox line-mode, continuous
// exp for a smooth pinch) — misjudging it is a harmless granularity
// difference, never a pan↔zoom flip.
const isNotchedWheel = (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 || e.metaKey) {
zoomAt(e.clientX, e.clientY,
isNotchedWheel(e) ? Math.exp(-Math.sign(e.deltaY) * 0.18) : Math.exp(-e.deltaY * 0.01));
} else {
// Unmodified scroll pans. Normalize line/page deltas so a notched
// wheel moves a sensible distance instead of a few pixels per notch.
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? vp.clientHeight : 1;
tf.current.x -= e.deltaX * unit;
tf.current.y -= e.deltaY * unit;
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.
//
// On mobile the pointer-based two-finger pinch below is the single zoom
// source. iOS fires BOTH pointer events and these WebKit gesture* events
// for the same two fingers — zooming from both would double-apply, so on
// mobile gesture* degrades to preventDefault only (still suppressing the
// native page zoom).
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
if (dcIsMobile) return;
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, [data-miaoda-chrome]');
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 = '';
};
// ── Mobile touch gestures (replaces the desktop pointer mapping above):
// one-finger drag pans — starting on artboards too, since cards fill a
// phone viewport and background is scarce; two-finger pinch zooms about
// the midpoint while the midpoint's own travel pans (Figma semantics, one
// gesture frames the view). A tap/pan slop keeps label taps working: no
// capture happens within the slop, so a clean tap still clicks through to
// focus; past it the viewport captures the pointer and the click retargets
// harmlessly. No fling/inertia — release stops the canvas (spec §8).
const touches = new Map(); // pointerId -> last {x, y}
let mobMode = null; // null | 'tap' | 'pan' | 'pinch'
let tapStart = null; // down point for slop arbitration
let pinchDist = 0; // previous-frame finger distance
let pinchMid = null; // previous-frame midpoint
const MOB_SLOP = 9; // px of travel before a tap becomes a pan
// First two insertion-ordered touches define the pinch; a third finger is
// tracked but inert (its moves shift neither midpoint nor distance).
const pinchGeom = () => {
const [a, b] = [...touches.values()];
return { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
};
const onTouchDown = (e) => {
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (touches.size === 1) {
mobMode = 'tap';
tapStart = { x: e.clientX, y: e.clientY };
} else if (touches.size === 2) {
mobMode = 'pinch';
const g = pinchGeom();
pinchDist = g.d;
pinchMid = { x: g.mx, y: g.my };
try { vp.setPointerCapture(e.pointerId); } catch {}
}
};
const onTouchMove = (e) => {
const t = touches.get(e.pointerId);
if (!t) return;
const px = t.x, py = t.y;
t.x = e.clientX; t.y = e.clientY;
if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
mobMode = 'pan';
try { vp.setPointerCapture(e.pointerId); } catch {}
}
if (mobMode === 'pan') {
tf.current.x += t.x - px;
tf.current.y += t.y - py;
apply();
} else if (mobMode === 'pinch') {
const g = pinchGeom();
tf.current.x += g.mx - pinchMid.x;
tf.current.y += g.my - pinchMid.y;
pinchMid = { x: g.mx, y: g.my };
// zoomAt applies the pan mutation above too; degenerate distances
// (fingers together) skip the zoom but still need the pan applied.
// Same full zoomAt as the desktop trackpad-pinch path (gesturechange):
// per-frame --dc-inv-zoom keeps chrome size AND section spacing
// constant throughout the gesture, with the same drift compensation.
if (pinchDist > 0 && g.d > 0) zoomAt(g.mx, g.my, g.d / pinchDist);
else apply();
pinchDist = g.d;
}
};
const onTouchUp = (e) => {
if (!touches.delete(e.pointerId)) return;
try { vp.releasePointerCapture(e.pointerId); } catch {}
if (touches.size === 0) {
mobMode = null;
} else if (touches.size === 1) {
// Pinch collapsing to one finger hands off to pan with the surviving
// finger as the new baseline — no jump.
mobMode = 'pan';
} else if (mobMode === 'pinch') {
// 3+ fingers losing one: re-baseline so the new leading pair doesn't
// read as a sudden midpoint/distance delta.
const g = pinchGeom();
pinchDist = g.d;
pinchMid = { x: g.mx, y: g.my };
}
};
// 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 === 'miaoda:canvas: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 === 'miaoda:canvas: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: 'miaoda:canvas: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 miaoda:canvas:zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the miaoda:canvas:probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before miaoda:canvas:present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
lastPostedScale.current = undefined;
apply();
// Mobile swaps the desktop pointer mapping (bg-only pan) for the touch
// state machine; wheel/gesture listeners stay on both (harmless without
// the hardware, and a bluetooth mouse on Android still pans via wheel).
const pd = dcIsMobile ? onTouchDown : onPointerDown;
const pm = dcIsMobile ? onTouchMove : onPointerMove;
const pu = dcIsMobile ? onTouchUp : onPointerUp;
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', pd);
vp.addEventListener('pointermove', pm);
vp.addEventListener('pointerup', pu);
vp.addEventListener('pointercancel', pu);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', pd);
vp.removeEventListener('pointermove', pm);
vp.removeEventListener('pointerup', pu);
vp.removeEventListener('pointercancel', pu);
};
}, [apply, minScale, maxScale]);
return (
{children}
{/* Mobile zooms by pinch — the control is not rendered at all (host
set-zoom messages still work; the protocol is independent of it). */}
{!dcIsMobile && (
zoomCenteredRef.current?.(pct / 100)}
/>
)}
);
}
// ─────────────────────────────────────────────────────────────
// 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(dcFlatten(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 (
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '' + name + '' +
(fontCss ? '' : '') +
'' + xml + '';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// -loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
// Read height off the raw props (not a destructure default) so an *absent*
// height can opt into auto-grow while an explicit height={N} — even 480 —
// still means a fixed, content-clipping frame (thumbnail semantics).
const { id: rawId, label: rawLabel, width = 260, chromeless = false, children, style = {} } = artboard.props;
const hasHeight = artboard.props.height != null;
const height = hasHeight ? artboard.props.height : undefined;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
// Unicode-aware sanitize: keep letters (incl. CJK/accented), digits,
// whitespace, dot/underscore/hyphen; collapse everything else to '_'.
// ASCII-only \w would strip Chinese labels down to a bare '_'.
const name = String(label || id || 'artboard').replace(/[^\p{L}\p{N}\s._-]+/gu, '_');
const ew = cardRef.current.offsetWidth || width;
const eh = cardRef.current.offsetHeight || height;
dcExport(cardRef.current, ew, eh, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// 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 (
{/* Desktop stops pointerdown so header interactions never start a
bg-pan; mobile pans FROM chrome too (slop arbitration keeps label
taps working), so the event must bubble to the viewport there. */}
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
// Mobile is browse-only: render plain text — no contentEditable (a tap
// would pop the soft keyboard), no stopPropagation (pans start on labels
// too), and no onClick swallow (desktop uses it to keep an edit-click from
// bubbling; on mobile the tap should bubble so a label tap opens focus).
if (dcIsMobile) {
return {value};
}
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 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, chromeless = false, children } = artboard.props;
const hasHeight = artboard.props.height != null;
const height = hasHeight ? artboard.props.height : undefined;
// A fixed-frame card (non-chromeless WITH an explicit height) uses width/
// height directly. Otherwise the card has NO fixed height and its rendered
// size describes the content, not a frame — either a chromeless artboard
// (device frame / auto layout, both axes intrinsic) or a non-chromeless
// artboard that omitted height and grows to its content (frame `width`, auto
// height). Fitting such content to a default height would wrap it in an
// undersized clip box and magnify the clipped slice — so measure the real
// rendered size first and fit-scale to that, mirroring the canvas card.
const autoSize = chromeless || !hasHeight;
const measureRef = React.useRef(null);
const [nat, setNat] = React.useState(null); // { aid, w, h } measured natural size
React.useLayoutEffect(() => {
if (!autoSize) return;
const el = measureRef.current;
if (el) setNat({ aid, w: el.offsetWidth, h: el.offsetHeight });
}, [aid, autoSize]);
const useMeasured = autoSize && nat && nat.aid === aid && nat.w > 0;
const needsMeasure = autoSize && !useMeasured;
const cw = useMeasured ? nat.w : width;
const ch = useMeasured ? nat.h : (height ?? 480);
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); }, []);
// Fill the viewport leaving a safe margin — 70px left/right, 32px top/bottom.
// Take the smaller of the width-fit / height-fit ratios so the whole artboard
// stays fully visible; no upper cap, so small frames still fill the screen.
const MARGIN_X = 70;
const MARGIN_Y = 32;
const scale = Math.max(0.05, Math.min((vp.w - MARGIN_X * 2) / cw, (vp.h - MARGIN_Y * 2) / ch));
// Chrome (arrows / close / dots) is hidden until the cursor sits on the
// dark backdrop (anywhere that isn't the interactive content). Hiding also
// sets pointer-events:none so hidden controls are click-through and never
// block content interaction or a backdrop-click exit.
const [chromeVisible, setChromeVisible] = React.useState(false);
const contentRef = React.useRef(null);
const chromeStyle = { opacity: chromeVisible ? 1 : 0, pointerEvents: chromeVisible ? 'auto' : 'none' };
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)}
onMouseMove={(e) => setChromeVisible(!(contentRef.current && contentRef.current.contains(e.target)))}
onMouseLeave={() => setChromeVisible(false)}
onWheel={(e) => { if (!(contentRef.current && contentRef.current.contains(e.target))) e.preventDefault(); }}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,.8)', backdropFilter: 'blur(4px)',
fontFamily: DC.font, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* content fills the viewport to its safe margin (70px left/right, 32px
top/bottom), centered — interactive & scrollable. Only the content
stops propagation, so any backdrop click (the dark margin around it)
exits focus. */}
e.stopPropagation()}
style={{ width: cw * scale, height: ch * scale, position: 'relative',
// hidden until the auto-size measure pass below reports the real size
visibility: needsMeasure ? 'hidden' : 'visible' }}>
{/* Auto-size cards render a hidden measure pass first, then re-render
scaled. Chromeless measures both axes (inline-block → intrinsic
size); a non-chromeless auto card keeps its frame `width` and lets
height run natural. Card chrome (bg / radius / shadow / overflow:auto)
is added only for a non-chromeless box — matching the canvas card —
giving it a scroll viewport for anything it still clips; scrollbars
are hidden by the .dc-focus-content CSS. A chromeless box carries
none of it, so its content owns its look. */}
{children ||
{aid}
}
{/* close — top-right, revealed on backdrop hover */}
go(-1)} />
go(1)} />
{/* dots — revealed on backdrop hover */}