@lark-apaas/coding-registry-buildless 0.1.0 → 0.1.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1125 @@
1
+ /* BEGIN USAGE */
2
+ // DesignCanvas.jsx — Figma-ish design canvas wrapper
3
+ // Sections + Artboards + PostIt notes.
4
+ // Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
5
+ // Artboards are reorderable (grip-drag), deletable, labels/titles are
6
+ // inline-editable, and any artboard can be opened in a fullscreen focus
7
+ // overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
8
+ // via the host bridge. No assets, no deps.
9
+ //
10
+ // Usage:
11
+ // <DesignCanvas>
12
+ // <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
13
+ // <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
14
+ // <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
15
+ // </DCSection>
16
+ // </DesignCanvas>
17
+ //
18
+ // Artboards are static design frames, not scroll regions — never use
19
+ // height: 100% + overflow: auto/scroll on inner elements; size each artboard
20
+ // to fit its content (explicit pixel height, or let it grow).
21
+ //
22
+ // When placing a device frame inside a DCArtboard, pass chromeless to
23
+ // suppress the card chrome and let the artboard size to its content:
24
+ // <DCArtboard chromeless><IOSDevice>…</IOSDevice></DCArtboard>
25
+ /* END USAGE */
26
+
27
+ const DC = {
28
+ bg: '#FFFFFF',
29
+ label: '#8F959E',
30
+ title: '#1F2329',
31
+ subtitle: '#646A73',
32
+ postitBg: '#fef4a8',
33
+ postitText: '#5a4a2a',
34
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
35
+ };
36
+
37
+ // One-time CSS injection (classes are dc-prefixed so they don't collide with
38
+ // the hosted design's own styles).
39
+ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
40
+ const s = document.createElement('style');
41
+ s.id = 'dc-styles';
42
+ s.textContent = [
43
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}',
44
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}',
45
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
46
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
47
+ '[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)}',
48
+ // isolation:isolate contains artboard content's z-indexes so a
49
+ // z-indexed child (sticky navbar etc.) can't paint over .dc-header or
50
+ // the .dc-menu popover that drops into the top of the card.
51
+ '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
52
+ '.dc-card *{scrollbar-width:none}',
53
+ '.dc-card *::-webkit-scrollbar{display:none}',
54
+ // Per-artboard header: grip + label on the left, delete/expand on the
55
+ // right. Single flex row; when the artboard's on-screen width is too
56
+ // narrow for both the label yields (ellipsis, then hidden entirely below
57
+ // ~4ch via the container query) and the buttons stay on the row.
58
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
59
+ ' display:flex;align-items:center;container-type:inline-size}',
60
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
61
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
62
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
63
+ '.dc-grip:active{cursor:grabbing}',
64
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
65
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
66
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
67
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
68
+ // until the card is moused.
69
+ '@container (max-width: 110px){',
70
+ ' .dc-labeltext{display:none}',
71
+ ' .dc-grip{opacity:0}',
72
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
73
+ '}',
74
+ '.dc-labeltext:hover{background:rgba(31,35,41,0.05)}',
75
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
76
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
77
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
78
+ '[data-dc-slot]:hover .dc-header,[data-dc-slot]:hover .dc-header .dc-editable{color:#646A73!important}',
79
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
80
+ '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;',
81
+ ' background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;',
82
+ ' font:inherit;transition:background .12s,color .12s}',
83
+ '.dc-expand:hover,.dc-kebab:hover{background:rgba(31,35,41,0.05);color:#646A73}',
84
+ // Slot hosting an open menu floats above later siblings (which otherwise
85
+ // paint on top — same z-index:auto, later DOM order) so the popup isn't
86
+ // clipped by the next card.
87
+ '[data-dc-slot]:has(.dc-menu){z-index:10}',
88
+ '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;',
89
+ ' 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}',
90
+ '.dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;',
91
+ ' border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;',
92
+ ' color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
93
+ '.dc-menu button:hover{background:rgba(31,35,41,0.05)}',
94
+ '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
95
+ '.dc-menu .dc-danger{color:#F54A45}',
96
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
97
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
98
+ // DCViewport on every transform update and inherits to all descendants —
99
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
100
+ // it the same way.
101
+ //
102
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
103
+ // matter) with its world-space width set to card-width / inv-zoom so that
104
+ // after counter-scaling its on-screen width exactly matches the card's —
105
+ // that's what lets the container query + text-overflow behave against the
106
+ // card's visible edge at every zoom level.
107
+ //
108
+ // The section head uses CSS zoom instead of transform so its layout box
109
+ // grows with the counter-scale, pushing the card row down — otherwise the
110
+ // constant-screen-size title would overflow into the (shrinking) world-
111
+ // space gap and overlap the artboard headers at low zoom.
112
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
113
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
114
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
115
+ // On-canvas zoom control (bottom-right pill). Stays screen-fixed because
116
+ // it renders outside worldRef. z-index 50 keeps it above world content but
117
+ // below the focus overlay (z-index 100). The dropdown reuses .dc-menu and
118
+ // only overrides its positioning to open upward (.dc-zoom-menu).
119
+ '.dc-zoom{position:absolute;bottom:16px;right:20px;z-index:50;display:flex;align-items:center;',
120
+ ' background:#fff;border-radius:8px;padding:4px;font-family:inherit;',
121
+ ' 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)}',
122
+ '.dc-zoom-btn{height:24px;min-width:24px;border-radius:6px;border:none;cursor:pointer;padding:0;',
123
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
124
+ ' font:inherit;transition:background .12s,color .12s}',
125
+ '.dc-zoom-btn:hover{background:#1f23290d}',
126
+ '.dc-zoom-btn:disabled{opacity:.35;cursor:default;background:transparent;color:rgba(60,50,40,.7)}',
127
+ '.dc-zoom-toggle{position:relative;display:flex}',
128
+ '.dc-zoom-trigger{padding:0 6px;gap:4px}',
129
+ '.dc-zoom-pct{min-width:36px;text-align:center;font-size:12px;font-weight:400;color:#1F2329}',
130
+ '.dc-zoom-menu{top:auto;bottom:100%;left:50%;transform:translateX(-50%);margin-top:0;margin-bottom:6px;min-width:114px}',
131
+ '.dc-zoom-menu button{display:flex;align-items:center;justify-content:space-between;gap:8px}',
132
+ ].join('\n');
133
+ document.head.appendChild(s);
134
+ }
135
+
136
+ const DCCtx = React.createContext(null);
137
+
138
+ // Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
139
+ // DCSection/DCArtboard children from the type-based walks below.
140
+ function dcFlatten(children) {
141
+ const out = [];
142
+ React.Children.forEach(children, (c) => {
143
+ if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
144
+ else out.push(c);
145
+ });
146
+ return out;
147
+ }
148
+
149
+ // ─────────────────────────────────────────────────────────────
150
+ // DesignCanvas — stateful wrapper around the pan/zoom viewport.
151
+ // Owns runtime state (per-section order, renamed titles/labels, hidden
152
+ // artboards, focused artboard). Order/titles/labels/hidden persist to a
153
+ // .design-canvas.state.json
154
+ // sidecar next to the HTML. Reads go via plain fetch() so the saved
155
+ // arrangement is visible anywhere the HTML + sidecar are served together
156
+ // (miaoda preview, direct link, downloaded zip). Writes go through the
157
+ // host's window.miaoda bridge — editing requires the miaoda runtime.
158
+ // Focus is ephemeral.
159
+ // ─────────────────────────────────────────────────────────────
160
+ const DC_STATE_FILE = '.design-canvas.state.json';
161
+
162
+ function DesignCanvas({ children, minScale, maxScale, style }) {
163
+ const [state, setState] = React.useState({ sections: {}, focus: null });
164
+ // Hold rendering until the sidecar read settles so the saved order/titles
165
+ // appear on first paint (no source-order flash). didRead gates writes until
166
+ // the read settles so the empty initial state can't clobber a slow read;
167
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
168
+ // hydration.
169
+ const [ready, setReady] = React.useState(false);
170
+ const didRead = React.useRef(false);
171
+ const skipNextWrite = React.useRef(false);
172
+
173
+ React.useEffect(() => {
174
+ let off = false;
175
+ fetch('./' + DC_STATE_FILE)
176
+ .then((r) => (r.ok ? r.json() : null))
177
+ .then((saved) => {
178
+ if (off || !saved || !saved.sections) return;
179
+ skipNextWrite.current = true;
180
+ setState((s) => ({ ...s, sections: saved.sections }));
181
+ })
182
+ .catch(() => {})
183
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
184
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
185
+ return () => { off = true; clearTimeout(t); };
186
+ }, []);
187
+
188
+ React.useEffect(() => {
189
+ if (!didRead.current) return;
190
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
191
+ const t = setTimeout(() => {
192
+ window.miaoda?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
193
+ }, 250);
194
+ return () => clearTimeout(t);
195
+ }, [state.sections]);
196
+
197
+ // Build registries synchronously from children so FocusOverlay can read
198
+ // them in the same render. Fragments are flattened; wrapping in other
199
+ // elements still opts out of focus/reorder.
200
+ const registry = {}; // slotId -> { sectionId, artboard }
201
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
202
+ const sectionOrder = [];
203
+ dcFlatten(children).forEach((sec) => {
204
+ if (!sec || sec.type !== DCSection) return;
205
+ const sid = sec.props.id ?? sec.props.title;
206
+ if (!sid) return;
207
+ sectionOrder.push(sid);
208
+ const persisted = state.sections[sid] || {};
209
+ const abs = [];
210
+ dcFlatten(sec.props.children).forEach((ab) => {
211
+ if (!ab || ab.type !== DCArtboard) return;
212
+ const aid = ab.props.id ?? ab.props.label;
213
+ if (aid) abs.push([aid, ab]);
214
+ });
215
+ // hidden is scoped to one source revision — when the agent regenerates
216
+ // (artboard-ID set changes), prior deletes don't apply to new content.
217
+ const srcKey = abs.map(([k]) => k).join('\x1f');
218
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
219
+ const srcIds = [];
220
+ abs.forEach(([aid, ab]) => {
221
+ if (hidden.includes(aid)) return;
222
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
223
+ srcIds.push(aid);
224
+ });
225
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
226
+ sectionMeta[sid] = {
227
+ title: persisted.title ?? sec.props.title,
228
+ subtitle: sec.props.subtitle,
229
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
230
+ };
231
+ });
232
+
233
+ const api = React.useMemo(() => ({
234
+ state,
235
+ section: (id) => state.sections[id] || {},
236
+ patchSection: (id, p) => setState((s) => ({
237
+ ...s,
238
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
239
+ })),
240
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
241
+ }), [state]);
242
+
243
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
244
+ React.useEffect(() => {
245
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
246
+ const onPd = (e) => {
247
+ const ae = document.activeElement;
248
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
249
+ };
250
+ document.addEventListener('keydown', onKey);
251
+ document.addEventListener('pointerdown', onPd, true);
252
+ return () => {
253
+ document.removeEventListener('keydown', onKey);
254
+ document.removeEventListener('pointerdown', onPd, true);
255
+ };
256
+ }, [api]);
257
+
258
+ // dcOverlay opt-in: render outside DCViewport so the component is free from
259
+ // worldRef's transform (pan/zoom). Unmarked children stay in the canvas.
260
+ const flat = dcFlatten(children);
261
+ const overlayChildren = flat.filter(c => c && c.type?.dcOverlay);
262
+ const worldChildren = flat.filter(c => !c || !c.type?.dcOverlay);
263
+
264
+ return (
265
+ <DCCtx.Provider value={api}>
266
+ <DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && worldChildren}</DCViewport>
267
+ {ready && overlayChildren}
268
+ {state.focus && registry[state.focus] && (
269
+ <DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
270
+ )}
271
+ </DCCtx.Provider>
272
+ );
273
+ }
274
+
275
+ // ─────────────────────────────────────────────────────────────
276
+ // DCZoomControl — bottom-right zoom pill (zoom-in / % + presets / zoom-out)
277
+ //
278
+ // Renders outside worldRef so it stays screen-fixed. data-miaoda-chrome marks
279
+ // it as non-background so the viewport's pointerdown doesn't start a pan on it
280
+ // (the native vp listener can't be stopped by React's synthetic propagation,
281
+ // so the attribute — not stopPropagation — is what prevents the pan). Buttons
282
+ // step to the nearest 10% grid, clamped to the canvas scale range; the dropdown
283
+ // offers common presets and opens upward.
284
+ // ─────────────────────────────────────────────────────────────
285
+ const DC_ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
286
+
287
+ function DCZoomControl({ scalePct, minScale, maxScale, onZoomTo }) {
288
+ const [open, setOpen] = React.useState(false);
289
+ const rootRef = React.useRef(null);
290
+ const minPct = Math.round(minScale * 100);
291
+ const maxPct = Math.round(maxScale * 100);
292
+ const snapUp = Math.min(maxPct, (Math.floor(scalePct / 10) + 1) * 10);
293
+ const snapDown = Math.max(minPct, (Math.ceil(scalePct / 10) - 1) * 10);
294
+
295
+ React.useEffect(() => {
296
+ if (!open) return;
297
+ const onDocDown = (e) => {
298
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
299
+ };
300
+ document.addEventListener('pointerdown', onDocDown);
301
+ return () => document.removeEventListener('pointerdown', onDocDown);
302
+ }, [open]);
303
+
304
+ return (
305
+ <div
306
+ ref={rootRef}
307
+ className="dc-zoom"
308
+ data-miaoda-chrome=""
309
+ onPointerDown={(e) => e.stopPropagation()}
310
+ >
311
+ <button
312
+ type="button"
313
+ className="dc-zoom-btn"
314
+ disabled={scalePct >= maxPct}
315
+ onClick={() => onZoomTo(snapUp)}
316
+ >
317
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
318
+ <path d="M12.4957 11.5517L14.9105 13.9668C15.1702 14.2266 15.1684 14.648 14.9087 14.9077C14.649 15.1674 14.2275 15.1692 13.9678 14.9095L11.5527 12.4943C10.3631 13.4696 8.87188 14.0016 7.33366 13.9993C3.65166 13.9993 0.666992 11.0147 0.666992 7.33268C0.666992 3.65068 3.65166 0.666016 7.33366 0.666016C11.0157 0.666016 14.0003 3.65068 14.0003 7.33268C14.0003 8.93368 13.436 10.4027 12.4957 11.5517ZM6.66699 6.66602V5.33268C6.66699 4.96449 6.96547 4.66602 7.33366 4.66602C7.70185 4.66602 8.00033 4.96449 8.00033 5.33268V6.66602H9.33366C9.70185 6.66602 10.0003 6.96449 10.0003 7.33268C10.0003 7.70087 9.70185 7.99935 9.33366 7.99935H8.00033V9.33268C8.00033 9.70087 7.70185 9.99935 7.33366 9.99935C6.96547 9.99935 6.66699 9.70087 6.66699 9.33268V7.99935H5.33366C4.96547 7.99935 4.66699 7.70087 4.66699 7.33268C4.66699 6.96449 4.96547 6.66602 5.33366 6.66602H6.66699ZM7.33366 12.666C10.2793 12.666 12.667 10.2783 12.667 7.33268C12.667 4.38702 10.2793 1.99935 7.33366 1.99935C4.38799 1.99935 2.00033 4.38702 2.00033 7.33268C2.00033 10.2783 4.38799 12.666 7.33366 12.666Z" fill="#1F2329"/>
319
+ </svg>
320
+ </button>
321
+
322
+ <div className="dc-zoom-toggle">
323
+ <button
324
+ type="button"
325
+ className="dc-zoom-btn dc-zoom-trigger"
326
+ onClick={() => setOpen((o) => !o)}
327
+ >
328
+ <span className="dc-zoom-pct">{scalePct}%</span>
329
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
330
+ <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
331
+ </svg>
332
+ </button>
333
+ {open && (
334
+ <div className="dc-menu dc-zoom-menu" onPointerDown={(e) => e.stopPropagation()}>
335
+ {DC_ZOOM_PRESETS.map((p) => (
336
+ <button type="button" key={p} onClick={() => { onZoomTo(p); setOpen(false); }}>
337
+ <span>{p}%</span>
338
+ {p === scalePct && (
339
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
340
+ <path d="M2.00002 5.64639C2.09378 5.55266 2.22094 5.5 2.35352 5.5C2.4861 5.5 2.61325 5.55266 2.70702 5.64639L4.74277 7.68189L9.27827 3.14639C9.37203 3.05266 9.49919 3 9.63177 3C9.76435 3 9.8915 3.05266 9.98527 3.14639L10.3388 3.49989C10.3852 3.54633 10.4221 3.60145 10.4472 3.66213C10.4723 3.72281 10.4853 3.78784 10.4853 3.85352C10.4853 3.9192 10.4723 3.98423 10.4472 4.04491C10.4221 4.10558 10.3852 4.16071 10.3388 4.20714L5.09627 9.44965C5.04984 9.4961 4.99471 9.53295 4.93403 9.55808C4.87336 9.58322 4.80832 9.59616 4.74264 9.59616C4.67697 9.59616 4.61193 9.58322 4.55125 9.55808C4.49058 9.53295 4.43545 9.4961 4.38902 9.44965L1.64652 6.70714C1.60007 6.66071 1.56322 6.60558 1.53808 6.54491C1.51294 6.48423 1.5 6.4192 1.5 6.35352C1.5 6.28784 1.51294 6.22281 1.53808 6.16213C1.56322 6.10145 1.60007 6.04633 1.64652 5.99989L2.00002 5.64639Z" fill="#1F2329"/>
341
+ </svg>
342
+ )}
343
+ </button>
344
+ ))}
345
+ </div>
346
+ )}
347
+ </div>
348
+
349
+ <button
350
+ type="button"
351
+ className="dc-zoom-btn"
352
+ disabled={scalePct <= minPct}
353
+ onClick={() => onZoomTo(snapDown)}
354
+ >
355
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
356
+ <path d="M12.4957 11.5517L14.9111 13.9674C15.1707 14.227 15.1697 14.6496 14.9102 14.9092C14.6506 15.1688 14.228 15.1697 13.9684 14.9101L11.5527 12.4943C10.3631 13.4696 8.87188 14.0016 7.33366 13.9993C3.65166 13.9993 0.666992 11.0147 0.666992 7.33268C0.666992 3.65068 3.65166 0.666016 7.33366 0.666016C11.0157 0.666016 14.0003 3.65068 14.0003 7.33268C14.0003 8.93368 13.436 10.4027 12.4957 11.5517ZM7.33366 12.666C10.2793 12.666 12.667 10.2783 12.667 7.33268C12.667 4.38702 10.2793 1.99935 7.33366 1.99935C4.38799 1.99935 2.00033 4.38702 2.00033 7.33268C2.00033 10.2783 4.38799 12.666 7.33366 12.666ZM4.66699 7.33268C4.66699 6.96449 4.96547 6.66602 5.33366 6.66602H9.33366C9.70185 6.66602 10.0003 6.96449 10.0003 7.33268C10.0003 7.70087 9.70185 7.99935 9.33366 7.99935H5.33366C4.96547 7.99935 4.66699 7.70087 4.66699 7.33268Z" fill="#1F2329"/>
357
+ </svg>
358
+ </button>
359
+ </div>
360
+ );
361
+ }
362
+
363
+ // ─────────────────────────────────────────────────────────────
364
+ // DCViewport — transform-based pan/zoom (internal)
365
+ //
366
+ // Input mapping (Figma-style):
367
+ // • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
368
+ // • trackpad scroll → pan (two-finger)
369
+ // • mouse wheel → zoom (notched; distinguished from trackpad scroll)
370
+ // • middle-drag / primary-drag-on-bg → pan
371
+ //
372
+ // Transform state lives in a ref and is written straight to the DOM
373
+ // (translate3d + will-change) so wheel ticks don't go through React —
374
+ // keeps pans at 60fps on dense canvases.
375
+ // ─────────────────────────────────────────────────────────────
376
+ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
377
+ const vpRef = React.useRef(null);
378
+ const worldRef = React.useRef(null);
379
+ // Default to 50% so a fresh canvas opens zoomed-out enough to see the
380
+ // overall layout; localStorage restore (below) overrides this on revisits.
381
+ const tf = React.useRef({ x: 0, y: 0, scale: 0.5 });
382
+ // Persist viewport across reloads so the user lands back where they were
383
+ // after an agent edit or browser refresh. The sandbox origin is already
384
+ // per-project; pathname keeps multiple canvas files in one project apart.
385
+ const tfKey = 'dc-viewport:' + location.pathname;
386
+ const saveT = React.useRef(0);
387
+
388
+ const lastPostedScale = React.useRef();
389
+ // Imperative handle for the on-canvas zoom control to zoom around the
390
+ // viewport centre (same path as the host's set-zoom message). Assigned in
391
+ // the pointer/wheel effect below where zoomAt is in scope.
392
+ const zoomCenteredRef = React.useRef(null);
393
+ // Percentage shown by the zoom control. Synced from apply() on every scale
394
+ // change — React bails out when the rounded value is unchanged, so pan ticks
395
+ // (scale unchanged) cost nothing. Lazily seeded from the persisted transform
396
+ // so the first paint already shows the restored — or default 50% — zoom.
397
+ const [scalePct, setScalePct] = React.useState(() => {
398
+ try {
399
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
400
+ if (s && Number.isFinite(s.scale)) {
401
+ return Math.round(Math.min(maxScale, Math.max(minScale, s.scale)) * 100);
402
+ }
403
+ } catch {}
404
+ return Math.round(tf.current.scale * 100);
405
+ });
406
+ const apply = React.useCallback(() => {
407
+ const { x, y, scale } = tf.current;
408
+ const el = worldRef.current;
409
+ if (!el) return;
410
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
411
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
412
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
413
+ // Keep the on-canvas zoom control's % readout in sync. React bails out on
414
+ // an unchanged rounded value, so pan ticks (scale unchanged) don't render.
415
+ setScalePct(Math.round(scale * 100));
416
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
417
+ // ticks leave scale unchanged — skip the cross-frame post for those.
418
+ if (lastPostedScale.current !== scale) {
419
+ lastPostedScale.current = scale;
420
+ window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
421
+ }
422
+ clearTimeout(saveT.current);
423
+ saveT.current = setTimeout(() => {
424
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
425
+ }, 200);
426
+ }, [tfKey]);
427
+
428
+ React.useLayoutEffect(() => {
429
+ const flush = () => {
430
+ clearTimeout(saveT.current);
431
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
432
+ };
433
+ try {
434
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
435
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
436
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
437
+ }
438
+ } catch {}
439
+ // Apply before first paint whether we restored or fell back to the default
440
+ // (scale 0.5), so the canvas never flashes 100% before snapping to 50%.
441
+ apply();
442
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
443
+ // window doesn't drop the last pan/zoom.
444
+ window.addEventListener('pagehide', flush);
445
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
446
+ }, []);
447
+
448
+ React.useEffect(() => {
449
+ const vp = vpRef.current;
450
+ if (!vp) return;
451
+
452
+ const zoomAt = (cx, cy, factor) => {
453
+ const r = vp.getBoundingClientRect();
454
+ const px = cx - r.left, py = cy - r.top;
455
+ const t = tf.current;
456
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
457
+ const k = next / t.scale;
458
+ // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
459
+ // marginBottom) reflow on every scale change, vertically shifting the
460
+ // world layout — so a world point mathematically pinned under the cursor
461
+ // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
462
+ // Anchor the DOM element under the cursor instead: record its screen Y,
463
+ // apply the transform + --dc-inv-zoom, then cancel whatever vertical
464
+ // drift the reflow introduced so it stays put on screen.
465
+ let marker = null, markerY0 = 0;
466
+ if (k !== 1) {
467
+ const hit = document.elementFromPoint(cx, cy);
468
+ marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
469
+ if (marker) markerY0 = marker.getBoundingClientRect().top;
470
+ }
471
+ // keep the world point under the cursor fixed
472
+ t.x = px - (px - t.x) * k;
473
+ t.y = py - (py - t.y) * k;
474
+ t.scale = next;
475
+ apply();
476
+ if (marker) {
477
+ // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
478
+ // departure after the --dc-inv-zoom reflow is the layout drift.
479
+ const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
480
+ if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
481
+ }
482
+ };
483
+
484
+ // On-canvas zoom control: zoom to a target scale around the viewport
485
+ // centre, matching the host set-zoom path (onHostMsg). Closes over the
486
+ // live zoomAt / vp / tf, so it always uses the current transform.
487
+ zoomCenteredRef.current = (targetScale) => {
488
+ const r = vp.getBoundingClientRect();
489
+ const clamped = Math.min(maxScale, Math.max(minScale, targetScale));
490
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, clamped / tf.current.scale);
491
+ };
492
+
493
+ // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
494
+ // line-mode deltas (Firefox) or large integer pixel deltas with no X
495
+ // component (Chrome/Safari, typically multiples of 100/120). Trackpad
496
+ // two-finger scroll sends small/fractional pixel deltas, often with
497
+ // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
498
+ const isMouseWheel = (e) =>
499
+ e.deltaMode !== 0 ||
500
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
501
+
502
+ const onWheel = (e) => {
503
+ e.preventDefault();
504
+ if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
505
+ if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
506
+ // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
507
+ // wheels fall through to the fixed-step branch below.
508
+ zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
509
+ } else if (isMouseWheel(e)) {
510
+ // notched mouse wheel — fixed-ratio step per click
511
+ zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
512
+ } else {
513
+ // trackpad two-finger scroll — pan
514
+ tf.current.x -= e.deltaX;
515
+ tf.current.y -= e.deltaY;
516
+ apply();
517
+ }
518
+ };
519
+
520
+ // Safari sends native gesture* events for trackpad pinch with a smooth
521
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
522
+ // better feel there. No-ops on other browsers. Safari also fires
523
+ // ctrlKey wheel events during the same pinch — isGesturing makes
524
+ // onWheel drop those entirely so they neither zoom nor pan.
525
+ let gsBase = 1;
526
+ let isGesturing = false;
527
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
528
+ const onGestureChange = (e) => {
529
+ e.preventDefault();
530
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
531
+ };
532
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
533
+
534
+ // Drag-pan: middle button anywhere, or primary button on canvas
535
+ // background (anything that isn't an artboard or an inline editor).
536
+ let drag = null;
537
+ const onPointerDown = (e) => {
538
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable, [data-miaoda-chrome]');
539
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
540
+ e.preventDefault();
541
+ vp.setPointerCapture(e.pointerId);
542
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
543
+ vp.style.cursor = 'grabbing';
544
+ };
545
+ const onPointerMove = (e) => {
546
+ if (!drag || e.pointerId !== drag.id) return;
547
+ tf.current.x += e.clientX - drag.lx;
548
+ tf.current.y += e.clientY - drag.ly;
549
+ drag.lx = e.clientX; drag.ly = e.clientY;
550
+ apply();
551
+ };
552
+ const onPointerUp = (e) => {
553
+ if (!drag || e.pointerId !== drag.id) return;
554
+ vp.releasePointerCapture(e.pointerId);
555
+ drag = null;
556
+ vp.style.cursor = '';
557
+ };
558
+
559
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
560
+ // visible midpoint stays fixed — matching the host's iframe-zoom feel.
561
+ const onHostMsg = (e) => {
562
+ const d = e.data;
563
+ if (d && d.type === 'miaoda:canvas:set-zoom' && typeof d.scale === 'number') {
564
+ const r = vp.getBoundingClientRect();
565
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
566
+ } else if (d && d.type === 'miaoda:canvas:probe') {
567
+ // Host's [readyGen] reset asks whether a canvas is present; it
568
+ // fires on the iframe's native 'load', which for canvases with
569
+ // images/fonts is after our mount-time announce, so re-announce.
570
+ // Clear the pan-tick guard so apply() re-posts the current scale
571
+ // even if it's unchanged — the host just reset dcScale to 1.
572
+ window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
573
+ lastPostedScale.current = undefined;
574
+ apply();
575
+ }
576
+ };
577
+ window.addEventListener('message', onHostMsg);
578
+ // Announce canvas mode so the host toolbar proxies its % control here
579
+ // instead of scaling the iframe element (which would just shrink the
580
+ // viewport window of an infinite canvas). The apply() that follows emits
581
+ // the initial miaoda:canvas:zoom so the toolbar % is correct before first pinch.
582
+ // lastPostedScale reset mirrors the miaoda:canvas:probe handler: the layout
583
+ // effect's restore-path apply() may already have posted the restored
584
+ // scale (before miaoda:canvas:present), so clear the guard to re-post it in order.
585
+ window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
586
+ lastPostedScale.current = undefined;
587
+ apply();
588
+
589
+ vp.addEventListener('wheel', onWheel, { passive: false });
590
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
591
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
592
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
593
+ vp.addEventListener('pointerdown', onPointerDown);
594
+ vp.addEventListener('pointermove', onPointerMove);
595
+ vp.addEventListener('pointerup', onPointerUp);
596
+ vp.addEventListener('pointercancel', onPointerUp);
597
+ return () => {
598
+ window.removeEventListener('message', onHostMsg);
599
+ vp.removeEventListener('wheel', onWheel);
600
+ vp.removeEventListener('gesturestart', onGestureStart);
601
+ vp.removeEventListener('gesturechange', onGestureChange);
602
+ vp.removeEventListener('gestureend', onGestureEnd);
603
+ vp.removeEventListener('pointerdown', onPointerDown);
604
+ vp.removeEventListener('pointermove', onPointerMove);
605
+ vp.removeEventListener('pointerup', onPointerUp);
606
+ vp.removeEventListener('pointercancel', onPointerUp);
607
+ };
608
+ }, [apply, minScale, maxScale]);
609
+
610
+ return (
611
+ <div
612
+ ref={vpRef}
613
+ className="design-canvas"
614
+ style={{
615
+ height: '100vh', width: '100vw',
616
+ background: DC.bg,
617
+ overflow: 'hidden',
618
+ overscrollBehavior: 'none',
619
+ touchAction: 'none',
620
+ position: 'relative',
621
+ fontFamily: DC.font,
622
+ boxSizing: 'border-box',
623
+ ...style,
624
+ }}
625
+ >
626
+ <div
627
+ ref={worldRef}
628
+ style={{
629
+ position: 'absolute', top: 0, left: 0,
630
+ transformOrigin: '0 0',
631
+ willChange: 'transform',
632
+ width: 'max-content', minWidth: '100%',
633
+ minHeight: '100%',
634
+ padding: '60px 0 80px',
635
+ }}
636
+ >
637
+ {children}
638
+ </div>
639
+ <DCZoomControl
640
+ scalePct={scalePct}
641
+ minScale={minScale}
642
+ maxScale={maxScale}
643
+ onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
644
+ />
645
+ </div>
646
+ );
647
+ }
648
+
649
+ // ─────────────────────────────────────────────────────────────
650
+ // DCSection — editable title + h-row of artboards in persisted order
651
+ // ─────────────────────────────────────────────────────────────
652
+ function DCSection({ id, title, subtitle, children, gap = 48 }) {
653
+ const ctx = React.useContext(DCCtx);
654
+ const sid = id ?? title;
655
+ const all = React.Children.toArray(dcFlatten(children));
656
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
657
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
658
+ const sec = (ctx && sid && ctx.section(sid)) || {};
659
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
660
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
661
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
662
+ const srcKey = allIds.join('\x1f');
663
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
664
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
665
+
666
+ const order = React.useMemo(() => {
667
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
668
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
669
+ }, [sec.order, srcOrder.join('|')]);
670
+
671
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
672
+
673
+ // marginBottom counter-scales so the on-screen gap between sections stays
674
+ // constant — otherwise at low zoom the (world-space) gap collapses while
675
+ // the screen-constant sectionhead below it doesn't, and the title reads as
676
+ // belonging to the section above. paddingBottom below is just enough for
677
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
678
+ // the title sits tight against its own row at every zoom.
679
+ return (
680
+ <div data-dc-section={sid}
681
+ style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
682
+ <div style={{ padding: '0 60px' }}>
683
+ <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
684
+ <DCEditable tag="div" value={sec.title ?? title}
685
+ onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
686
+ style={{ fontSize: 20, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 2, display: 'inline-block', lineHeight: '28px' }} />
687
+ {subtitle && <div style={{ fontSize: 14, color: DC.subtitle, lineHeight: '22px' }}>{subtitle}</div>}
688
+ </div>
689
+ </div>
690
+ <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
691
+ {order.map((k) => (
692
+ <DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
693
+ label={(sec.labels || {})[k] ?? byId[k].props.label}
694
+ onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
695
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
696
+ onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
697
+ hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
698
+ srcKey,
699
+ }))}
700
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
701
+ ))}
702
+ </div>
703
+ {rest}
704
+ </div>
705
+ );
706
+ }
707
+
708
+ // DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
709
+ function DCArtboard() { return null; }
710
+
711
+ // Per-artboard export (kind: 'png' | 'html'). Both paths share the same
712
+ // self-contained clone: computed styles baked in, @font-face / <img> /
713
+ // inline-style background-image urls inlined as data URIs. PNG wraps the
714
+ // clone in foreignObject→canvas at 3× the artboard's natural width×height
715
+ // (same pipeline the host uses for page captures); HTML wraps it in a
716
+ // minimal standalone document. Both are independent of viewport zoom.
717
+ async function dcExport(node, w, h, name, kind) {
718
+ try { await document.fonts.ready; } catch {}
719
+ const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
720
+ const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
721
+ })).catch(() => url);
722
+
723
+ // Collect @font-face rules. ss.cssRules throws SecurityError on
724
+ // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
725
+ // the CSS text directly (those endpoints send ACAO:*) and regex-extract
726
+ // the blocks. @import and @media/@supports are walked so nested
727
+ // @font-face rules aren't missed.
728
+ const fontRules = [], pending = [], seen = new Set();
729
+ const scrapeCss = (href) => {
730
+ if (seen.has(href)) return; seen.add(href);
731
+ pending.push(fetch(href).then((r) => r.text()).then((css) => {
732
+ for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
733
+ for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
734
+ scrapeCss(new URL(m[1], href).href);
735
+ }).catch(() => {}));
736
+ };
737
+ const walk = (rules, base) => {
738
+ for (const r of rules) {
739
+ if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
740
+ else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
741
+ const ibase = r.styleSheet.href || base;
742
+ try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
743
+ } else if (r.cssRules) walk(r.cssRules, base);
744
+ }
745
+ };
746
+ for (const ss of document.styleSheets) {
747
+ const base = ss.href || location.href;
748
+ try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
749
+ }
750
+ while (pending.length) await pending.shift();
751
+ const fontCss = (await Promise.all(fontRules.map(async (rule) => {
752
+ let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
753
+ while ((m = re.exec(rule.css))) {
754
+ if (m[2].indexOf('data:') === 0) continue;
755
+ let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
756
+ out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
757
+ }
758
+ return out;
759
+ }))).join('\n');
760
+
761
+ const cloneStyled = (src) => {
762
+ if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
763
+ const dst = src.cloneNode(false);
764
+ if (src.nodeType === 1) {
765
+ const cs = getComputedStyle(src); let txt = '';
766
+ for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
767
+ dst.setAttribute('style', txt + 'animation:none;transition:none;');
768
+ if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
769
+ }
770
+ for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
771
+ return dst;
772
+ };
773
+ const clone = cloneStyled(node);
774
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
775
+ // Drop the card's own shadow/radius so the export is a flush w×h rect;
776
+ // the artboard's own background (if any) is already in the computed style.
777
+ clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
778
+
779
+ const jobs = [];
780
+ clone.querySelectorAll('img').forEach((el) => {
781
+ const s = el.getAttribute('src');
782
+ if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
783
+ });
784
+ [clone, ...clone.querySelectorAll('*')].forEach((el) => {
785
+ const bg = el.style.backgroundImage; if (!bg) return;
786
+ let m; const re = /url\(["']?([^"')]+)["']?\)/g;
787
+ while ((m = re.exec(bg))) {
788
+ const tok = m[0], url = m[1];
789
+ if (url.indexOf('data:') === 0) continue;
790
+ jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
791
+ }
792
+ });
793
+ await Promise.all(jobs);
794
+
795
+ const xml = new XMLSerializer().serializeToString(clone);
796
+ const save = (blob, ext) => {
797
+ if (!blob) return;
798
+ const a = document.createElement('a');
799
+ a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
800
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
801
+ };
802
+
803
+ if (kind === 'html') {
804
+ const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
805
+ (fontCss ? '<style>' + fontCss + '</style>' : '') +
806
+ '</head><body style="margin:0">' + xml + '</body></html>';
807
+ return save(new Blob([html], { type: 'text/html' }), 'html');
808
+ }
809
+
810
+ // PNG: the SVG's own width/height must be the output resolution — an
811
+ // <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
812
+ // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
813
+ // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
814
+ // the HTML at full resolution.
815
+ const px = 3;
816
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
817
+ '" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
818
+ (fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
819
+ const img = new Image();
820
+ await new Promise((res, rej) => {
821
+ img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
822
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
823
+ });
824
+ const cv = document.createElement('canvas');
825
+ cv.width = w * px; cv.height = h * px;
826
+ cv.getContext('2d').drawImage(img, 0, 0);
827
+ cv.toBlob((blob) => save(blob, 'png'), 'image/png');
828
+ }
829
+
830
+ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
831
+ const { id: rawId, label: rawLabel, width = 260, height = 480, chromeless = false, children, style = {} } = artboard.props;
832
+ const id = rawId ?? rawLabel;
833
+ const ref = React.useRef(null);
834
+ const cardRef = React.useRef(null);
835
+ const menuRef = React.useRef(null);
836
+ const [menuOpen, setMenuOpen] = React.useState(false);
837
+ const [confirming, setConfirming] = React.useState(false);
838
+
839
+ // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
840
+ // the menu — first click arms the row, second commits; closing disarms.
841
+ React.useEffect(() => {
842
+ if (!menuOpen) { setConfirming(false); return; }
843
+ const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
844
+ document.addEventListener('pointerdown', off, true);
845
+ return () => document.removeEventListener('pointerdown', off, true);
846
+ }, [menuOpen]);
847
+
848
+ const doExport = (kind) => {
849
+ setMenuOpen(false);
850
+ if (!cardRef.current) return;
851
+ const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
852
+ const ew = cardRef.current.offsetWidth || width;
853
+ const eh = cardRef.current.offsetHeight || height;
854
+ dcExport(cardRef.current, ew, eh, name, kind)
855
+ .catch((e) => console.error('[design-canvas] export failed:', e));
856
+ };
857
+
858
+ // Live drag-reorder: dragged card sticks to cursor; siblings slide into
859
+ // their would-be slots in real time via transforms. DOM order only
860
+ // changes on drop.
861
+ const onGripDown = (e) => {
862
+ e.preventDefault(); e.stopPropagation();
863
+ const me = ref.current;
864
+ // translateX is applied in local (pre-scale) space but pointer deltas and
865
+ // getBoundingClientRect().left are screen-space — divide by the viewport's
866
+ // current scale so the dragged card tracks the cursor at any zoom level.
867
+ const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
868
+ const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
869
+ const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
870
+ const slotXs = homes.map((h) => h.x);
871
+ const startIdx = order.indexOf(id);
872
+ const startX = e.clientX;
873
+ let liveOrder = order.slice();
874
+ me.classList.add('dc-dragging');
875
+
876
+ const layout = () => {
877
+ for (const h of homes) {
878
+ if (h.id === id) continue;
879
+ const slot = liveOrder.indexOf(h.id);
880
+ h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
881
+ }
882
+ };
883
+
884
+ const move = (ev) => {
885
+ const dx = ev.clientX - startX;
886
+ me.style.transform = `translateX(${dx / scale}px)`;
887
+ const cur = homes[startIdx].x + dx;
888
+ let nearest = 0, best = Infinity;
889
+ for (let i = 0; i < slotXs.length; i++) {
890
+ const d = Math.abs(slotXs[i] - cur);
891
+ if (d < best) { best = d; nearest = i; }
892
+ }
893
+ if (liveOrder.indexOf(id) !== nearest) {
894
+ liveOrder = order.filter((k) => k !== id);
895
+ liveOrder.splice(nearest, 0, id);
896
+ layout();
897
+ }
898
+ };
899
+
900
+ const up = () => {
901
+ document.removeEventListener('pointermove', move);
902
+ document.removeEventListener('pointerup', up);
903
+ const finalSlot = liveOrder.indexOf(id);
904
+ me.classList.remove('dc-dragging');
905
+ me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
906
+ // After the settle transition, kill transitions + clear transforms +
907
+ // commit the reorder in the same frame so there's no visual snap-back.
908
+ setTimeout(() => {
909
+ for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
910
+ if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
911
+ requestAnimationFrame(() => requestAnimationFrame(() => {
912
+ for (const h of homes) h.el.style.transition = '';
913
+ }));
914
+ }, 180);
915
+ };
916
+ document.addEventListener('pointermove', move);
917
+ document.addEventListener('pointerup', up);
918
+ };
919
+
920
+ return (
921
+ <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
922
+ <div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
923
+ <div className="dc-labelrow">
924
+ <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
925
+ <svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
926
+ </div>
927
+ <div className="dc-labeltext" onClick={onFocus} title="Click to focus">
928
+ <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
929
+ style={{ fontSize: 14, fontWeight: 400, color: DC.label, lineHeight: '22px' }} />
930
+ </div>
931
+ </div>
932
+ <div className="dc-btns">
933
+ <div ref={menuRef} style={{ position: 'relative' }}>
934
+ <button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
935
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
936
+ </button>
937
+ {menuOpen && (
938
+ <div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
939
+ <button onClick={() => doExport('png')}>下载 PNG</button>
940
+ <button onClick={() => doExport('html')}>下载 HTML</button>
941
+ <button className="dc-danger"
942
+ onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
943
+ {confirming ? '确认删除' : '删除'}
944
+ </button>
945
+ </div>
946
+ )}
947
+ </div>
948
+ <button className="dc-expand" onClick={onFocus} title="Focus">
949
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
950
+ </button>
951
+ </div>
952
+ </div>
953
+ <div ref={cardRef} className="dc-card"
954
+ style={{ ...(chromeless ? {} : { width, height, borderRadius: 4, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', background: '#fff' }), ...style }}>
955
+ {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
956
+ </div>
957
+ </div>
958
+ );
959
+ }
960
+
961
+ // Inline rename — commits on blur or Enter.
962
+ function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
963
+ const T = tag;
964
+ return (
965
+ <T className="dc-editable" contentEditable suppressContentEditableWarning
966
+ onClick={onClick}
967
+ onPointerDown={(e) => e.stopPropagation()}
968
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
969
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
970
+ style={style}>{value}</T>
971
+ );
972
+ }
973
+
974
+ // ─────────────────────────────────────────────────────────────
975
+ // Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
976
+ // sections, Esc or backdrop click to exit.
977
+ // ─────────────────────────────────────────────────────────────
978
+ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
979
+ const ctx = React.useContext(DCCtx);
980
+ const { sectionId, artboard } = entry;
981
+ const sec = ctx.section(sectionId);
982
+ const meta = sectionMeta[sectionId];
983
+ const peers = meta.slotIds;
984
+ const aid = artboard.props.id ?? artboard.props.label;
985
+ const idx = peers.indexOf(aid);
986
+ const secIdx = sectionOrder.indexOf(sectionId);
987
+
988
+ const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
989
+ const goSection = (d) => {
990
+ // Sections whose artboards are all deleted have slotIds:[] — step past
991
+ // them to the next non-empty section so ↑/↓ doesn't dead-end.
992
+ const n = sectionOrder.length;
993
+ for (let i = 1; i < n; i++) {
994
+ const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
995
+ const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
996
+ if (first) { ctx.setFocus(`${ns}/${first}`); return; }
997
+ }
998
+ };
999
+
1000
+ React.useEffect(() => {
1001
+ const k = (e) => {
1002
+ if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
1003
+ if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
1004
+ if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
1005
+ if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
1006
+ };
1007
+ document.addEventListener('keydown', k);
1008
+ return () => document.removeEventListener('keydown', k);
1009
+ });
1010
+
1011
+ const { width = 260, height = 480, children } = artboard.props;
1012
+ const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
1013
+ React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
1014
+ const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
1015
+
1016
+ const [ddOpen, setDd] = React.useState(false);
1017
+ const Arrow = ({ dir, onClick }) => (
1018
+ <button onClick={(e) => { e.stopPropagation(); onClick(); }}
1019
+ style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
1020
+ border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
1021
+ width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
1022
+ display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
1023
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
1024
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
1025
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
1026
+ <path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
1027
+ </button>
1028
+ );
1029
+
1030
+ // Portal to body so position:fixed is the real viewport regardless of any
1031
+ // transform on DesignCanvas's ancestors (including the canvas zoom itself).
1032
+ return ReactDOM.createPortal(
1033
+ <div onClick={() => ctx.setFocus(null)}
1034
+ onWheel={(e) => e.preventDefault()}
1035
+ style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
1036
+ fontFamily: DC.font, color: '#fff' }}>
1037
+
1038
+ {/* top bar: section dropdown (left) · close (right) */}
1039
+ <div onClick={(e) => e.stopPropagation()}
1040
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
1041
+ <div style={{ position: 'relative' }}>
1042
+ <button onClick={() => setDd((o) => !o)}
1043
+ style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
1044
+ borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
1045
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1046
+ <span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
1047
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
1048
+ </span>
1049
+ {meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
1050
+ </button>
1051
+ {ddOpen && (
1052
+ <div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
1053
+ boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
1054
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
1055
+ <button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
1056
+ style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
1057
+ background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
1058
+ padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
1059
+ {sectionMeta[sid].title}
1060
+ </button>
1061
+ ))}
1062
+ </div>
1063
+ )}
1064
+ </div>
1065
+ <div style={{ flex: 1 }} />
1066
+ <button onClick={() => ctx.setFocus(null)}
1067
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
1068
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
1069
+ style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
1070
+ borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
1071
+ </div>
1072
+
1073
+ {/* card centered, label + index below — only the card itself stops
1074
+ propagation so any backdrop click (including the margins around
1075
+ the card) exits focus */}
1076
+ <div
1077
+ style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
1078
+ <div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
1079
+ <div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
1080
+ boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
1081
+ {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
1082
+ </div>
1083
+ </div>
1084
+ <div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
1085
+ {(sec.labels || {})[aid] ?? artboard.props.label}
1086
+ <span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
1087
+ </div>
1088
+ </div>
1089
+
1090
+ <Arrow dir="left" onClick={() => go(-1)} />
1091
+ <Arrow dir="right" onClick={() => go(1)} />
1092
+
1093
+ {/* dots */}
1094
+ <div onClick={(e) => e.stopPropagation()}
1095
+ style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
1096
+ {peers.map((p, i) => (
1097
+ <button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
1098
+ style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
1099
+ background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
1100
+ ))}
1101
+ </div>
1102
+ </div>,
1103
+ document.body,
1104
+ );
1105
+ }
1106
+
1107
+ // ─────────────────────────────────────────────────────────────
1108
+ // Post-it — absolute-positioned sticky note
1109
+ // ─────────────────────────────────────────────────────────────
1110
+ function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
1111
+ return (
1112
+ <div style={{
1113
+ position: 'absolute', top, left, right, bottom, width,
1114
+ background: DC.postitBg, padding: '14px 16px',
1115
+ fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
1116
+ fontSize: 14, lineHeight: 1.4, color: DC.postitText,
1117
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
1118
+ transform: `rotate(${rotate}deg)`,
1119
+ zIndex: 5,
1120
+ }}>{children}</div>
1121
+ );
1122
+ }
1123
+
1124
+ Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
1125
+