@lark-apaas/coding-registry-buildless 0.1.20 → 0.1.22

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,2189 @@
1
+ /* BEGIN USAGE */
2
+ // design-canvas.js — Figma-ish design canvas, as custom elements.
3
+ // Sections + artboards + post-it notes on an infinite pan/zoom canvas. No React.
4
+ // Defines: <design-canvas>, <dc-section>, <dc-artboard>, <dc-postit>
5
+ //
6
+ // Usage — load with a plain <script src="design-canvas.js"></script>:
7
+ //
8
+ // <design-canvas>
9
+ // <dc-section id="onboarding" title="Onboarding" subtitle="First-run variants">
10
+ // <dc-artboard id="welcome" label="01 · Welcome" width="390">
11
+ // ...your screen markup...
12
+ // </dc-artboard>
13
+ // <dc-artboard id="phone" label="02 · Phone number" width="390" height="844">…</dc-artboard>
14
+ // </dc-section>
15
+ // <dc-postit top="40" left="60">A quick note</dc-postit>
16
+ // </design-canvas>
17
+ //
18
+ // Attributes:
19
+ // <design-canvas> min-scale (0.1) · max-scale (8)
20
+ // <dc-section> id · title · subtitle · gap (48)
21
+ // <dc-artboard> id · label · width (260) · height (omit to grow with content) · chromeless
22
+ // <dc-postit> top · left · right · bottom · rotate (-2) · width (180)
23
+ // Booleans are by attribute presence: `chromeless` on, absent off.
24
+ //
25
+ // Artboards are reorderable (drag the grip), renameable (click the label),
26
+ // deletable (⋯ menu), and focusable (click the label or the expand button).
27
+ // Order / titles / labels / deletions persist to a .design-canvas.state.json
28
+ // sidecar next to the HTML; the viewport transform persists to localStorage.
29
+ /* END USAGE */
30
+
31
+ (() => {
32
+ const DC = {
33
+ bg: '#FCFCFC',
34
+ label: '#8F959E',
35
+ title: '#1F2329',
36
+ subtitle: '#646A73',
37
+ postitBg: '#fef4a8',
38
+ postitText: '#5a4a2a',
39
+ // Single quotes around font names: this string goes into a style="..." attribute,
40
+ // and a double quote inside it would close the attribute early.
41
+ font: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
42
+ };
43
+
44
+ /**
45
+ * Mirrors the host platform's isMobile() (UA regex plus iPad's maxTouchPoints).
46
+ * The generated page runs in a cross-origin iframe, so the shared helper cannot
47
+ * be imported. Deliberately NOT a viewport-width test: the desktop workbench
48
+ * puts the canvas inside a narrow preview iframe, and a width breakpoint would
49
+ * read that as mobile. Evaluated once at load. Keep in sync with the copy in
50
+ * deck-stage.js.
51
+ */
52
+ const DC_MOBILE_UA_RE =
53
+ /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
54
+ const dcIsMobile =
55
+ typeof navigator !== 'undefined' &&
56
+ (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
57
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) &&
58
+ !!navigator.maxTouchPoints &&
59
+ navigator.maxTouchPoints > 1));
60
+
61
+ /**
62
+ * This CSS goes into every shadow root instead of being injected into the
63
+ * document: once these became custom elements, their styles should no longer
64
+ * leak into the host page (the author's design has its own). Class names keep
65
+ * the dc- prefix — generated pages and downstream tooling recognise them.
66
+ */
67
+ /**
68
+ * Thumbnail capture pages stay out of Magic Share: a capture is just another
69
+ * render of the same page, and broadcasting from it would clobber the
70
+ * presenter's framing.
71
+ */
72
+ const dcCaptureMode =
73
+ typeof location !== 'undefined' && /[?&](_snthumb|thumbnail)=/.test(location.search || '');
74
+
75
+ /**
76
+ * Magic Share transport. This is the same singleton the JSX version defines —
77
+ * whichever file loads first owns it, so the two implementations must behave
78
+ * identically or the two halves of one page disagree.
79
+ *
80
+ * emit(msg, opts) — broadcast a snapshot. opts.key is the coalescing key
81
+ * (the host merges consecutive frames per key) and
82
+ * opts.throttle the suggested merge interval in ms.
83
+ * Snapshot semantics make dropped frames harmless.
84
+ * onApply(type, fn) — claim replays of one message type; returns an
85
+ * unsubscribe. fn answers applied / failed / ignored:
86
+ * 'failed' makes the host retry, 'ignored' is terminal.
87
+ * Unclaimed types stay silent on purpose — another script
88
+ * on the page may own them, and the host's timeout is the
89
+ * backstop.
90
+ */
91
+ if (!window.__miaodaMirror) {
92
+ const appliers = new Map(); // message type -> Set<handler>
93
+ window.__miaodaMirror = {
94
+ emit(msg, opts) {
95
+ try {
96
+ window.parent.postMessage(
97
+ Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }),
98
+ '*',
99
+ );
100
+ } catch (e) {}
101
+ },
102
+ onApply(type, fn) {
103
+ if (!appliers.has(type)) appliers.set(type, new Set());
104
+ appliers.get(type).add(fn);
105
+ return () => {
106
+ const set = appliers.get(type);
107
+ if (set) set.delete(fn);
108
+ };
109
+ },
110
+ };
111
+ window.addEventListener('message', async (e) => {
112
+ // Parent frame only: generated pages embed arbitrary third-party iframes,
113
+ // any of which could otherwise forge an apply and drive state — along with
114
+ // the persistence that follows from it.
115
+ if (e.source !== window.parent) return;
116
+ const d = e.data;
117
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
118
+ const fns = appliers.get(d.message.type);
119
+ if (!fns || !fns.size) return;
120
+ // Instances share one channel, so one success is enough. A sibling that is
121
+ // not ready yet must not drag an already-applied result down to 'failed' —
122
+ // that would put the host into a retry storm of no-ops. Every handler still
123
+ // runs (no short-circuit) so each instance receives the state.
124
+ let result = { type: 'ignored', reason: 'no applier result' };
125
+ for (const fn of Array.from(fns)) {
126
+ let r;
127
+ try {
128
+ r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid });
129
+ } catch (err) {
130
+ r = { type: 'failed', reason: String((err && err.message) || err) };
131
+ }
132
+ if (!r) continue;
133
+ if (r.type === 'applied') result = r;
134
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed'))
135
+ result = r;
136
+ }
137
+ try {
138
+ window.parent.postMessage(
139
+ { type: 'miaoda:mirror:apply-result', uuid: d.uuid, result },
140
+ '*',
141
+ );
142
+ } catch (e2) {}
143
+ });
144
+ }
145
+
146
+ /**
147
+ * The React version lives in the light DOM, so its box model comes from the
148
+ * product's own `*` reset (most products set border-box). That reset never
149
+ * reaches into a shadow root, and hardcoding either value gets half the products
150
+ * wrong. `inherit` is wrong too — it passes an inline border-box down to
151
+ * descendants, which a `*` rule would not. Copying the host's computed value is
152
+ * the only thing that matches the React version exactly.
153
+ */
154
+ const boxRule = (el) => `*,*::before,*::after{box-sizing:${getComputedStyle(el).boxSizing}}`;
155
+
156
+ const DC_CSS = `
157
+ /* Drag and selection: the React version injects these into the document and
158
+ matches the host element via [data-dc-slot]. Rules inside a shadow root cannot
159
+ reach their own host, so they have to go through :host(...). Miss this and the
160
+ card does not track the pointer and the dragged one gets no highlight — the
161
+ behaviour is all there, it just looks broken. */
162
+ :host{transition:transform .18s cubic-bezier(.2,.7,.3,1)}
163
+ :host(.dc-dragging){transition:none;z-index:10;pointer-events:none}
164
+ :host(.dc-dragging) .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #336DF4;transform:scale(1.02)}
165
+ .dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}
166
+ .dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}
167
+ .dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}
168
+ .dc-card *{scrollbar-width:none}
169
+ .dc-card *::-webkit-scrollbar{display:none}
170
+ .dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;
171
+ display:flex;align-items:center;container-type:inline-size;
172
+ width:calc((100% + 4px) / var(--dc-inv-zoom,1));
173
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left;will-change:transform}
174
+ .dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}
175
+ .dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}
176
+ .dc-grip:hover{background:rgba(0,0,0,.08)}
177
+ .dc-grip:active{cursor:grabbing}
178
+ .dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;
179
+ display:flex;align-items:center;transition:background .12s;overflow:hidden}
180
+ @container (max-width: 110px){
181
+ .dc-labeltext{display:none}
182
+ .dc-grip{opacity:0}
183
+ :host(:hover) .dc-grip{opacity:1}
184
+ }
185
+ .dc-labeltext:hover{background:rgba(31,35,41,0.05)}
186
+ .dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}
187
+ .dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}
188
+ .dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}
189
+ :host(:hover) .dc-header,:host(:hover) .dc-header .dc-editable{color:#646A73!important}
190
+ :host(:hover) .dc-btns,.dc-btns:has(.dc-menu){opacity:1}
191
+ @media (hover: none){.dc-btns{display:none}}
192
+ :host([data-dc-mobile]) .dc-grip{display:none}
193
+ :host(:hover) .dc-card.chromeless>*,:host(: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)}
194
+ .dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;
195
+ background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;
196
+ font:inherit;transition:background .12s,color .12s}
197
+ .dc-expand:hover,.dc-kebab:hover{background:rgba(31,35,41,0.05);color:#646A73}
198
+ .dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;
199
+ 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}
200
+ .dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;
201
+ border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;
202
+ color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}
203
+ .dc-menu button:hover{background:rgba(31,35,41,0.05)}
204
+ .dc-menu .dc-danger{color:#F54A45}
205
+ `;
206
+
207
+ const CANVAS_CSS = `
208
+ :host{display:block}
209
+ .dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}
210
+ .dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}
211
+ .dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;
212
+ 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}
213
+ .dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;
214
+ border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;
215
+ color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}
216
+ .dc-menu button:hover{background:rgba(31,35,41,0.05)}
217
+ .dc-zoom{position:absolute;bottom:16px;right:20px;z-index:50;display:flex;align-items:center;
218
+ background:#fff;border-radius:8px;padding:4px;font-family:inherit;
219
+ 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)}
220
+ .dc-zoom-btn{height:24px;min-width:24px;border-radius:6px;border:none;cursor:pointer;padding:0;
221
+ background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;
222
+ font:inherit;transition:background .12s,color .12s}
223
+ .dc-zoom-btn:hover{background:#1f23290d}
224
+ .dc-zoom-btn:disabled{opacity:.35;cursor:default;background:transparent;color:rgba(60,50,40,.7)}
225
+ .dc-zoom-toggle{position:relative;display:flex}
226
+ .dc-zoom-trigger{padding:0 6px;gap:4px}
227
+ .dc-zoom-pct{min-width:36px;text-align:center;font-size:12px;font-weight:400;color:#1F2329}
228
+ .dc-zoom-menu{top:auto;bottom:100%;left:50%;transform:translateX(-50%);margin-top:0;margin-bottom:6px;min-width:114px}
229
+ .dc-zoom-menu button{display:flex;align-items:center;justify-content:space-between;gap:8px}
230
+ .dc-sectionhead{}
231
+ `;
232
+
233
+ /**
234
+ * React version renders text and attributes through JSX, which escapes them. These
235
+ * elements build their shadow with innerHTML template strings, so that protection is
236
+ * gone — every value that comes from an author attribute, from persisted state, or
237
+ * from a Magic Share replay has to go through here. The artboard label matters most:
238
+ * it is renameable, it round-trips through the sidecar, AND a peer can push one in
239
+ * over Magic Share, so an unescaped one is script execution in the follower's page.
240
+ */
241
+ const esc = (v) =>
242
+ String(v ?? '').replace(
243
+ /[&<>"']/g,
244
+ (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch],
245
+ );
246
+
247
+ // Non-numeric input falls through into a style="..." attribute, so it must be
248
+ // escaped too — a quote in it would break out of the attribute.
249
+ const px = (v) => (/^-?\d+(\.\d+)?$/.test(String(v)) ? `${v}px` : esc(v));
250
+
251
+ /** An event's propagation path. composedPath throws in some implementations;
252
+ * fall back to target — better to under-match than to mis-match. */
253
+ const pathOf = (e) => {
254
+ try {
255
+ const p = e.composedPath ? e.composedPath() : null;
256
+ if (p && p.length) return p.filter(Boolean);
257
+ } catch {}
258
+ return e.target ? [e.target] : [];
259
+ };
260
+ const numAttr = (el, name, fallback) => {
261
+ const v = el.getAttribute(name);
262
+ const n = v === null || v === '' ? NaN : Number(v);
263
+ return Number.isFinite(n) ? n : fallback;
264
+ };
265
+
266
+ class Base extends HTMLElement {
267
+ constructor() {
268
+ super();
269
+ this.attachShadow({ mode: 'open' });
270
+ }
271
+ connectedCallback() {
272
+ this._render();
273
+ }
274
+ attributeChangedCallback() {
275
+ if (this.shadowRoot) this._render();
276
+ }
277
+ attr(name, fallback) {
278
+ const v = this.getAttribute(name);
279
+ return v === null || v === '' ? fallback : v;
280
+ }
281
+ /** Bubbles to <design-canvas>: it owns the state, children only report intent. */
282
+ emit(type, detail) {
283
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
284
+ }
285
+ }
286
+
287
+ /** Inline rename, committed on blur or Enter. Read-only on mobile, where a tap
288
+ * would pop the soft keyboard instead of browsing. */
289
+ function editable(cls, value, style) {
290
+ const editableAttr = dcIsMobile ? '' : ' contenteditable="true"';
291
+ return `<span class="dc-editable ${cls}"${editableAttr} style="${style}">${esc(value)}</span>`;
292
+ }
293
+ function wireEditable(root, sel, onChange) {
294
+ const el = root.querySelector(sel);
295
+ if (!el || dcIsMobile) return;
296
+ let committed = false;
297
+ // Commit entry point. The normal path is the blur event, but blur() throws in
298
+ // some implementations — the click-outside handler then calls this directly,
299
+ // so a value is never lost to a difference in how focus is dropped.
300
+ el.__dcCommit = () => {
301
+ if (committed) return;
302
+ committed = true;
303
+ onChange(el.textContent);
304
+ };
305
+ // The editable reports its own edit state rather than relying on activeElement:
306
+ // across a shadow boundary that returns the host element, and implementations
307
+ // disagree on the exact semantics.
308
+ el.addEventListener('focus', () => {
309
+ committed = false;
310
+ el.dispatchEvent(new CustomEvent('dc:edit-start', { bubbles: true, composed: true }));
311
+ });
312
+ el.addEventListener('pointerdown', (e) => e.stopPropagation());
313
+ el.addEventListener('blur', () => {
314
+ el.__dcCommit();
315
+ el.dispatchEvent(new CustomEvent('dc:edit-end', { bubbles: true, composed: true }));
316
+ });
317
+ el.addEventListener('keydown', (e) => {
318
+ if (e.key === 'Enter') {
319
+ e.preventDefault();
320
+ el.blur();
321
+ }
322
+ });
323
+ }
324
+
325
+ // ───────────────────────────────────────────────────────────
326
+ // <dc-postit> — an absolutely positioned sticky note
327
+ // ───────────────────────────────────────────────────────────
328
+ class DCPostIt extends Base {
329
+ static get observedAttributes() {
330
+ return ['top', 'left', 'right', 'bottom', 'rotate', 'width'];
331
+ }
332
+ _render() {
333
+ const pos = ['top', 'left', 'right', 'bottom']
334
+ .filter((k) => this.hasAttribute(k))
335
+ .map((k) => `${k}:${px(this.getAttribute(k))}`)
336
+ .join(';');
337
+ // Position, size and rotation go on the host element itself: it stands in for
338
+ // the div the React version rendered, and putting them on an inner node would
339
+ // hide them from anything measuring from outside.
340
+ this.style.cssText =
341
+ `position:absolute;${pos};width:${px(numAttr(this, 'width', 180))};` +
342
+ `transform:rotate(${numAttr(this, 'rotate', -2)}deg);z-index:5;display:block`;
343
+ this.shadowRoot.innerHTML = `<style>${boxRule(this)}:host{display:block}</style>
344
+ <div part="frame" style="background:${DC.postitBg};padding:14px 16px;
345
+ font-family:'Comic Sans MS','Marker Felt','Segoe Print',cursive;
346
+ font-size:14px;line-height:1.4;color:${DC.postitText};
347
+ box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 2px rgba(0,0,0,0.08)"><slot></slot></div>`;
348
+ }
349
+ }
350
+
351
+ // ───────────────────────────────────────────────────────────
352
+ // <dc-artboard> — header bar (grip / label / ⋯ / expand) plus the card
353
+ // ───────────────────────────────────────────────────────────
354
+ class DCArtboard extends Base {
355
+ static get observedAttributes() {
356
+ return ['id', 'label', 'width', 'height', 'chromeless'];
357
+ }
358
+ get slotId() {
359
+ return this.getAttribute('id') || this.getAttribute('label') || '';
360
+ }
361
+ /** The persisted label, pushed down by <design-canvas> (a rename wins). */
362
+ setLabel(v) {
363
+ this._label = v;
364
+ const el = this.shadowRoot?.querySelector('.dc-labeltext .dc-editable');
365
+ if (el && el.textContent !== v) el.textContent = v ?? '';
366
+ }
367
+ connectedCallback() {
368
+ this.setAttribute('data-dc-slot', this.slotId);
369
+ if (dcIsMobile) this.setAttribute('data-dc-mobile', '');
370
+ /**
371
+ * Layout-critical properties go inline rather than into :host. Per the Shadow
372
+ * DOM spec a :host rule loses to ANY document rule that matches the host, so a
373
+ * single `* { margin: 0 }` in the author's stylesheet wipes it out. The React
374
+ * version already set these inline; this just keeps that.
375
+ */
376
+ this.style.position = 'relative';
377
+ this.style.flexShrink = '0';
378
+ this.style.display = 'block';
379
+ this._render();
380
+ }
381
+ _render() {
382
+ const chromeless = this.hasAttribute('chromeless');
383
+ const width = numAttr(this, 'width', 260);
384
+ const hasHeight = this.hasAttribute('height');
385
+ const height = hasHeight ? numAttr(this, 'height', 0) : undefined;
386
+ const label = this._label ?? this.attr('label', '');
387
+ const cardStyle = chromeless
388
+ ? ''
389
+ : `width:${px(width)};${hasHeight ? `height:${px(height)};` : ''}` +
390
+ 'border-radius:4px;border:1px solid #DEE0E3;overflow:hidden;background:#fff;';
391
+
392
+ this.shadowRoot.innerHTML = `<style>${boxRule(this)}${DC_CSS}
393
+ :host{display:block;position:relative;flex-shrink:0}
394
+ :host([data-dc-hidden]){display:none}
395
+ </style>
396
+ <div part="frame" style="display:contents">
397
+ <div class="dc-header" part="header" data-miaoda-chrome="" style="color:${DC.label}">
398
+ <div class="dc-labelrow">
399
+ <div class="dc-grip" part="grip" title="Drag to reorder">
400
+ <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>
401
+ </div>
402
+ <div class="dc-labeltext" part="labeltext" title="Click to focus">
403
+ ${editable('', label, `font-size:14px;font-weight:400;color:${DC.label};line-height:22px`)}
404
+ </div>
405
+ </div>
406
+ <div class="dc-btns">
407
+ <div class="dc-kebab-wrap" style="position:relative">
408
+ <button class="dc-kebab" part="kebab" title="More">
409
+ <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>
410
+ </button>
411
+ </div>
412
+ <button class="dc-expand" part="expand" title="Focus">
413
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
414
+ </button>
415
+ </div>
416
+ </div>
417
+ <div class="dc-card${chromeless ? ' chromeless' : ''}" part="card" style="${cardStyle}"><slot></slot>${
418
+ this.childElementCount === 0 && !this.textContent.trim()
419
+ ? `<div style="min-height:160px;height:100%;display:flex;align-items:center;justify-content:center;color:#bbb;font-size:13px;font-family:${DC.font}">${esc(this.slotId)}</div>`
420
+ : ''
421
+ }</div>
422
+ </div>`;
423
+
424
+ this._wire();
425
+ }
426
+ _wire() {
427
+ const r = this.shadowRoot;
428
+ // On desktop, swallow pointerdown so header interactions do not start a
429
+ // background drag. On mobile it must reach the viewport, where the slop
430
+ // threshold arbitrates between tap and pan.
431
+ if (!dcIsMobile) {
432
+ r.querySelector('.dc-header')?.addEventListener('pointerdown', (e) => e.stopPropagation());
433
+ r.querySelector('.dc-grip')?.addEventListener('pointerdown', (e) => this._onGripDown(e));
434
+ }
435
+ r.querySelector('.dc-labeltext')?.addEventListener('click', () =>
436
+ this.emit('dc:focus', { slotId: this.slotId }),
437
+ );
438
+ r.querySelector('.dc-labeltext .dc-editable')?.addEventListener('click', (e) =>
439
+ e.stopPropagation(),
440
+ );
441
+ wireEditable(r, '.dc-labeltext .dc-editable', (v) =>
442
+ this.emit('dc:rename-label', { slotId: this.slotId, value: v }),
443
+ );
444
+ r.querySelector('.dc-expand')?.addEventListener('click', () =>
445
+ this.emit('dc:focus', { slotId: this.slotId }),
446
+ );
447
+ r.querySelector('.dc-kebab')?.addEventListener('click', () => this._toggleMenu());
448
+ }
449
+ _toggleMenu() {
450
+ const wrap = this.shadowRoot.querySelector('.dc-kebab-wrap');
451
+ const open = wrap.querySelector('.dc-menu');
452
+ if (open) {
453
+ open.remove();
454
+ this._confirming = false;
455
+ document.removeEventListener('pointerdown', this._offMenu, true);
456
+ return;
457
+ }
458
+ this._confirming = false;
459
+ const menu = document.createElement('div');
460
+ menu.className = 'dc-menu';
461
+ menu.innerHTML =
462
+ '<button data-act="png">下载 PNG</button><button data-act="html">下载 HTML</button>' +
463
+ '<button class="dc-danger" data-act="del">删除</button>';
464
+ menu.addEventListener('pointerdown', (e) => e.stopPropagation());
465
+ menu.addEventListener('click', (e) => {
466
+ const act = e.target.closest('button')?.dataset.act;
467
+ if (act === 'png' || act === 'html') {
468
+ this._toggleMenu();
469
+ this._export(act);
470
+ } else if (act === 'del') {
471
+ // Two-click confirm: the first click only changes the wording
472
+ if (this._confirming) {
473
+ this._toggleMenu();
474
+ this.emit('dc:delete', { slotId: this.slotId });
475
+ } else {
476
+ this._confirming = true;
477
+ e.target.closest('button').textContent = '确认删除';
478
+ }
479
+ }
480
+ });
481
+ wrap.appendChild(menu);
482
+ // Attach immediately and use composedPath to tell whether the click landed
483
+ // inside the menu. Deferring with setTimeout would never fire under fake
484
+ // timers, leaving the listener unattached and the menu permanently open.
485
+ const kebab = this.shadowRoot.querySelector('.dc-kebab');
486
+ this._offMenu = (e) => {
487
+ const inside = pathOf(e).some(
488
+ (n) => n === menu || menu.contains(n) || n === kebab || kebab?.contains(n),
489
+ );
490
+ if (!inside) this._toggleMenu();
491
+ };
492
+ document.addEventListener('pointerdown', this._offMenu, true);
493
+ }
494
+ _export(kind) {
495
+ const card = this.shadowRoot.querySelector('.dc-card');
496
+ if (!card) return;
497
+ // Unicode-friendly filename scrub: keep letters (CJK included), digits,
498
+ // whitespace and . _ -
499
+ const name = String(
500
+ this._label || this.attr('label', '') || this.slotId || 'artboard',
501
+ ).replace(/[^\p{L}\p{N}\s._-]+/gu, '_');
502
+ dcExport(
503
+ card,
504
+ card.offsetWidth || numAttr(this, 'width', 260),
505
+ card.offsetHeight || numAttr(this, 'height', 0),
506
+ name,
507
+ kind,
508
+ ).catch((e) => console.error('[design-canvas] export failed:', e));
509
+ }
510
+ /**
511
+ * Drag to reorder: the dragged card follows the pointer, the others make room
512
+ * live via transform, and the real order only changes on drop.
513
+ * Peers come from the light DOM — they are <dc-section>'s own children, so no
514
+ * shadow boundary is crossed.
515
+ */
516
+ _onGripDown(e) {
517
+ e.preventDefault();
518
+ e.stopPropagation();
519
+ const section = this.closest('dc-section');
520
+ if (!section) return;
521
+ const id = this.slotId;
522
+ // translateX applies in unscaled local space while pointer movement is in
523
+ // screen space — divide by the current scale.
524
+ const scale = this.getBoundingClientRect().width / this.offsetWidth || 1;
525
+ const peers = [...section.querySelectorAll(':scope > dc-artboard')];
526
+ const homes = peers.map((el) => ({ el, id: el.slotId, x: el.getBoundingClientRect().left }));
527
+ const order = section.currentOrder();
528
+ const slotXs = homes.map((h) => h.x);
529
+ const startIdx = order.indexOf(id);
530
+ const startX = e.clientX;
531
+ let liveOrder = order.slice();
532
+ this.classList.add('dc-dragging');
533
+
534
+ const layout = () => {
535
+ for (const h of homes) {
536
+ if (h.id === id) continue;
537
+ const slot = liveOrder.indexOf(h.id);
538
+ h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
539
+ }
540
+ };
541
+ const move = (ev) => {
542
+ const dx = ev.clientX - startX;
543
+ this.style.transform = `translateX(${dx / scale}px)`;
544
+ const cur = homes[startIdx].x + dx;
545
+ let nearest = 0,
546
+ best = Infinity;
547
+ for (let i = 0; i < slotXs.length; i++) {
548
+ const d = Math.abs(slotXs[i] - cur);
549
+ if (d < best) {
550
+ best = d;
551
+ nearest = i;
552
+ }
553
+ }
554
+ if (liveOrder.indexOf(id) !== nearest) {
555
+ liveOrder = order.filter((k) => k !== id);
556
+ liveOrder.splice(nearest, 0, id);
557
+ layout();
558
+ }
559
+ };
560
+ const up = () => {
561
+ document.removeEventListener('pointermove', move);
562
+ document.removeEventListener('pointerup', up);
563
+ const finalSlot = liveOrder.indexOf(id);
564
+ this.classList.remove('dc-dragging');
565
+ this.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
566
+ // When the settle animation ends, drop the transition, clear the transforms and
567
+ // commit the new order in the same frame, so nothing snaps back visibly.
568
+ setTimeout(() => {
569
+ for (const h of homes) {
570
+ h.el.style.transition = 'none';
571
+ h.el.style.transform = '';
572
+ }
573
+ if (liveOrder.join('|') !== order.join('|'))
574
+ this.emit('dc:reorder', { order: liveOrder });
575
+ requestAnimationFrame(() =>
576
+ requestAnimationFrame(() => {
577
+ for (const h of homes) h.el.style.transition = '';
578
+ }),
579
+ );
580
+ }, 180);
581
+ };
582
+ document.addEventListener('pointermove', move);
583
+ document.addEventListener('pointerup', up);
584
+ }
585
+ }
586
+
587
+ // ───────────────────────────────────────────────────────────
588
+ // <dc-section> — a renameable heading plus one row of artboards, in persisted order
589
+ // ───────────────────────────────────────────────────────────
590
+ class DCSection extends Base {
591
+ static get observedAttributes() {
592
+ return ['id', 'title', 'subtitle', 'gap'];
593
+ }
594
+ get sectionId() {
595
+ return this.getAttribute('id') || this.getAttribute('title') || '';
596
+ }
597
+ /** The artboard order in effect, deletions already removed. */
598
+ currentOrder() {
599
+ return this._order ?? this.srcOrder();
600
+ }
601
+ /**
602
+ * The SOURCE order — the order the author wrote, not the current DOM order.
603
+ *
604
+ * srcKey is derived from it and decides whether persisted deletions still apply
605
+ * to this version of the content. Reading the DOM directly breaks that: a
606
+ * reorder changes DOM order, srcKey changes with it, and every prior deletion
607
+ * is silently discarded. The React version reads JSX children, which never move;
608
+ * remembering the first-seen order restores the same semantics here.
609
+ * Attributes are read directly rather than through the slotId getter, since a
610
+ * child may not have upgraded yet.
611
+ */
612
+ srcOrder() {
613
+ const cur = [...this.querySelectorAll(':scope > dc-artboard')]
614
+ .map((a) => a.getAttribute('id') || a.getAttribute('label'))
615
+ .filter(Boolean);
616
+ const seen = this._srcSeen || [];
617
+ const next = [
618
+ ...seen.filter((id) => cur.includes(id)),
619
+ ...cur.filter((id) => !seen.includes(id)),
620
+ ];
621
+ this._srcSeen = next;
622
+ return next;
623
+ }
624
+ connectedCallback() {
625
+ this.setAttribute('data-dc-section', this.sectionId);
626
+ // Same reason as the artboard: only inline beats the author's `*` reset. The
627
+ // gap counter-scales so it stays constant on screen.
628
+ this.style.display = 'block';
629
+ this.style.position = 'relative';
630
+ this.style.marginBottom = 'calc(80px * var(--dc-inv-zoom, 1))';
631
+ this._render();
632
+ }
633
+ /** Pushed down by <design-canvas>: renamed title, order, deleted artboards. */
634
+ applyState(meta) {
635
+ this._order = meta.slotIds;
636
+ const t = this.shadowRoot?.querySelector('.dc-sectionhead .dc-editable');
637
+ if (t && meta.title != null && t.textContent !== meta.title) t.textContent = meta.title;
638
+ const boards = [...this.querySelectorAll(':scope > dc-artboard')];
639
+ for (const ab of boards) {
640
+ const shown = meta.ready && meta.slotIds.includes(ab.slotId);
641
+ /**
642
+ * A deleted artboard loses its data-dc-slot attribute too, not just
643
+ * display:none. That attribute is the shared anchor for author scripts, the
644
+ * export pipeline and the first-visit fit — leaving it in place means "still
645
+ * here" to all three.
646
+ */
647
+ if (shown) ab.setAttribute('data-dc-slot', ab.slotId);
648
+ else ab.removeAttribute('data-dc-slot');
649
+ ab.toggleAttribute('data-dc-hidden', !shown);
650
+ // display is inline now, so :host([data-dc-hidden]) cannot beat it — set the
651
+ // inline value directly.
652
+ ab.style.display = shown ? 'block' : 'none';
653
+ if (meta.labels && meta.labels[ab.slotId] != null) ab.setLabel(meta.labels[ab.slotId]);
654
+ }
655
+ /**
656
+ * Reordering really moves DOM nodes rather than using CSS order:
657
+ * querySelectorAll order is the external contract (author scripts, export and
658
+ * the thumbnail pipeline all read it), and CSS order changes only the visuals.
659
+ *
660
+ * Two safeguards; drop either one and this spins forever in a real browser:
661
+ * 1. Move nothing when the order is already right — a move is itself a
662
+ * mutation;
663
+ * 2. Tell the canvas to stop reacting while moving — its MutationObserver
664
+ * watches childList+subtree, so without the mute "reorder -> observe ->
665
+ * reorder" never terminates.
666
+ * happy-dom's MutationObserver is less eager, which is why the unit tests
667
+ * cannot see this one.
668
+ */
669
+ const cur = boards.filter((b) => meta.slotIds.includes(b.slotId)).map((b) => b.slotId);
670
+ if (cur.join('|') === meta.slotIds.join('|')) return;
671
+ const canvas = this.closest('design-canvas');
672
+ canvas?.pauseObserving();
673
+ let anchor = null;
674
+ for (const id of meta.slotIds) {
675
+ const ab = boards.find((b) => b.slotId === id);
676
+ if (!ab) continue;
677
+ if (anchor) anchor.after(ab);
678
+ else this.prepend(ab);
679
+ anchor = ab;
680
+ }
681
+ canvas?.resumeObserving();
682
+ }
683
+ _render() {
684
+ const subtitle = this.attr('subtitle', '');
685
+ const gap = numAttr(this, 'gap', 48);
686
+ this.shadowRoot.innerHTML = `<style>${boxRule(this)}${DC_CSS}
687
+ :host{display:block;position:relative;margin-bottom:calc(80px * var(--dc-inv-zoom,1))}
688
+ .dc-sectionhead{padding-bottom:calc(36px * var(--dc-inv-zoom,1))}
689
+ </style>
690
+ <div part="frame">
691
+ <div style="padding:0 60px">
692
+ <div class="dc-sectionhead" part="sectionhead">
693
+ ${editable(
694
+ '',
695
+ this.attr('title', ''),
696
+ `font-size:calc(20px * var(--dc-inv-zoom,1));font-weight:600;color:${DC.title};` +
697
+ 'letter-spacing:calc(-0.4px * var(--dc-inv-zoom,1));margin-bottom:calc(2px * var(--dc-inv-zoom,1));' +
698
+ 'display:inline-block;line-height:calc(28px * var(--dc-inv-zoom,1))',
699
+ )}
700
+ ${
701
+ subtitle
702
+ ? `<div style="font-size:calc(14px * var(--dc-inv-zoom,1));color:${DC.subtitle};line-height:calc(22px * var(--dc-inv-zoom,1))">${esc(subtitle)}</div>`
703
+ : ''
704
+ }
705
+ </div>
706
+ </div>
707
+ <div style="display:flex;gap:${px(gap)};padding:0 60px;align-items:flex-start;width:max-content">
708
+ <slot></slot>
709
+ </div>
710
+ </div>`;
711
+ wireEditable(this.shadowRoot, '.dc-sectionhead .dc-editable', (v) =>
712
+ this.emit('dc:rename-section', { sectionId: this.sectionId, value: v }),
713
+ );
714
+ }
715
+ }
716
+
717
+ // ───────────────────────────────────────────────────────────
718
+ // Single-artboard export (png / html). Both paths share one self-contained clone:
719
+ // computed styles baked into style attributes, and every @font-face plus <img> /
720
+ // inline background-image url inlined as a data URI.
721
+ // PNG puts the clone through foreignObject -> canvas at 3x the artboard's natural
722
+ // size; HTML wraps a minimal standalone document. Neither depends on canvas zoom.
723
+ // ───────────────────────────────────────────────────────────
724
+ async function dcExport(node, w, h, name, kind) {
725
+ try {
726
+ await document.fonts.ready;
727
+ } catch {}
728
+ const toDataURL = (url) =>
729
+ fetch(url)
730
+ .then((r) => r.blob())
731
+ .then(
732
+ (b) =>
733
+ new Promise((res) => {
734
+ const fr = new FileReader();
735
+ fr.onload = () => res(fr.result);
736
+ fr.onerror = () => res(url);
737
+ fr.readAsDataURL(b);
738
+ }),
739
+ )
740
+ .catch(() => url);
741
+
742
+ // Reading cssRules from a cross-origin stylesheet throws SecurityError (e.g.
743
+ // fonts.googleapis.com); fall back to fetching the CSS text and pulling
744
+ // @font-face out with a regex. @import and @media have to be walked too.
745
+ const fontRules = [],
746
+ pending = [],
747
+ seen = new Set();
748
+ const scrapeCss = (href) => {
749
+ if (seen.has(href)) return;
750
+ seen.add(href);
751
+ pending.push(
752
+ fetch(href)
753
+ .then((r) => r.text())
754
+ .then((css) => {
755
+ for (const m of css.match(/@font-face\s*{[^}]*}/g) || [])
756
+ fontRules.push({ css: m, base: href });
757
+ for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
758
+ scrapeCss(new URL(m[1], href).href);
759
+ })
760
+ .catch(() => {}),
761
+ );
762
+ };
763
+ const walk = (rules, base) => {
764
+ for (const r of rules) {
765
+ if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
766
+ else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
767
+ const ibase = r.styleSheet.href || base;
768
+ try {
769
+ walk(r.styleSheet.cssRules, ibase);
770
+ } catch {
771
+ scrapeCss(ibase);
772
+ }
773
+ } else if (r.cssRules) walk(r.cssRules, base);
774
+ }
775
+ };
776
+ for (const ss of document.styleSheets) {
777
+ const base = ss.href || location.href;
778
+ try {
779
+ walk(ss.cssRules, base);
780
+ } catch {
781
+ if (ss.href) scrapeCss(ss.href);
782
+ }
783
+ }
784
+ while (pending.length) await pending.shift();
785
+ const fontCss = (
786
+ await Promise.all(
787
+ fontRules.map(async (rule) => {
788
+ let out = rule.css,
789
+ m;
790
+ const re = /url\((['"]?)([^'")]+)\1\)/g;
791
+ while ((m = re.exec(rule.css))) {
792
+ if (m[2].indexOf('data:') === 0) continue;
793
+ let abs;
794
+ try {
795
+ abs = new URL(m[2], rule.base).href;
796
+ } catch {
797
+ continue;
798
+ }
799
+ out = out.split(m[0]).join('url("' + (await toDataURL(abs)) + '")');
800
+ }
801
+ return out;
802
+ }),
803
+ )
804
+ ).join('\n');
805
+
806
+ /**
807
+ * Cloning has to pierce shadow roots: artboard content arrives through a slot,
808
+ * so a plain cloneNode yields nothing but an empty <slot>. This is one of the
809
+ * things becoming custom elements forced — the React version had no such
810
+ * boundary.
811
+ */
812
+ const cloneStyled = (src) => {
813
+ if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT'))
814
+ return document.createTextNode('');
815
+ if (src.nodeType === 1 && src.localName === 'slot') {
816
+ // The slot itself never appears in the export; substitute the nodes it
817
+ // actually projects.
818
+ const frag = document.createDocumentFragment();
819
+ for (const n of src.assignedNodes({ flatten: true })) frag.appendChild(cloneStyled(n));
820
+ return frag;
821
+ }
822
+ const dst = src.cloneNode(false);
823
+ if (src.nodeType === 1) {
824
+ const cs = getComputedStyle(src);
825
+ let txt = '';
826
+ for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
827
+ dst.setAttribute('style', txt + 'animation:none;transition:none;');
828
+ if (src.tagName === 'CANVAS')
829
+ try {
830
+ const im = document.createElement('img');
831
+ im.src = src.toDataURL();
832
+ im.setAttribute('style', txt);
833
+ return im;
834
+ } catch {}
835
+ }
836
+ // A custom element's visible content lives in its shadow root; the light DOM
837
+ // is only the data source.
838
+ const inner = src.shadowRoot ?? src;
839
+ for (let c = inner.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
840
+ return dst;
841
+ };
842
+ const clone = cloneStyled(node);
843
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
844
+ // Drop the card's own shadow and corner radius — an export is a flush w×h block.
845
+ clone.style.boxShadow = 'none';
846
+ clone.style.borderRadius = '0';
847
+
848
+ const jobs = [];
849
+ clone.querySelectorAll('img').forEach((el) => {
850
+ const s = el.getAttribute('src');
851
+ if (s && s.indexOf('data:') !== 0)
852
+ jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
853
+ });
854
+ [clone, ...clone.querySelectorAll('*')].forEach((el) => {
855
+ const bg = el.style.backgroundImage;
856
+ if (!bg) return;
857
+ let m;
858
+ const re = /url\(["']?([^"')]+)["']?\)/g;
859
+ while ((m = re.exec(bg))) {
860
+ const tok = m[0],
861
+ url = m[1];
862
+ if (url.indexOf('data:') === 0) continue;
863
+ jobs.push(
864
+ toDataURL(url).then((d) => {
865
+ el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")');
866
+ }),
867
+ );
868
+ }
869
+ });
870
+ await Promise.all(jobs);
871
+
872
+ const xml = new XMLSerializer().serializeToString(clone);
873
+ const save = (blob, ext) => {
874
+ if (!blob) return;
875
+ const a = document.createElement('a');
876
+ a.href = URL.createObjectURL(blob);
877
+ a.download = name + '.' + ext;
878
+ a.click();
879
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
880
+ };
881
+
882
+ if (kind === 'html') {
883
+ const html =
884
+ '<!doctype html><html><head><meta charset="utf-8"><title>' +
885
+ name +
886
+ '</title>' +
887
+ (fontCss ? '<style>' + fontCss + '</style>' : '') +
888
+ '</head><body style="margin:0">' +
889
+ xml +
890
+ '</body></html>';
891
+ return save(new Blob([html], { type: 'text/html' }), 'html');
892
+ }
893
+
894
+ // PNG: the SVG's own width/height ARE the output resolution — an SVG loaded
895
+ // through <img> rasterises at its intrinsic size, so rendering at 1x and then
896
+ // ctx.scale-ing up would just magnify a 1x bitmap.
897
+ const scale = 3;
898
+ const svg =
899
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' +
900
+ w * scale +
901
+ '" height="' +
902
+ h * scale +
903
+ '" viewBox="0 0 ' +
904
+ w +
905
+ ' ' +
906
+ h +
907
+ '"><foreignObject width="' +
908
+ w +
909
+ '" height="' +
910
+ h +
911
+ '">' +
912
+ (fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') +
913
+ xml +
914
+ '</foreignObject></svg>';
915
+ const img = new Image();
916
+ await new Promise((res, rej) => {
917
+ img.onload = res;
918
+ img.onerror = () => rej(new Error('svg load failed'));
919
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
920
+ });
921
+ const cv = document.createElement('canvas');
922
+ cv.width = w * scale;
923
+ cv.height = h * scale;
924
+ cv.getContext('2d').drawImage(img, 0, 0);
925
+ cv.toBlob((blob) => save(blob, 'png'), 'image/png');
926
+ }
927
+
928
+ const DC_STATE_FILE = '.design-canvas.state.json';
929
+ const DC_ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
930
+
931
+ /** Hand the sidecar content back to the host to save, via postMessage. With no
932
+ * parent, do nothing. */
933
+ function miaodaWriteFile(path, content) {
934
+ try {
935
+ window.parent.postMessage({ type: 'miaoda:bridge:write-file', path, content }, '*');
936
+ } catch {
937
+ /* no parent — ignore */
938
+ }
939
+ return Promise.resolve();
940
+ }
941
+
942
+ // ───────────────────────────────────────────────────────────
943
+ // Focus overlay — blows up a single artboard. ←/→ moves within the section, ↑/↓
944
+ // across sections, Esc or a backdrop click exits.
945
+ // Mounted on document.body rather than inside a shadow root, so position:fixed
946
+ // resolves against the real viewport — inside, the canvas's own transform would
947
+ // turn it into a containing block.
948
+ // ───────────────────────────────────────────────────────────
949
+ class DCFocusOverlay {
950
+ constructor(canvas) {
951
+ this.canvas = canvas;
952
+ this.root = null;
953
+ }
954
+ close() {
955
+ if (!this.root) return;
956
+ document.removeEventListener('keydown', this._onKey);
957
+ window.removeEventListener('resize', this._onResize);
958
+ this.root.remove();
959
+ this.root = null;
960
+ }
961
+ open(slotId) {
962
+ this.close();
963
+ const { sectionId, artboard } = this.canvas.registry[slotId] || {};
964
+ if (!artboard) return;
965
+ const meta = this.canvas.sectionMeta[sectionId];
966
+ const peers = meta.slotIds;
967
+ const idx = peers.indexOf(artboard.slotId);
968
+
969
+ const chromeless = artboard.hasAttribute('chromeless');
970
+ const hasHeight = artboard.hasAttribute('height');
971
+ const width = numAttr(artboard, 'width', 260);
972
+ // Auto-sized cards (chromeless, or no explicit height) are measured against
973
+ // their content first and scaled from that measurement; otherwise they get
974
+ // stuffed into an undersized clip box and the magnified result is a cropped
975
+ // slice.
976
+ const autoSize = chromeless || !hasHeight;
977
+ const card = artboard.shadowRoot.querySelector('.dc-card');
978
+ const measured = autoSize ? { w: card.offsetWidth, h: card.offsetHeight } : null;
979
+ // Hide an auto-sized card that could not be measured — one frame scaled from
980
+ // the fallback size flashes visibly wrong content.
981
+ const needsMeasure = autoSize && !(measured.w > 0);
982
+ const cw = autoSize ? measured.w || width : width;
983
+ const ch = autoSize ? measured.h || 480 : numAttr(artboard, 'height', 480);
984
+
985
+ const root = document.createElement('div');
986
+ root.className = 'dc-focus-root';
987
+ root.innerHTML = `<style>
988
+ .dc-focus-content *{scrollbar-width:none}
989
+ .dc-focus-content *::-webkit-scrollbar{display:none}
990
+ </style>`;
991
+ Object.assign(root.style, {
992
+ position: 'fixed',
993
+ inset: '0',
994
+ zIndex: '100',
995
+ background: 'rgba(0,0,0,.8)',
996
+ backdropFilter: 'blur(4px)',
997
+ fontFamily: DC.font,
998
+ display: 'flex',
999
+ alignItems: 'center',
1000
+ justifyContent: 'center',
1001
+ });
1002
+
1003
+ const content = document.createElement('div');
1004
+ content.className = 'dc-focus-content';
1005
+ content.style.position = 'relative';
1006
+ // Set explicitly: stay hidden until the natural size is known, so no frame is
1007
+ // painted at the wrong size.
1008
+ content.style.visibility = needsMeasure ? 'hidden' : 'visible';
1009
+ const inner = document.createElement('div');
1010
+ // The content is the artboard's real light DOM, not a clone, and goes back
1011
+ // when the overlay closes — so interaction state (scroll position, typed
1012
+ // input) survives entering and leaving focus.
1013
+ this._borrowed = [...artboard.childNodes];
1014
+ this._returnTo = artboard;
1015
+ for (const n of this._borrowed) inner.appendChild(n);
1016
+ content.appendChild(inner);
1017
+ root.appendChild(content);
1018
+
1019
+ const layout = () => {
1020
+ // Fill the viewport minus a safe margin (70 left/right, 32 top/bottom), taking
1021
+ // the smaller of the width and height ratios so the whole board stays visible.
1022
+ const MARGIN_X = 70,
1023
+ MARGIN_Y = 32;
1024
+ const scale = Math.max(
1025
+ 0.05,
1026
+ Math.min(
1027
+ (window.innerWidth - MARGIN_X * 2) / cw,
1028
+ (window.innerHeight - MARGIN_Y * 2) / ch,
1029
+ ),
1030
+ );
1031
+ content.style.width = cw * scale + 'px';
1032
+ content.style.height = ch * scale + 'px';
1033
+ Object.assign(inner.style, {
1034
+ width: cw + 'px',
1035
+ height: ch + 'px',
1036
+ transform: `scale(${scale})`,
1037
+ transformOrigin: 'top left',
1038
+ });
1039
+ if (!chromeless) {
1040
+ Object.assign(inner.style, {
1041
+ background: '#fff',
1042
+ borderRadius: '2px',
1043
+ overflow: 'auto',
1044
+ boxShadow: '0 20px 80px rgba(0,0,0,.4)',
1045
+ });
1046
+ }
1047
+ };
1048
+ layout();
1049
+ this._onResize = layout;
1050
+ window.addEventListener('resize', this._onResize);
1051
+
1052
+ // The chrome (arrows / close / dots) only shows while the pointer is over the
1053
+ // dim backdrop; when hidden it also goes pointer-events:none so it cannot
1054
+ // block content interaction or a backdrop click.
1055
+ let chromeVisible = false;
1056
+ const chromeEls = [];
1057
+ const setChrome = (v) => {
1058
+ if (chromeVisible === v) return;
1059
+ chromeVisible = v;
1060
+ for (const el of chromeEls) {
1061
+ el.style.opacity = v ? '1' : '0';
1062
+ el.style.pointerEvents = v ? 'auto' : 'none';
1063
+ }
1064
+ };
1065
+ const btn = (extra, svg, onClick) => {
1066
+ const b = document.createElement('button');
1067
+ b.innerHTML = svg;
1068
+ Object.assign(b.style, {
1069
+ position: 'absolute',
1070
+ border: 'none',
1071
+ background: 'rgba(255,255,255,.08)',
1072
+ color: 'rgb(255,255,255)',
1073
+ width: '36px',
1074
+ height: '36px',
1075
+ borderRadius: '22px',
1076
+ fontSize: '16px',
1077
+ cursor: 'pointer',
1078
+ display: 'flex',
1079
+ alignItems: 'center',
1080
+ justifyContent: 'center',
1081
+ transition: 'background .15s, opacity .18s ease',
1082
+ opacity: '0',
1083
+ pointerEvents: 'none',
1084
+ ...extra,
1085
+ });
1086
+ b.addEventListener('mouseenter', () => (b.style.background = 'rgba(255,255,255,.18)'));
1087
+ b.addEventListener('mouseleave', () => (b.style.background = 'rgba(255,255,255,.08)'));
1088
+ b.addEventListener('click', (e) => {
1089
+ e.stopPropagation();
1090
+ onClick();
1091
+ });
1092
+ chromeEls.push(b);
1093
+ root.appendChild(b);
1094
+ return b;
1095
+ };
1096
+
1097
+ const go = (d) => {
1098
+ const n = peers[(idx + d + peers.length) % peers.length];
1099
+ if (n) this.canvas.setFocus(`${sectionId}/${n}`);
1100
+ };
1101
+ const goSection = (d) => {
1102
+ // A section whose artboards were all deleted has empty slotIds — skip it so
1103
+ // ↑/↓ never lands on a dead end.
1104
+ const order = this.canvas.sectionOrder;
1105
+ const n = order.length;
1106
+ const secIdx = order.indexOf(sectionId);
1107
+ for (let i = 1; i < n; i++) {
1108
+ const ns = order[(((secIdx + d * i) % n) + n) % n];
1109
+ const first = this.canvas.sectionMeta[ns] && this.canvas.sectionMeta[ns].slotIds[0];
1110
+ if (first) {
1111
+ this.canvas.setFocus(`${ns}/${first}`);
1112
+ return;
1113
+ }
1114
+ }
1115
+ };
1116
+
1117
+ const ARROW_L =
1118
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10.86 1.53c.26.26.26.68 0 .94L5.33 8l5.53 5.53c.26.26.26.68 0 .94a.67.67 0 0 1-.94 0L4.39 8.94a1.33 1.33 0 0 1 0-1.89l5.53-5.52c.26-.26.68-.26.94 0Z" fill="currentColor"/></svg>';
1119
+ const ARROW_R =
1120
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M5.14 14.47a.67.67 0 0 1 0-.94L10.67 8 5.14 2.47a.67.67 0 1 1 .94-.94l5.53 5.53c.52.52.52 1.37 0 1.89l-5.53 5.52a.67.67 0 0 1-.94 0Z" fill="currentColor"/></svg>';
1121
+ const CLOSE =
1122
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.47 13.47a.66.66 0 0 0 0-.93L8.94 8l4.53-4.54a.66.66 0 1 0-.93-.93L8 7.06 3.46 2.53a.66.66 0 0 0-.93.93L7.06 8l-4.53 4.54a.66.66 0 0 0 .93.93L8 8.94l4.54 4.53c.26.26.68.26.93 0Z" fill="currentColor"/></svg>';
1123
+
1124
+ btn({ top: '50%', left: '20px', transform: 'translateY(-50%)' }, ARROW_L, () => go(-1));
1125
+ btn({ top: '50%', right: '20px', transform: 'translateY(-50%)' }, ARROW_R, () => go(1));
1126
+ btn({ top: '20px', right: '24px' }, CLOSE, () => this.canvas.setFocus(null));
1127
+
1128
+ const dots = document.createElement('div');
1129
+ Object.assign(dots.style, {
1130
+ position: 'absolute',
1131
+ bottom: '13px',
1132
+ left: '50%',
1133
+ transform: 'translateX(-50%)',
1134
+ display: 'flex',
1135
+ gap: '8px',
1136
+ transition: 'opacity .18s ease',
1137
+ opacity: '0',
1138
+ pointerEvents: 'none',
1139
+ });
1140
+ dots.addEventListener('click', (e) => e.stopPropagation());
1141
+ peers.forEach((p, i) => {
1142
+ const d = document.createElement('button');
1143
+ Object.assign(d.style, {
1144
+ border: 'none',
1145
+ padding: '0',
1146
+ cursor: 'pointer',
1147
+ width: '6px',
1148
+ height: '6px',
1149
+ borderRadius: '3px',
1150
+ background: i === idx ? '#fff' : 'rgba(255,255,255,.3)',
1151
+ });
1152
+ d.addEventListener('click', () => this.canvas.setFocus(`${sectionId}/${p}`));
1153
+ dots.appendChild(d);
1154
+ });
1155
+ chromeEls.push(dots);
1156
+ root.appendChild(dots);
1157
+
1158
+ content.addEventListener('click', (e) => e.stopPropagation());
1159
+ root.addEventListener('click', () => this.canvas.setFocus(null));
1160
+ root.addEventListener('mousemove', (e) => setChrome(!content.contains(e.target)));
1161
+ root.addEventListener('mouseleave', () => setChrome(false));
1162
+ root.addEventListener('wheel', (e) => {
1163
+ if (!content.contains(e.target)) e.preventDefault();
1164
+ });
1165
+
1166
+ this._onKey = (e) => {
1167
+ if (e.key === 'ArrowLeft') {
1168
+ e.preventDefault();
1169
+ go(-1);
1170
+ } else if (e.key === 'ArrowRight') {
1171
+ e.preventDefault();
1172
+ go(1);
1173
+ } else if (e.key === 'ArrowUp') {
1174
+ e.preventDefault();
1175
+ goSection(-1);
1176
+ } else if (e.key === 'ArrowDown') {
1177
+ e.preventDefault();
1178
+ goSection(1);
1179
+ }
1180
+ };
1181
+ document.addEventListener('keydown', this._onKey);
1182
+
1183
+ document.body.appendChild(root);
1184
+ this.root = root;
1185
+ }
1186
+ /** Put the borrowed content back on the artboard, unchanged. */
1187
+ restore() {
1188
+ if (!this._returnTo) return;
1189
+ for (const n of this._borrowed) this._returnTo.appendChild(n);
1190
+ this._returnTo = null;
1191
+ this._borrowed = [];
1192
+ }
1193
+ }
1194
+
1195
+ // ───────────────────────────────────────────────────────────
1196
+ // <design-canvas> — the pan/zoom viewport and the owner of all state.
1197
+ //
1198
+ // Input mapping (Figma's):
1199
+ // trackpad pinch / Ctrl·⌘+wheel -> zoom; unmodified wheel -> pan;
1200
+ // middle-drag or left-drag on the background -> pan; on mobile, one finger pans
1201
+ // and two fingers pinch to zoom.
1202
+ // The transform lives in instance fields and is written straight to the DOM
1203
+ // (translate3d + will-change) rather than driving a re-render.
1204
+ // ───────────────────────────────────────────────────────────
1205
+ class DesignCanvas extends Base {
1206
+ static get observedAttributes() {
1207
+ return ['min-scale', 'max-scale'];
1208
+ }
1209
+ constructor() {
1210
+ super();
1211
+ this.tf = { x: 0, y: 0, scale: 0.5 };
1212
+ this.state = { sections: {}, focus: null };
1213
+ this.registry = {};
1214
+ this.sectionMeta = {};
1215
+ this.sectionOrder = [];
1216
+ this.overlay = new DCFocusOverlay(this);
1217
+ this._tfKey = 'dc-viewport:' + location.pathname;
1218
+ this._saveT = 0;
1219
+ this._writeT = 0;
1220
+ this._didRead = false;
1221
+ this._ready = false;
1222
+ this._skipNextWrite = false;
1223
+ // Magic Share echo guards, one per mirrored piece of state: a remote replay has
1224
+ // to land locally without re-broadcasting as if the user did it here.
1225
+ this._skipNextMirrorEmit = false;
1226
+ this._skipNextFocusEmit = false;
1227
+ this._applyingRemoteView = false;
1228
+ this._viewIsRemote = false;
1229
+ this._mirrorSeeded = false;
1230
+ this._mirrorEmitT = 0;
1231
+ this._mirrorOffs = [];
1232
+ this._didFit = false;
1233
+ this._restoredView = false;
1234
+ this._lastPostedScale = undefined;
1235
+ }
1236
+ get minScale() {
1237
+ return numAttr(this, 'min-scale', 0.1);
1238
+ }
1239
+ get maxScale() {
1240
+ return numAttr(this, 'max-scale', 8);
1241
+ }
1242
+
1243
+ connectedCallback() {
1244
+ this._render();
1245
+ this._readSidecar();
1246
+ this._bindHost();
1247
+ this._bindGestures();
1248
+ this._bindChildEvents();
1249
+ this.addEventListener('dc:edit-start', (ev) => {
1250
+ this._editing = pathOf(ev)[0];
1251
+ });
1252
+ this.addEventListener('dc:edit-end', () => {
1253
+ this._editing = null;
1254
+ });
1255
+ // Any section or artboard the author adds or removes rebuilds the registry
1256
+ this._mo = new MutationObserver(() => this.rebuild());
1257
+ this._mo.observe(this, {
1258
+ childList: true,
1259
+ subtree: true,
1260
+ attributeFilter: ['id', 'label', 'title'],
1261
+ });
1262
+ // Order matters: write the transform into the DOM first, then rebuild the
1263
+ // registry and fit. The other way round, the first measurement happens while
1264
+ // the world still has no transform, so world coordinates come out divided by
1265
+ // scale=1 — off by the whole zoom factor.
1266
+ this._restoreView();
1267
+ this.rebuild();
1268
+ // The first-visit fit has to happen at a deterministic moment. During
1269
+ // connectedCallback the descendant custom elements have not upgraded yet, so
1270
+ // there is nothing to measure; waiting for MutationObserver delivery instead
1271
+ // lets the fit drift across hundreds of milliseconds with how fast the page's
1272
+ // own scripts load — and content measures differently before and after a web
1273
+ // font lands, so the opening framing jitters with it. On the microtask tick
1274
+ // every descendant inserted in the same task has upgraded synchronously: the
1275
+ // moment is fixed, and earlier than any network resource. This matches the
1276
+ // semantics of the React version's useLayoutEffect.
1277
+ queueMicrotask(() => this.fitToContent());
1278
+ this._onDocKey = (e) => {
1279
+ if (e.key === 'Escape') this.setFocus(null);
1280
+ };
1281
+ this._onDocDown = (e) => {
1282
+ const ae = this._editing;
1283
+ if (!ae) return;
1284
+ // A click on the element being edited is not a click outside. Decide via
1285
+ // composedPath: e.target alone resolves to the host element across a shadow
1286
+ // boundary.
1287
+ if (pathOf(e).includes(ae)) return;
1288
+ // blur triggers a focus recalculation, and some implementations throw there
1289
+ // when the element sits inside a shadow root. Commit the value anyway —
1290
+ // content must not be lost to a difference in the focus mechanism.
1291
+ try {
1292
+ ae.blur();
1293
+ } catch {}
1294
+ ae.__dcCommit?.();
1295
+ };
1296
+ document.addEventListener('keydown', this._onDocKey);
1297
+ this.addEventListener('pointerdown', this._onDocDown);
1298
+ this._bindMirror();
1299
+ }
1300
+
1301
+ /**
1302
+ * Magic Share replay entries. They claim exactly the types this component emits,
1303
+ * so the emit-and-apply contract stays in one place.
1304
+ * Each answers applied or ignored — 'failed' means "cannot apply yet, retry",
1305
+ * and none of these three has such a state; answering it by mistake makes the
1306
+ * host retry forever.
1307
+ */
1308
+ _bindMirror() {
1309
+ if (dcCaptureMode) return;
1310
+ this._mirrorOffs = [
1311
+ window.__miaodaMirror.onApply('miaoda:canvas:structure-changed', (m) => {
1312
+ // Per-field shape check, not just typeof: a malformed snapshot (order that
1313
+ // is not an array, say) would assign cleanly, be answered 'applied', and
1314
+ // only then blow up the whole tree on rebuild — an unrecoverable failure
1315
+ // the retry protocol cannot see.
1316
+ if (!m.sections || typeof m.sections !== 'object')
1317
+ return { type: 'ignored', reason: 'no sections' };
1318
+ for (const sec of Object.values(m.sections)) {
1319
+ if (
1320
+ !sec ||
1321
+ typeof sec !== 'object' ||
1322
+ (sec.order !== undefined && !Array.isArray(sec.order)) ||
1323
+ (sec.hidden !== undefined && !Array.isArray(sec.hidden)) ||
1324
+ (sec.labels !== undefined && (typeof sec.labels !== 'object' || sec.labels === null))
1325
+ ) {
1326
+ return { type: 'ignored', reason: 'malformed sections' };
1327
+ }
1328
+ }
1329
+ this._skipNextMirrorEmit = true;
1330
+ // A replayed arrangement is the presenter's state, not a local edit — never
1331
+ // persist it back through this end's write bridge.
1332
+ this._skipNextWrite = true;
1333
+ // Once set, a slower sidecar read cannot clobber it. _didRead goes up with
1334
+ // it, or the guard at the top of _writeSidecar would strand both flags in
1335
+ // the raised position and swallow the next real broadcast and write.
1336
+ this._mirrorSeeded = true;
1337
+ this._didRead = true;
1338
+ this.state.sections = m.sections;
1339
+ this.rebuild();
1340
+ this._writeSidecar();
1341
+ return { type: 'applied' };
1342
+ }),
1343
+ window.__miaodaMirror.onApply('miaoda:canvas:focus-changed', (m) => {
1344
+ const next = typeof m.focus === 'string' ? m.focus : null;
1345
+ // With an unchanged value setFocus returns early and emits nothing, so
1346
+ // raising the guard here would strand it and swallow the next real focus
1347
+ // broadcast.
1348
+ if (next === this.state.focus) return { type: 'applied' };
1349
+ this._skipNextFocusEmit = true;
1350
+ this.setFocus(next);
1351
+ return { type: 'applied' };
1352
+ }),
1353
+ window.__miaodaMirror.onApply('miaoda:canvas:viewport-changed', (m) => {
1354
+ if (![m.x, m.y, m.scale].every(Number.isFinite))
1355
+ return { type: 'ignored', reason: 'bad snapshot' };
1356
+ // Kill the pending local-emit timer: it was armed before this replay and
1357
+ // would fire 120ms later with tf already holding the remote state — echoing
1358
+ // the presenter's framing back as our own.
1359
+ clearTimeout(this._mirrorEmitT);
1360
+ this._mirrorEmitT = 0;
1361
+ // Same staleness for the persist timer: armed by a local gesture, but it reads
1362
+ // tf at fire time and would save the remote framing as this viewer's.
1363
+ clearTimeout(this._saveT);
1364
+ // _viewIsRemote is raised by apply(), the single fan-out for both local
1365
+ // gestures and replays.
1366
+ // A replayed framing IS this canvas's framing now — mark the first-visit fit
1367
+ // as satisfied, or it re-solves as soon as content mounts and overrides the
1368
+ // presenter's viewport.
1369
+ this._didFit = true;
1370
+ this._applyingRemoteView = true;
1371
+ this.tf = {
1372
+ x: m.x,
1373
+ y: m.y,
1374
+ scale: Math.min(this.maxScale, Math.max(this.minScale, m.scale)),
1375
+ };
1376
+ this.apply();
1377
+ this._applyingRemoteView = false;
1378
+ return { type: 'applied' };
1379
+ }),
1380
+ ];
1381
+ }
1382
+
1383
+ disconnectedCallback() {
1384
+ for (const off of this._mirrorOffs || []) off();
1385
+ this._mirrorOffs = [];
1386
+ clearTimeout(this._mirrorEmitT);
1387
+ this._mirrorEmitT = 0;
1388
+ this._mo?.disconnect();
1389
+ this._mo = null;
1390
+ document.removeEventListener('keydown', this._onDocKey);
1391
+ this.removeEventListener('pointerdown', this._onDocDown);
1392
+ window.removeEventListener('message', this._onHostMsg);
1393
+ window.removeEventListener('pagehide', this._flushView);
1394
+ this._flushView?.();
1395
+ this.overlay.close();
1396
+ for (const off of this._offs || []) off();
1397
+ this._offs = [];
1398
+ }
1399
+
1400
+ /** While moving DOM ourselves, do not read our own changes as the author's. */
1401
+ pauseObserving() {
1402
+ this._mo?.disconnect();
1403
+ }
1404
+ resumeObserving() {
1405
+ // takeRecords discards the batch we just produced; without it, resuming
1406
+ // observation fires again immediately.
1407
+ this._mo?.takeRecords?.();
1408
+ this._mo?.observe(this, {
1409
+ childList: true,
1410
+ subtree: true,
1411
+ attributeFilter: ['id', 'label', 'title'],
1412
+ });
1413
+ }
1414
+
1415
+ _render() {
1416
+ this.shadowRoot.innerHTML = `<style>${boxRule(this)}${CANVAS_CSS}</style>
1417
+ <div class="design-canvas${dcIsMobile ? ' dc-mobile' : ''}" part="frame" style="height:100vh;width:100vw;
1418
+ background:${DC.bg};overflow:hidden;overscroll-behavior:none;touch-action:none;
1419
+ position:relative;font-family:${DC.font};box-sizing:border-box">
1420
+ <div class="dc-world" part="world" style="position:absolute;top:0;left:0;transform-origin:0 0;
1421
+ will-change:transform;width:max-content;min-width:100%;min-height:100%;padding:60px 0 80px">
1422
+ <slot></slot>
1423
+ </div>
1424
+ <!-- Things that sit above the canvas without panning or zooming with it
1425
+ (panels and such) go through a named slot. The React version picked
1426
+ them out via a static dcOverlay marker on the component, which does not
1427
+ survive the custom-element boundary. -->
1428
+ <slot name="overlay"></slot>
1429
+ </div>`;
1430
+ this.vp = this.shadowRoot.querySelector('.design-canvas');
1431
+ this.world = this.shadowRoot.querySelector('.dc-world');
1432
+ // Mobile zooms by pinching, so the zoom control is not rendered. The host's
1433
+ // set-zoom message still works — the protocol is independent of the control.
1434
+ if (!dcIsMobile) this._renderZoom();
1435
+ }
1436
+
1437
+ // ── Zoom control (bottom right) ─────────────────────────
1438
+ _renderZoom() {
1439
+ const wrap = document.createElement('div');
1440
+ wrap.className = 'dc-zoom';
1441
+ wrap.setAttribute('data-miaoda-chrome', '');
1442
+ wrap.innerHTML = `
1443
+ <button type="button" class="dc-zoom-btn dc-zoom-in">
1444
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><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"/></svg>
1445
+ </button>
1446
+ <div class="dc-zoom-toggle">
1447
+ <button type="button" class="dc-zoom-btn dc-zoom-trigger">
1448
+ <span class="dc-zoom-pct">50%</span>
1449
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
1450
+ </button>
1451
+ </div>
1452
+ <button type="button" class="dc-zoom-btn dc-zoom-out">
1453
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><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"/></svg>
1454
+ </button>`;
1455
+ this.vp.appendChild(wrap);
1456
+ this._zoomWrap = wrap;
1457
+ // Swallow pointerdown so clicking the control does not start a background drag
1458
+ // (data-miaoda-chrome is the second line of defence).
1459
+ wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
1460
+ wrap.querySelector('.dc-zoom-in').addEventListener('click', () => {
1461
+ const maxPct = Math.round(this.maxScale * 100);
1462
+ this.zoomToPct(Math.min(maxPct, (Math.floor(this._pct() / 10) + 1) * 10));
1463
+ });
1464
+ wrap.querySelector('.dc-zoom-out').addEventListener('click', () => {
1465
+ const minPct = Math.round(this.minScale * 100);
1466
+ this.zoomToPct(Math.max(minPct, (Math.ceil(this._pct() / 10) - 1) * 10));
1467
+ });
1468
+ wrap
1469
+ .querySelector('.dc-zoom-trigger')
1470
+ .addEventListener('click', () => this._toggleZoomMenu());
1471
+ this._syncZoom();
1472
+ }
1473
+ _pct() {
1474
+ return Math.round(this.tf.scale * 100);
1475
+ }
1476
+ _syncZoom() {
1477
+ if (!this._zoomWrap) return;
1478
+ const pct = this._pct();
1479
+ const el = this._zoomWrap.querySelector('.dc-zoom-pct');
1480
+ if (el) el.textContent = pct + '%';
1481
+ this._zoomWrap.querySelector('.dc-zoom-in').disabled = pct >= Math.round(this.maxScale * 100);
1482
+ this._zoomWrap.querySelector('.dc-zoom-out').disabled =
1483
+ pct <= Math.round(this.minScale * 100);
1484
+ }
1485
+ _toggleZoomMenu() {
1486
+ const host = this._zoomWrap.querySelector('.dc-zoom-toggle');
1487
+ const open = host.querySelector('.dc-zoom-menu');
1488
+ if (open) {
1489
+ open.remove();
1490
+ document.removeEventListener('pointerdown', this._offZoomMenu, true);
1491
+ return;
1492
+ }
1493
+ const menu = document.createElement('div');
1494
+ menu.className = 'dc-menu dc-zoom-menu';
1495
+ menu.innerHTML = DC_ZOOM_PRESETS.map(
1496
+ (p) =>
1497
+ `<button type="button" data-pct="${p}"><span>${p}%</span>${
1498
+ p === this._pct()
1499
+ ? '<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><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"/></svg>'
1500
+ : ''
1501
+ }</button>`,
1502
+ ).join('');
1503
+ menu.addEventListener('pointerdown', (e) => e.stopPropagation());
1504
+ menu.addEventListener('click', (e) => {
1505
+ const p = e.target.closest('button')?.dataset.pct;
1506
+ if (!p) return;
1507
+ this.zoomToPct(Number(p));
1508
+ this._toggleZoomMenu();
1509
+ });
1510
+ host.appendChild(menu);
1511
+ this._offZoomMenu = (e) => {
1512
+ const inside = pathOf(e).some((n) => n === this._zoomWrap || this._zoomWrap.contains(n));
1513
+ if (!inside) this._toggleZoomMenu();
1514
+ };
1515
+ // Attach immediately, never deferred: the menu opens on click and this listens
1516
+ // for pointerdown, so it cannot close itself. Deferring would never fire under
1517
+ // fake timers, leaving the listener unattached and the menu permanently open.
1518
+ document.addEventListener('pointerdown', this._offZoomMenu, true);
1519
+ }
1520
+ zoomToPct(pct) {
1521
+ const r = this.vp.getBoundingClientRect();
1522
+ const target = Math.min(this.maxScale, Math.max(this.minScale, pct / 100));
1523
+ this.zoomAt(r.left + r.width / 2, r.top + r.height / 2, target / this.tf.scale);
1524
+ }
1525
+
1526
+ // ── Transform ───────────────────────────────────────────
1527
+ apply() {
1528
+ const { x, y, scale } = this.tf;
1529
+ if (!this.world) return;
1530
+ this.world.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
1531
+ // Consumed by the counter-scaled chrome (labels, buttons, section gaps)
1532
+ this.world.style.setProperty('--dc-inv-zoom', String(1 / scale));
1533
+ // The same variable also goes on the host element: sections and artboards read
1534
+ // it inside their own shadow roots, and custom properties inherit down the
1535
+ // light DOM — set on the world it would never reach children projected out
1536
+ // through a slot.
1537
+ this.style.setProperty('--dc-inv-zoom', String(1 / scale));
1538
+ if (!dcIsMobile) this._syncZoom();
1539
+ // A pan leaves scale unchanged — skip the cross-frame post
1540
+ if (this._lastPostedScale !== scale) {
1541
+ this._lastPostedScale = scale;
1542
+ try {
1543
+ window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
1544
+ } catch {}
1545
+ }
1546
+ // A replayed framing is the presenter's, not this viewer's — it must not
1547
+ // overwrite the viewport this end restores when it opens the canvas on its own
1548
+ // later. Any local gesture re-owns the view (and its persistence); apply() is
1549
+ // the single fan-out for both, so this distinction belongs here and nowhere
1550
+ // else.
1551
+ if (this._applyingRemoteView) {
1552
+ this._viewIsRemote = true;
1553
+ } else {
1554
+ this._viewIsRemote = false;
1555
+ clearTimeout(this._saveT);
1556
+ this._saveT = setTimeout(() => {
1557
+ try {
1558
+ localStorage.setItem(this._tfKey, JSON.stringify(this.tf));
1559
+ } catch {}
1560
+ }, 200);
1561
+ }
1562
+ // Magic Share: mirror the whole viewport (scale AND pan) as one snapshot,
1563
+ // trailing-throttled at 120ms rather than per call — apply() runs at pointer
1564
+ // rate and a per-frame cross-window post is too expensive. Snapshot semantics
1565
+ // make the dropped intermediate frames harmless.
1566
+ if (!dcCaptureMode && !this._applyingRemoteView && !this._mirrorEmitT) {
1567
+ this._mirrorEmitT = setTimeout(() => {
1568
+ this._mirrorEmitT = 0;
1569
+ window.__miaodaMirror.emit(
1570
+ {
1571
+ type: 'miaoda:canvas:viewport-changed',
1572
+ x: this.tf.x,
1573
+ y: this.tf.y,
1574
+ scale: this.tf.scale,
1575
+ },
1576
+ { key: 'canvas:viewport', throttle: 60 },
1577
+ );
1578
+ }, 120);
1579
+ }
1580
+ }
1581
+
1582
+ zoomAt(cx, cy, factor) {
1583
+ const r = this.vp.getBoundingClientRect();
1584
+ const px2 = cx - r.left,
1585
+ py = cy - r.top;
1586
+ const t = this.tf;
1587
+ const next = Math.min(this.maxScale, Math.max(this.minScale, t.scale * factor));
1588
+ const k = next / t.scale;
1589
+ /**
1590
+ * The consumers of --dc-inv-zoom (a section heading's calc()'d font metrics,
1591
+ * the gaps between sections) reflow after every scale change, so a world point
1592
+ * that is mathematically pinned under the cursor drifts anyway. Anchor the DOM
1593
+ * element under the cursor instead: record its screen Y, apply the transform,
1594
+ * then cancel whatever vertical drift the reflow introduced.
1595
+ */
1596
+ let marker = null,
1597
+ markerY0 = 0;
1598
+ if (k !== 1) {
1599
+ const hit = document.elementFromPoint(cx, cy);
1600
+ marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
1601
+ if (marker) markerY0 = marker.getBoundingClientRect().top;
1602
+ }
1603
+ t.x = px2 - (px2 - t.x) * k;
1604
+ t.y = py - (py - t.y) * k;
1605
+ t.scale = next;
1606
+ this.apply();
1607
+ if (marker) {
1608
+ const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
1609
+ if (Math.abs(drift) > 0.1) {
1610
+ t.y -= drift;
1611
+ this.apply();
1612
+ }
1613
+ }
1614
+ }
1615
+
1616
+ _restoreView() {
1617
+ this._flushView = () => {
1618
+ clearTimeout(this._saveT);
1619
+ // With a replayed framing on screen, persist nothing — the saved viewport must
1620
+ // stay this viewer's own.
1621
+ if (this._viewIsRemote) return;
1622
+ try {
1623
+ localStorage.setItem(this._tfKey, JSON.stringify(this.tf));
1624
+ } catch {}
1625
+ };
1626
+ try {
1627
+ const s = JSON.parse(localStorage.getItem(this._tfKey) || 'null');
1628
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
1629
+ this.tf = {
1630
+ x: s.x,
1631
+ y: s.y,
1632
+ scale: Math.min(this.maxScale, Math.max(this.minScale, s.scale)),
1633
+ };
1634
+ this._restoredView = true;
1635
+ }
1636
+ } catch {}
1637
+ this.apply();
1638
+ window.addEventListener('pagehide', this._flushView);
1639
+ }
1640
+
1641
+ /**
1642
+ * First-visit fit: scale and centre the whole canvas with at least PAD of safe
1643
+ * margin on every side. Skipped when a viewport was restored, so a returning
1644
+ * viewer keeps their own view.
1645
+ */
1646
+ fitToContent() {
1647
+ if (this._didFit || this._restoredView) return;
1648
+ const vp = this.vp,
1649
+ world = this.world;
1650
+ if (!vp || !world) return;
1651
+ const slots = this.querySelectorAll('[data-dc-slot]');
1652
+ if (!slots.length) return; // content has not mounted yet — wait for the next pass
1653
+ // The React version fits inside useLayoutEffect, after the whole tree has
1654
+ // committed. Here rebuild() also runs from connectedCallback, before the
1655
+ // descendant custom elements have upgraded one by one, and the bounding box
1656
+ // then misses the headings that have not appeared yet — which is exactly what
1657
+ // made the opening framing jitter between loads. Do not fit until every
1658
+ // heading is present; the next MutationObserver pass will come.
1659
+ const secs = [...this.querySelectorAll('dc-section')];
1660
+ const heads = secs.map((s) => s.shadowRoot?.querySelector('.dc-sectionhead')).filter(Boolean);
1661
+ if (heads.length < secs.length) return;
1662
+ // Mobile screens are narrow — 64px a side eats a third of the width, so fit
1663
+ // flush-ish at 16px there.
1664
+ const PAD = dcIsMobile ? 16 : 64;
1665
+
1666
+ const measure = () => {
1667
+ const vpr = vp.getBoundingClientRect();
1668
+ const t = this.tf;
1669
+ let minX = Infinity,
1670
+ minY = Infinity,
1671
+ maxX = -Infinity,
1672
+ maxY = -Infinity;
1673
+ slots.forEach((el) => {
1674
+ const r = el.getBoundingClientRect();
1675
+ minX = Math.min(minX, (r.left - vpr.left - t.x) / t.scale);
1676
+ maxX = Math.max(maxX, (r.right - vpr.left - t.x) / t.scale);
1677
+ minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
1678
+ maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
1679
+ });
1680
+ heads.forEach((el) => {
1681
+ const r = el.getBoundingClientRect();
1682
+ minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
1683
+ maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
1684
+ });
1685
+ return { vpr, minX, minY, w: maxX - minX, h: maxY - minY };
1686
+ };
1687
+
1688
+ /**
1689
+ * Content SCREEN width is linear in scale (screenW = s·Wworld) and SCREEN
1690
+ * height is affine (screenH = s·P + Q): headings and inter-section gaps
1691
+ * counter-scale with zoom, holding a constant screen size while cards scale.
1692
+ * Sample two scales to solve the coefficients, then take the largest scale that
1693
+ * keeps both axes inside the viewport minus PAD. Exact — no iterate-to-converge.
1694
+ */
1695
+ const s1 = this.tf.scale;
1696
+ const m1 = measure();
1697
+ if (!(m1.w > 0) || !(m1.h > 0)) {
1698
+ this._didFit = true;
1699
+ return;
1700
+ }
1701
+ const s2 = s1 * 0.7;
1702
+ this.tf.scale = s2;
1703
+ this.apply();
1704
+ const m2 = measure();
1705
+
1706
+ const { vpr } = m1;
1707
+ const avW = vpr.width - PAD * 2,
1708
+ avH = vpr.height - PAD * 2;
1709
+ const Wworld = m1.w;
1710
+ const P = (s1 * m1.h - s2 * m2.h) / (s1 - s2);
1711
+ const Q = s1 * m1.h - s1 * P;
1712
+ const fitW = avW / Wworld;
1713
+ const fitH = P > 0 ? (avH - Q) / P : Infinity;
1714
+ const next = Math.max(this.minScale, Math.min(this.maxScale, Math.min(fitW, fitH)));
1715
+
1716
+ // Measure again at the solved scale and centre from that, so the margins come
1717
+ // out symmetric.
1718
+ this.tf.scale = next;
1719
+ this.apply();
1720
+ const mf = measure();
1721
+ this.tf.x = (vpr.width - mf.w * next) / 2 - mf.minX * next;
1722
+ this.tf.y = (vpr.height - mf.h * next) / 2 - mf.minY * next;
1723
+ this.apply();
1724
+ this._didFit = true;
1725
+ }
1726
+
1727
+ // ── Gestures ────────────────────────────────────────────
1728
+ _bindGestures() {
1729
+ const vp = this.vp;
1730
+ this._offs = [];
1731
+ const on = (t, h, o) => {
1732
+ vp.addEventListener(t, h, o);
1733
+ this._offs.push(() => vp.removeEventListener(t, h, o));
1734
+ };
1735
+
1736
+ /**
1737
+ * Zoom is keyed on modifiers only: browsers set ctrlKey for a trackpad pinch,
1738
+ * and Ctrl/⌘+wheel is an explicit mouse zoom gesture; an unmodified wheel always
1739
+ * pans. That is Figma's mapping, and it sidesteps a classification that cannot
1740
+ * be made reliably — the peak frame of a fast trackpad flick and one mouse
1741
+ * wheel notch are indistinguishable at the event level.
1742
+ * isNotchedWheel now only picks the zoom STEP, so getting it wrong changes
1743
+ * granularity and can never flip pan into zoom.
1744
+ */
1745
+ const isNotchedWheel = (e) =>
1746
+ e.deltaMode !== 0 ||
1747
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
1748
+
1749
+ let isGesturing = false;
1750
+ let gsBase = 1;
1751
+
1752
+ on(
1753
+ 'wheel',
1754
+ (e) => {
1755
+ e.preventDefault();
1756
+ if (isGesturing) return; // Safari: gesture* owns this pinch — drop concurrent wheels
1757
+ if (e.ctrlKey || e.metaKey) {
1758
+ this.zoomAt(
1759
+ e.clientX,
1760
+ e.clientY,
1761
+ isNotchedWheel(e)
1762
+ ? Math.exp(-Math.sign(e.deltaY) * 0.18)
1763
+ : Math.exp(-e.deltaY * 0.01),
1764
+ );
1765
+ } else {
1766
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? vp.clientHeight : 1;
1767
+ this.tf.x -= e.deltaX * unit;
1768
+ this.tf.y -= e.deltaY * unit;
1769
+ this.apply();
1770
+ }
1771
+ },
1772
+ { passive: false },
1773
+ );
1774
+
1775
+ // Safari routes trackpad pinches through native gesture* events, where e.scale
1776
+ // is continuous and feels far better than the ctrl+wheel fallback.
1777
+ // Two-finger pinch on mobile is already handled by the pointer state machine
1778
+ // below, so here we only preventDefault — otherwise it would zoom twice.
1779
+ on(
1780
+ 'gesturestart',
1781
+ (e) => {
1782
+ e.preventDefault();
1783
+ isGesturing = true;
1784
+ gsBase = this.tf.scale;
1785
+ },
1786
+ { passive: false },
1787
+ );
1788
+ on(
1789
+ 'gesturechange',
1790
+ (e) => {
1791
+ e.preventDefault();
1792
+ if (dcIsMobile) return;
1793
+ this.zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / this.tf.scale);
1794
+ },
1795
+ { passive: false },
1796
+ );
1797
+ on(
1798
+ 'gestureend',
1799
+ (e) => {
1800
+ e.preventDefault();
1801
+ isGesturing = false;
1802
+ },
1803
+ { passive: false },
1804
+ );
1805
+
1806
+ // Desktop: middle-drag anywhere, or left-drag on the canvas background — not on
1807
+ // an artboard, an inline editor, or chrome.
1808
+ let drag = null;
1809
+ const onPointerDown = (e) => {
1810
+ const onBg = !pathOf(e).some(
1811
+ (n) =>
1812
+ n.matches &&
1813
+ (n.matches('[data-dc-slot]') ||
1814
+ n.matches('.dc-editable') ||
1815
+ n.matches('[data-miaoda-chrome]')),
1816
+ );
1817
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
1818
+ e.preventDefault();
1819
+ try {
1820
+ this.setPointerCapture(e.pointerId);
1821
+ } catch {}
1822
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
1823
+ vp.style.cursor = 'grabbing';
1824
+ };
1825
+ const onPointerMove = (e) => {
1826
+ if (!drag || e.pointerId !== drag.id) return;
1827
+ this.tf.x += e.clientX - drag.lx;
1828
+ this.tf.y += e.clientY - drag.ly;
1829
+ drag.lx = e.clientX;
1830
+ drag.ly = e.clientY;
1831
+ this.apply();
1832
+ };
1833
+ const onPointerUp = (e) => {
1834
+ if (!drag || e.pointerId !== drag.id) return;
1835
+ try {
1836
+ this.releasePointerCapture(e.pointerId);
1837
+ } catch {}
1838
+ drag = null;
1839
+ vp.style.cursor = '';
1840
+ };
1841
+
1842
+ /**
1843
+ * Mobile gestures, replacing the desktop mapping above: one finger pans — cards
1844
+ * fill a phone viewport and background is scarce, so a drag that starts on an
1845
+ * artboard counts too. Two fingers pinch to zoom about their midpoint, and the
1846
+ * midpoint's own movement pans at the same time (Figma semantics: one gesture
1847
+ * frames the view). The tap/pan slop keeps label taps working.
1848
+ * No inertial flick: motion stops when the fingers lift.
1849
+ */
1850
+ const touches = new Map();
1851
+ let mobMode = null;
1852
+ let tapStart = null;
1853
+ let pinchDist = 0;
1854
+ let pinchMid = null;
1855
+ let panApplied = null;
1856
+ const MOB_SLOP = 9;
1857
+ const pinchGeom = () => {
1858
+ const [a, b] = [...touches.values()];
1859
+ return { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
1860
+ };
1861
+ let mobRaf = 0;
1862
+ const mobFlushFrame = () => {
1863
+ mobRaf = 0;
1864
+ if (mobMode === 'pan') {
1865
+ const [t] = [...touches.values()];
1866
+ if (!t) return;
1867
+ this.tf.x += t.x - panApplied.x;
1868
+ this.tf.y += t.y - panApplied.y;
1869
+ panApplied = { x: t.x, y: t.y };
1870
+ this.apply();
1871
+ } else if (mobMode === 'pinch' && touches.size >= 2) {
1872
+ const g = pinchGeom();
1873
+ this.tf.x += g.mx - pinchMid.x;
1874
+ this.tf.y += g.my - pinchMid.y;
1875
+ pinchMid = { x: g.mx, y: g.my };
1876
+ if (pinchDist > 0 && g.d > 0) this.zoomAt(g.mx, g.my, g.d / pinchDist);
1877
+ else this.apply();
1878
+ pinchDist = g.d;
1879
+ }
1880
+ };
1881
+ // Coalesce into rAF: move handlers only record positions, and geometry is
1882
+ // applied at most once per frame. pointermove fires well above the render rate
1883
+ // (120Hz sampling, fast pinches), and running zoomAt on every event produces
1884
+ // garbage faster than mobile WebKit collects it — which shows up as unbounded
1885
+ // memory growth. Only the last position in a frame is ever painted anyway.
1886
+ const mobSchedule = () => {
1887
+ if (!mobRaf) mobRaf = requestAnimationFrame(mobFlushFrame);
1888
+ };
1889
+ const mobFlushNow = () => {
1890
+ if (mobRaf) {
1891
+ cancelAnimationFrame(mobRaf);
1892
+ mobFlushFrame();
1893
+ }
1894
+ };
1895
+ const onTouchDown = (e) => {
1896
+ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
1897
+ if (touches.size === 1) {
1898
+ mobMode = 'tap';
1899
+ tapStart = { x: e.clientX, y: e.clientY };
1900
+ } else if (touches.size === 2) {
1901
+ mobFlushNow();
1902
+ mobMode = 'pinch';
1903
+ const g = pinchGeom();
1904
+ pinchDist = g.d;
1905
+ pinchMid = { x: g.mx, y: g.my };
1906
+ try {
1907
+ vp.setPointerCapture(e.pointerId);
1908
+ } catch {}
1909
+ }
1910
+ };
1911
+ const onTouchMove = (e) => {
1912
+ const t = touches.get(e.pointerId);
1913
+ if (!t) return;
1914
+ t.x = e.clientX;
1915
+ t.y = e.clientY;
1916
+ if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
1917
+ mobMode = 'pan';
1918
+ panApplied = { x: t.x, y: t.y };
1919
+ try {
1920
+ vp.setPointerCapture(e.pointerId);
1921
+ } catch {}
1922
+ }
1923
+ if (mobMode === 'pan' || mobMode === 'pinch') mobSchedule();
1924
+ };
1925
+ const onTouchUp = (e) => {
1926
+ mobFlushNow();
1927
+ if (!touches.delete(e.pointerId)) return;
1928
+ try {
1929
+ vp.releasePointerCapture(e.pointerId);
1930
+ } catch {}
1931
+ if (touches.size === 0) {
1932
+ mobMode = null;
1933
+ } else if (touches.size === 1) {
1934
+ // A pinch collapsing to one finger: rebase on the surviving finger and take
1935
+ // over panning without a jump.
1936
+ mobMode = 'pan';
1937
+ const [t] = [...touches.values()];
1938
+ panApplied = { x: t.x, y: t.y };
1939
+ } else if (mobMode === 'pinch') {
1940
+ const g = pinchGeom();
1941
+ pinchDist = g.d;
1942
+ pinchMid = { x: g.mx, y: g.my };
1943
+ }
1944
+ };
1945
+
1946
+ const pd = dcIsMobile ? onTouchDown : onPointerDown;
1947
+ const pm = dcIsMobile ? onTouchMove : onPointerMove;
1948
+ const pu = dcIsMobile ? onTouchUp : onPointerUp;
1949
+ const onHost = (t, h) => {
1950
+ this.addEventListener(t, h);
1951
+ this._offs.push(() => this.removeEventListener(t, h));
1952
+ };
1953
+ onHost('pointerdown', pd);
1954
+ onHost('pointermove', pm);
1955
+ onHost('pointerup', pu);
1956
+ onHost('pointercancel', pu);
1957
+ this._offs.push(() => {
1958
+ if (mobRaf) cancelAnimationFrame(mobRaf);
1959
+ });
1960
+ }
1961
+
1962
+ // ── Host protocol ───────────────────────────────────────
1963
+ _bindHost() {
1964
+ this._onHostMsg = (e) => {
1965
+ const d = e.data;
1966
+ if (d && d.type === 'miaoda:canvas:set-zoom' && typeof d.scale === 'number') {
1967
+ const r = this.vp.getBoundingClientRect();
1968
+ this.zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / this.tf.scale);
1969
+ } else if (d && d.type === 'miaoda:canvas:probe') {
1970
+ // The host's readyGen reset asks "is there a canvas?" on the iframe's native
1971
+ // load event, which for a canvas with images or fonts happens after our
1972
+ // mount-time announce — so announce again.
1973
+ try {
1974
+ window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
1975
+ window.parent.postMessage(
1976
+ { type: 'miaoda:upgrade:available', kind: 'design-canvas' },
1977
+ '*',
1978
+ );
1979
+ } catch {}
1980
+ this._lastPostedScale = undefined;
1981
+ this.apply();
1982
+ }
1983
+ };
1984
+ window.addEventListener('message', this._onHostMsg);
1985
+ try {
1986
+ window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
1987
+ window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*');
1988
+ } catch {}
1989
+ this._lastPostedScale = undefined;
1990
+ }
1991
+
1992
+ // ── Sidecar persistence ─────────────────────────────────
1993
+ /**
1994
+ * Reads go through a plain fetch, so a saved arrangement shows up anywhere the
1995
+ * HTML and its sidecar are served together (preview, direct link, downloaded zip).
1996
+ * Writes hand the content back to the host over postMessage — editing needs the
1997
+ * miaoda runtime.
1998
+ */
1999
+ _readSidecar() {
2000
+ // Backstop: render even if the read hangs — the canvas must not stay blank
2001
+ this._readTimer = setTimeout(() => {
2002
+ if (!this._ready) {
2003
+ this._ready = true;
2004
+ this.rebuild();
2005
+ }
2006
+ }, 150);
2007
+ fetch('./' + DC_STATE_FILE)
2008
+ .then((r) => (r.ok ? r.json() : null))
2009
+ .then((saved) => {
2010
+ // A late joiner's replay races this fetch, and the replay is the fresher
2011
+ // truth (it IS the presenter's current arrangement, and it was already
2012
+ // answered 'applied' so the host will not resend) — a slower sidecar must
2013
+ // not clobber it.
2014
+ if (!saved || !saved.sections || this._mirrorSeeded) return;
2015
+ // The write that immediately follows hydration is an echo — skip it, or the
2016
+ // empty initial state overwrites the slower read.
2017
+ this._skipNextWrite = true;
2018
+ this.state.sections = saved.sections;
2019
+ this.rebuild();
2020
+ // Hydration is itself an arrangement settling, so it must be broadcast.
2021
+ // _didRead is normally raised in finally; raise it early here or the guard
2022
+ // at the top of _writeSidecar would swallow this broadcast.
2023
+ this._didRead = true;
2024
+ this._writeSidecar();
2025
+ })
2026
+ .catch(() => {})
2027
+ .finally(() => {
2028
+ this._didRead = true;
2029
+ clearTimeout(this._readTimer);
2030
+ if (!this._ready) {
2031
+ this._ready = true;
2032
+ this.rebuild();
2033
+ }
2034
+ });
2035
+ }
2036
+ _writeSidecar() {
2037
+ if (!this._didRead) return;
2038
+ // Magic Share: broadcast the full snapshot whenever the arrangement settles,
2039
+ // hydration included — that is exactly the baseline a late joiner needs.
2040
+ // Only a remote replay must not re-broadcast, or the two ends trigger each
2041
+ // other in an echo.
2042
+ if (this._skipNextMirrorEmit) this._skipNextMirrorEmit = false;
2043
+ else if (!dcCaptureMode)
2044
+ window.__miaodaMirror.emit(
2045
+ { type: 'miaoda:canvas:structure-changed', sections: this.state.sections },
2046
+ { key: 'canvas:structure' },
2047
+ );
2048
+ if (this._skipNextWrite) {
2049
+ this._skipNextWrite = false;
2050
+ return;
2051
+ }
2052
+ clearTimeout(this._writeT);
2053
+ this._writeT = setTimeout(() => {
2054
+ miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: this.state.sections })).catch(
2055
+ () => {},
2056
+ );
2057
+ }, 250);
2058
+ }
2059
+ patchSection(id, patch) {
2060
+ const cur = this.state.sections[id] || {};
2061
+ this.state.sections = {
2062
+ ...this.state.sections,
2063
+ [id]: { ...cur, ...(typeof patch === 'function' ? patch(cur) : patch) },
2064
+ };
2065
+ this.rebuild();
2066
+ this._writeSidecar();
2067
+ }
2068
+ setFocus(slotId) {
2069
+ if (this.state.focus === slotId) return;
2070
+ this.overlay.restore();
2071
+ this.state.focus = slotId;
2072
+ if (slotId) this.overlay.open(slotId);
2073
+ else this.overlay.close();
2074
+ // Magic Share: focus is ephemeral view state, mirrored under its own key so a
2075
+ // follower enters and leaves the focus overlay in step with the presenter.
2076
+ // Leaving broadcasts null — the empty value is itself a state.
2077
+ if (this._skipNextFocusEmit) this._skipNextFocusEmit = false;
2078
+ else if (!dcCaptureMode)
2079
+ window.__miaodaMirror.emit(
2080
+ { type: 'miaoda:canvas:focus-changed', focus: slotId || null },
2081
+ { key: 'canvas:focus' },
2082
+ );
2083
+ // The host and author scripts need to observe focus changes, and only a
2084
+ // composed event crosses the shadow boundary.
2085
+ this.dispatchEvent(
2086
+ new CustomEvent('dcfocuschange', { detail: { slotId }, bubbles: true, composed: true }),
2087
+ );
2088
+ }
2089
+
2090
+ _bindChildEvents() {
2091
+ this.addEventListener('dc:rename-section', (e) => {
2092
+ e.stopPropagation();
2093
+ this.patchSection(e.detail.sectionId, { title: e.detail.value });
2094
+ });
2095
+ this.addEventListener('dc:rename-label', (e) => {
2096
+ e.stopPropagation();
2097
+ const sec = e.target.closest('dc-section');
2098
+ if (!sec) return;
2099
+ const k = e.detail.slotId;
2100
+ this.patchSection(sec.sectionId, (x) => ({ labels: { ...x.labels, [k]: e.detail.value } }));
2101
+ });
2102
+ this.addEventListener('dc:reorder', (e) => {
2103
+ e.stopPropagation();
2104
+ const sec = e.target.closest('dc-section');
2105
+ if (sec) this.patchSection(sec.sectionId, { order: e.detail.order });
2106
+ });
2107
+ this.addEventListener('dc:delete', (e) => {
2108
+ e.stopPropagation();
2109
+ const sec = e.target.closest('dc-section');
2110
+ if (!sec) return;
2111
+ const srcKey = sec.srcOrder().join('\x1f');
2112
+ const k = e.detail.slotId;
2113
+ this.patchSection(sec.sectionId, (x) => ({
2114
+ hidden: [...(x.srcKey === srcKey ? x.hidden || [] : []), k],
2115
+ srcKey,
2116
+ }));
2117
+ });
2118
+ this.addEventListener('dc:focus', (e) => {
2119
+ e.stopPropagation();
2120
+ const sec = e.target.closest('dc-section');
2121
+ if (sec) this.setFocus(`${sec.sectionId}/${e.detail.slotId}`);
2122
+ });
2123
+ }
2124
+
2125
+ /**
2126
+ * Rebuild the registry from the light DOM children and push state down to each
2127
+ * section.
2128
+ * `hidden` only applies to the same source content: once the content is
2129
+ * regenerated (the set of artboard IDs changes), earlier deletions must not carry
2130
+ * over to the new content.
2131
+ */
2132
+ rebuild() {
2133
+ this.registry = {};
2134
+ this.sectionMeta = {};
2135
+ this.sectionOrder = [];
2136
+ for (const sec of this.querySelectorAll(':scope > dc-section')) {
2137
+ const sid = sec.sectionId;
2138
+ if (!sid) continue;
2139
+ this.sectionOrder.push(sid);
2140
+ const persisted = this.state.sections[sid] || {};
2141
+ const allIds = sec.srcOrder();
2142
+ const srcKey = allIds.join('\x1f');
2143
+ const hidden = persisted.srcKey === srcKey ? persisted.hidden || [] : [];
2144
+ const srcIds = allIds.filter((k) => !hidden.includes(k));
2145
+ for (const ab of sec.querySelectorAll(':scope > dc-artboard')) {
2146
+ if (!srcIds.includes(ab.slotId)) continue;
2147
+ this.registry[`${sid}/${ab.slotId}`] = { sectionId: sid, artboard: ab };
2148
+ }
2149
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
2150
+ const meta = {
2151
+ ready: this._ready,
2152
+ title: persisted.title ?? sec.getAttribute('title'),
2153
+ labels: persisted.labels || {},
2154
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
2155
+ };
2156
+ this.sectionMeta[sid] = meta;
2157
+ sec.applyState(meta);
2158
+ }
2159
+ // If the focused artboard is gone (deleted, or content regenerated), leave focus.
2160
+ // This is a LOCAL correction, not a user action, so it must not broadcast: in a
2161
+ // Magic Share session one follower whose content lacks the artboard would
2162
+ // otherwise push focus:null back and kick the presenter out of the overlay.
2163
+ // The React version has no equivalent broadcast — it simply declines to render
2164
+ // the overlay for an unknown id and leaves its own state alone.
2165
+ if (this.state.focus && !this.registry[this.state.focus]) {
2166
+ this._skipNextFocusEmit = true;
2167
+ this.setFocus(null);
2168
+ // setFocus bails out on an unchanged value without emitting, which would leave
2169
+ // the guard raised and swallow the next real broadcast.
2170
+ this._skipNextFocusEmit = false;
2171
+ }
2172
+ // Fit as soon as the content is in place, synchronously, matching React's
2173
+ // useLayoutEffect. Deferring to rAF is one tick late and flashes an unfitted
2174
+ // first frame.
2175
+ this.fitToContent();
2176
+ }
2177
+ }
2178
+
2179
+ // A duplicate define throws — copies of this file can coexist on one page, so the
2180
+ // guard is mandatory.
2181
+ for (const [tag, ctor] of [
2182
+ ['dc-postit', DCPostIt],
2183
+ ['dc-artboard', DCArtboard],
2184
+ ['dc-section', DCSection],
2185
+ ['design-canvas', DesignCanvas],
2186
+ ]) {
2187
+ if (!customElements.get(tag)) customElements.define(tag, ctor);
2188
+ }
2189
+ })();