@lark-apaas/coding-registry-buildless 0.1.23 → 0.1.24

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,2349 @@
1
+ /* BEGIN USAGE */
2
+ /**
3
+ * <card-stage> — canvas for social image cards (Xiaohongshu / RED, WeChat Channels,
4
+ * WeChat official accounts).
5
+ *
6
+ * Every card is a <section> that is a DIRECT child of <card-stage>. The component
7
+ * sizes each section to the frame (3:4 means 1080×1440); the section IS the card
8
+ * face — background, padding and display:flex all go on the section itself. Never
9
+ * nest another "card" div inside it, and never wrap it in a container. If the page
10
+ * loads the <script> without a <card-stage> tag, the component does nothing and the
11
+ * sections just sit on the page at their natural size.
12
+ *
13
+ * <style>card-stage { visibility: hidden; }</style>
14
+ * <script src="card-stage.js"></script>
15
+ * <card-stage ratio="3:4">
16
+ * <section data-label="封面">…</section>
17
+ * <section data-label="规格">…</section>
18
+ * </card-stage>
19
+ *
20
+ * Do not drop that style line in <head>: while the script is still downloading the
21
+ * artifact renders unstyled and flashes one frame of full-size cards. The component
22
+ * lifts the rule itself — see data-cs-ready for when.
23
+ *
24
+ * Desktop: a pan/zoom canvas. Above each card sits an editable label, a "⋯" menu
25
+ * (download / copy image / delete) and an expand button; the bottom-right zoom pill
26
+ * only zooms. Clicking a card opens a fullscreen focus overlay (prev/next, download,
27
+ * copy image). Deletes and renames go to a sidecar and survive a reload.
28
+ * Batch export has NO entry point in the artifact — the host drives it by calling
29
+ * stage.downloadAll().
30
+ * Mobile (UA-detected) switches to a different browsing mode entirely: one card per
31
+ * screen, swipe to page, centred page dots at the bottom plus copy / download buttons
32
+ * bottom-right (in-app WebViews on Android get download only). Authors write no
33
+ * mobile-specific code.
34
+ * Cards are not drag-reorderable: publish order is source order, so reorder the source.
35
+ * Only two things differ from design-canvas, both because these are cards:
36
+ * 1. Card content is static <section> markup that literally exists in the document,
37
+ * not JSX children — the editor only recognises HTML elements in the source, and
38
+ * that is what makes direct copy edits stick.
39
+ * 2. Export scale is fixed at 1: cards are already authored at upload sizes like
40
+ * 1080×1440, so 1× IS the exact size.
41
+ *
42
+ * Frame: ratio accepts any "W:H". The short edge is always 1080 (matching upload
43
+ * specs) and the long edge follows the ratio, capped at 4096.
44
+ * Common presets: "3:4" (default) 1080×1440 · "9:16" 1080×1920 · "1:1" 1080×1080 ·
45
+ * "4:5" 1080×1350 · "16:9" 1920×1080. width / height also accept raw pixels.
46
+ * An unparseable ratio or an over-cap long edge logs console.error and falls back
47
+ * to 3:4 (preflight fails on it).
48
+ *
49
+ * card-stage itself is the world; the transform is applied to it. Wrap a section in
50
+ * anything and the author's own `card-stage > section { display:flex; padding }` stops
51
+ * matching; nest anything inside a section and flex:1 / margin-top:auto lose their
52
+ * reference — both show up as every card's content filling only the top half.
53
+ * Card headers are absolutely-positioned siblings aligned by offset; they take no part
54
+ * in layout.
55
+ *
56
+ * Frame dimensions are exposed per card as --cs-w / --cs-h — read them for anything
57
+ * that has to be computed from the frame (full-bleed image height and the like). For
58
+ * the top and bottom edges just use the fixed --edge (~120px); no arithmetic needed.
59
+ * Never use percentage padding for vertical spacing — it resolves against WIDTH.
60
+ *
61
+ * Content past the frame is silently clipped by overflow:hidden, so the component
62
+ * measures it itself and console.errors which card overflowed and by how many px
63
+ * (preflight collects console errors, so overflow fails the check). ?guides=1 also
64
+ * outlines it in red with the measurement.
65
+ *
66
+ * ?guides=1 show safe-area guides and overflow markers (self-check only, off by default)
67
+ * ?export=1&card=N render card N alone, 1:1, with no extra chrome, for screenshot tools
68
+ * stage.renderCard(n) resolve to that card's PNG Blob (1-indexed, no download)
69
+ * stage.downloadCard(n) / stage.downloadAll() save that card, named after its label
70
+ * Desktop and ordinary mobile browsers use <a download>; only in-app WebViews take a
71
+ * detour (share sheet → long-press save layer), because there a blob: handed to the
72
+ * host is rejected as a 0-byte download. A false return means the image was only
73
+ * handed to the user and not actually saved — do not report success.
74
+ * stage.copyCard(n) copy that card to the clipboard (the host iframe needs
75
+ * allow="clipboard-write").
76
+ * Images cannot reach the system clipboard inside Android in-app WebViews, so the
77
+ * mobile bar hides this button there; calling it directly shows
78
+ * 「复制不可用,请使用下载」.
79
+ */
80
+ /* END USAGE */
81
+
82
+ (() => {
83
+ /** Shorthands for the common frames; anything else is computed by parseRatio — this is not an allowlist */
84
+ const RATIOS = {
85
+ '3:4': [1080, 1440],
86
+ '9:16': [1080, 1920],
87
+ '1:1': [1080, 1080],
88
+ '4:5': [1080, 1350],
89
+ '16:9': [1920, 1080],
90
+ };
91
+ const SHORT_EDGE = 1080;
92
+ const MAX_EDGE = 4096;
93
+
94
+ /** "W:H" → [width, height], short edge pinned to SHORT_EDGE. Returns null if unparseable or the long edge exceeds the cap */
95
+ const parseRatio = (str) => {
96
+ if (RATIOS[str]) return RATIOS[str];
97
+ const m = /^\s*(\d+(?:\.\d+)?)\s*[::/]\s*(\d+(?:\.\d+)?)\s*$/.exec(str || '');
98
+ if (!m) return null;
99
+ const w = Number(m[1]);
100
+ const h = Number(m[2]);
101
+ if (!(w > 0) || !(h > 0)) return null;
102
+ const long = Math.round((SHORT_EDGE * Math.max(w, h)) / Math.min(w, h));
103
+ if (long > MAX_EDGE) return null;
104
+ return w <= h ? [SHORT_EDGE, long] : [long, SHORT_EDGE];
105
+ };
106
+
107
+ const STYLE_ID = 'card-stage-style';
108
+ const STATE_FILE = '.card-stage.state.json';
109
+
110
+ // Host write-file bridge, same protocol as design-canvas.jsx. Deletes and renames
111
+ // have to survive a reload — a card that comes back after being deleted is worse than
112
+ // not offering the action at all. Reads go over a plain fetch, so anywhere the artifact
113
+ // is served alongside its sidecar (preview, direct link, downloaded zip) sees the
114
+ // saved result.
115
+ function hostWriteFile(path, content) {
116
+ try {
117
+ window.parent.postMessage({ type: 'miaoda:bridge:write-file', path, content }, '*');
118
+ } catch { /* No host: the change still applies for this session, it just is not persisted */ }
119
+ }
120
+ // Mirrors design-canvas.jsx / deck-stage.js mobile detection. The product
121
+ // iframe can be narrow on desktop, so this is UA-based rather than viewport-based.
122
+ const CS_MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
123
+ const csIsMobile = typeof navigator !== 'undefined' &&
124
+ (CS_MOBILE_UA_RE.test(navigator.userAgent) ||
125
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
126
+ // Feishu/Lark in-app browser. The one place UA sniffing is unavoidable: <a download>
127
+ // feature-detects fine everywhere, but a WebView hands the download to its host, the
128
+ // host cannot read blob:, and that only surfaces as a failure on the device.
129
+ const csInAppWebView = typeof navigator !== 'undefined' && /Lark|Feishu/i.test(navigator.userAgent);
130
+ // WebKit (every iOS browser is a WKWebView). Used only to pick the clipboard call — see copyCard.
131
+ const csIsWebKit = typeof navigator !== 'undefined' &&
132
+ (/iP(hone|ad|od)/i.test(navigator.userAgent) ||
133
+ (/Macintosh/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
134
+ // Mobile Blink in-app WebViews (Android Feishu and friends): images cannot reach the
135
+ // system clipboard at all, so the mobile bar omits the copy button there. Download has
136
+ // its own detour (share sheet → long-press save layer) and stays.
137
+ // iOS WKWebView pasteboard handles image/png fine (verified on device) — not included.
138
+ const csAndroidInAppWebView = csIsMobile && csInAppWebView && !csIsWebKit;
139
+ const CSS = `
140
+ /* Nothing paints until layout is done: init awaits loadState() (a real fetch inside
141
+ the host iframe), and until then the cards are laid out at 1× with no zoom computed
142
+ yet, which flashes one frame of full-size cards.
143
+ The export path has its own fallback (renderCardPng forces the clone root visible),
144
+ so do not depend on this rule's timing. */
145
+ html:not([data-cs-ready]) card-stage { visibility: hidden; }
146
+ /* Lifting it has to outrank the head style from the usage snippet — that author rule
147
+ carries no real specificity, but !important is the safe way out */
148
+ html[data-cs-ready] card-stage { visibility: visible !important; }
149
+ html[data-cs-canvas], html[data-cs-canvas] body { height: 100%; margin: 0; }
150
+ html[data-cs-canvas] body { overflow: hidden; background: #FCFCFC; }
151
+
152
+ /* Mobile browsing mode (UA-detected): one card per screen, swipe to page, no editing
153
+ chrome rendered at all.
154
+ The default touch-action inside card content swallows the horizontal swipe, so it has
155
+ to be disabled everywhere. */
156
+ html[data-cs-mobile] body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; background: #101114; }
157
+ html[data-cs-mobile] body, html[data-cs-mobile] card-stage, html[data-cs-mobile] card-stage * { touch-action: none; }
158
+ /* Single row: the neighbouring cards peek in, which is what hints that a swipe has
159
+ somewhere to go */
160
+ html[data-cs-mobile] card-stage { flex-wrap: nowrap; align-items: center; row-gap: 0; }
161
+ /* One card per screen on mobile, full-bleed to the edges: no stroke, shadow or radius */
162
+ html[data-cs-mobile] card-stage > section { box-shadow: none !important; outline: none; border-radius: 0; }
163
+
164
+ card-stage {
165
+ /* The world is the canvas, not part of the artifact. A background the author puts on
166
+ card-stage would also paint the 120px padding and the inter-card gaps, which reads
167
+ as a stray backdrop around the artifact. This rule only covers the moment before
168
+ the script runs; what actually beats author rules is the inline important set in
169
+ init. */
170
+ background: transparent !important;
171
+ --safe-inset: 12.5%;
172
+ --cs-gap: 120px;
173
+ position: absolute;
174
+ top: 0; left: 0;
175
+ transform-origin: 0 0;
176
+ display: flex;
177
+ flex-wrap: wrap;
178
+ align-items: flex-start;
179
+ /* Row gap has to clear the counter-scaled card headers: the further out you zoom,
180
+ the more world height a header occupies */
181
+ column-gap: var(--cs-gap);
182
+ row-gap: calc(var(--cs-gap) * 1.8);
183
+ padding: var(--cs-gap);
184
+ will-change: transform;
185
+ }
186
+ card-stage > section {
187
+ /* Artboard stroke. outline, not border: a border joins layout and would squeeze a
188
+ 1080×1440 frame down to 1078×1438. outline takes no space, and the export path
189
+ clears it anyway (clone.style.outline='none' in renderCardPng), so the stroke
190
+ belongs to canvas chrome and never reaches the artifact PNG.
191
+ The width is multiplied by --cs-inv to cancel the zoom, so it stays 0.5 screen
192
+ pixels (a hairline) at any scale.
193
+ #DEE0E3 comes from the design spec (same token as the menu border).
194
+ Known limitation: outline following border-radius needs Chrome 94+ / Safari 16.4+;
195
+ older iOS WebViews draw a square frame around the rounded card. */
196
+ outline: calc(0.5px * var(--cs-inv, 1)) solid #DEE0E3;
197
+ /* The radius is canvas chrome too and never reaches the artifact: renderCardPng
198
+ resets it to 0 before export, otherwise the PNG's four corners come out
199
+ transparent and the upload shows clipped corners. */
200
+ border-radius: 4px;
201
+ flex: 0 0 auto;
202
+ position: relative;
203
+ overflow: hidden;
204
+ box-sizing: border-box;
205
+ isolation: isolate;
206
+ transition: box-shadow .15s;
207
+ }
208
+ card-stage > section:hover,
209
+ card-stage > section.cs-hot {
210
+ box-shadow: 0 4px 8px -8px rgba(0,0,0,.06),
211
+ 0 6px 12px 0 rgba(0,0,0,.04),
212
+ 0 8px 24px 8px rgba(0,0,0,.04);
213
+ }
214
+
215
+ /* Card header: editable label on the left, download / expand on the right. Absolutely
216
+ positioned, so it stays out of the flex flow.
217
+ Counter-scaling keeps it a constant size on screen at any zoom, same as
218
+ design-canvas's --dc-inv-zoom. */
219
+ .cs-head {
220
+ position: absolute;
221
+ z-index: 2;
222
+ display: flex;
223
+ align-items: center;
224
+ height: 24px;
225
+ transform-origin: bottom left;
226
+ font: 400 13px/1.5 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
227
+ color: #8F959E;
228
+ pointer-events: auto;
229
+ }
230
+ .cs-head:hover, .cs-head.cs-hot { color: #646A73; }
231
+ .cs-head .cs-label {
232
+ flex: 1 1 auto;
233
+ min-width: 0;
234
+ padding: 3px 6px;
235
+ border-radius: 4px;
236
+ cursor: text;
237
+ outline: none;
238
+ white-space: nowrap;
239
+ overflow: hidden;
240
+ text-overflow: ellipsis;
241
+ transition: background .12s;
242
+ }
243
+ .cs-head .cs-label:hover { background: rgba(31,35,41,.05); }
244
+ .cs-head .cs-label:focus {
245
+ background: #fff;
246
+ box-shadow: 0 0 0 1px #336DF4;
247
+ overflow: visible;
248
+ text-overflow: clip;
249
+ }
250
+ .cs-head .cs-acts { flex: 0 0 auto; margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .12s; }
251
+ /* The buttons must not fade out while the menu is open, same as design-canvas's
252
+ .dc-btns:has(.dc-menu) */
253
+ .cs-head:hover .cs-acts, .cs-head.cs-hot .cs-acts, .cs-head:has(.cs-menu) .cs-acts { opacity: 1; }
254
+ .cs-iconbtn {
255
+ width: 22px; height: 22px;
256
+ border: 0; border-radius: 6px; padding: 0;
257
+ background: transparent; color: inherit;
258
+ display: flex; align-items: center; justify-content: center;
259
+ cursor: pointer; font: inherit;
260
+ transition: background .12s, color .12s;
261
+ }
262
+ .cs-iconbtn:hover { background: rgba(31,35,41,.05); color: #646A73; }
263
+
264
+ /* The "⋯" menu on the card header. It lives inside the counter-scaled header, so these
265
+ sizes are screen pixels */
266
+ .cs-menu {
267
+ position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 3;
268
+ min-width: 116px; padding: 4px;
269
+ background: #fff; border: 1px solid #DEE0E3; border-radius: 8px;
270
+ box-shadow: 0 4px 12px rgba(0,0,0,.10);
271
+ display: flex; flex-direction: column;
272
+ }
273
+ .cs-menu button {
274
+ border: 0; background: transparent; cursor: pointer;
275
+ text-align: left; padding: 7px 10px; border-radius: 6px;
276
+ font: 400 13px/1.4 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
277
+ color: #1F2329;
278
+ }
279
+ .cs-menu button:hover { background: rgba(31,35,41,.05); }
280
+ /* Destructive item: red text, and a light red hover instead of neutral grey. The colour
281
+ goes through --destructive-foreground so the host theme can override it; the literal is
282
+ only the fallback for artifacts that define no token.
283
+ The two background declarations are deliberate: the rgba one is the fallback, and only
284
+ the color-mix one can derive the tint from the token. Engines without color-mix
285
+ (Chrome < 111 / Safari < 16.2, i.e. older iOS WebViews) drop the second as invalid and
286
+ keep the first — do not merge them into one.
287
+ 8% is measured off the design spec (the light tint over white reads 252,239,239). */
288
+ .cs-menu button.danger { color: var(--destructive-foreground, #F54A45); }
289
+ .cs-menu button.danger:hover {
290
+ background: rgba(245,74,69,.08);
291
+ background: color-mix(in srgb, var(--destructive-foreground, #F54A45) 8%, transparent);
292
+ }
293
+ /* The divider runs edge to edge: the menu carries 4px of padding, so negative margins
294
+ push it back out (the spec shows it full-width) */
295
+ .cs-menu hr { border: 0; border-top: 1px solid #DEE0E3; margin: 4px -4px; }
296
+
297
+ /* Mobile bottom bar: page dots plus copy / download. Screen-fixed, outside the world
298
+ transform */
299
+ .cs-bar {
300
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
301
+ display: flex; flex-direction: column; align-items: center; gap: 18px;
302
+ padding: 14px 0 calc(18px + env(safe-area-inset-bottom, 0px));
303
+ pointer-events: none;
304
+ }
305
+ .cs-dots { display: flex; align-items: center; gap: 6px; pointer-events: auto; }
306
+ .cs-dots i {
307
+ width: 6px; height: 6px; border-radius: 50%;
308
+ background: rgba(255,255,255,.32);
309
+ transition: background .18s, width .18s;
310
+ }
311
+ .cs-dots i.on { width: 16px; border-radius: 3px; background: #fff; }
312
+ /* The button group sits bottom-right while the page dots stay centred — the two align on
313
+ different axes, so do not turn this into a single centred row */
314
+ .cs-macts { display: flex; gap: 8px; pointer-events: auto; align-self: flex-end; margin-right: 6%; }
315
+ .cs-macts button {
316
+ /* Sizes read off the design spec: 32×32 / radius 10 / 16px icon / 8px gap
317
+ (--spacing-2). That is under Apple's suggested 44pt touch target, which is the
318
+ designer's call — do not enlarge it in the name of "touch friendliness". */
319
+ width: 32px; height: 32px; padding: 0;
320
+ border: 0; border-radius: 10px;
321
+ background: rgba(255,255,255,.14); color: #fff;
322
+ display: flex; align-items: center; justify-content: center;
323
+ cursor: pointer; -webkit-tap-highlight-color: transparent;
324
+ }
325
+ /* Icons shrink with the button. Do this in CSS, not in the SVG constants: the focus-mode
326
+ bar reuses those two icons, so editing the constants would shrink them there too */
327
+ .cs-macts button svg { width: 16px; height: 16px; }
328
+ .cs-macts button:active { background: rgba(255,255,255,.28); }
329
+ .cs-macts button[disabled] { opacity: .5; }
330
+ /* Busy state: rendering takes a few hundred ms to a second or two, and with no feedback
331
+ the tap reads as ignored. The icon is hidden, not removed (visibility), or the button
332
+ would collapse to 0 width and snap back. The spinner is a pseudo-element, so no extra
333
+ nodes are inserted. */
334
+ .cs-macts button.is-busy, .cs-focus-btn.is-busy { position: relative; }
335
+ .cs-macts button.is-busy svg, .cs-focus-btn.is-busy svg { visibility: hidden; }
336
+ .cs-macts button.is-busy::after, .cs-focus-btn.is-busy::after {
337
+ content: ''; position: absolute; inset: 0; margin: auto;
338
+ width: 14px; height: 14px; box-sizing: border-box;
339
+ border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%;
340
+ animation: cs-spin .7s linear infinite;
341
+ }
342
+ .cs-macts button.is-busy[disabled], .cs-focus-btn.is-busy[disabled] { opacity: 1; }
343
+ @keyframes cs-spin { to { transform: rotate(360deg); } }
344
+ @media (prefers-reduced-motion: reduce) {
345
+ .cs-macts button.is-busy::after, .cs-focus-btn.is-busy::after { animation-duration: 2s; }
346
+ }
347
+
348
+ /* Result toast: white ground plus a status icon, matching design spec 11513-6843.
349
+ Top-centred, same position as the platform's own toast (the host uses sonner's
350
+ top-center); the safe-area inset keeps it clear of a notch or Dynamic Island on mobile.
351
+ The z-index must beat the focus overlay (100) and the context menu (200) — below the
352
+ overlay, a copy/download in focus mode shows no feedback at all because the overlay's
353
+ translucent ground covers it completely. */
354
+ .cs-toast {
355
+ position: fixed; left: 50%; top: calc(16px + env(safe-area-inset-top, 0px));
356
+ transform: translateX(-50%);
357
+ z-index: 300; display: flex; align-items: center; gap: 8px;
358
+ max-width: 78vw;
359
+ background: #fff; color: #1F2329;
360
+ font: 500 14px/1.4 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
361
+ padding: 12px 16px; border-radius: 12px;
362
+ box-shadow: 0 6px 24px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.06);
363
+ pointer-events: none; opacity: 0; transition: opacity .18s;
364
+ }
365
+ .cs-toast.on { opacity: 1; }
366
+ .cs-toast svg { flex: none; }
367
+
368
+ /* Bottom-right zoom pill, screen-fixed and outside the world transform. Shaped after
369
+ design-canvas's .dc-zoom */
370
+ .cs-zoom {
371
+ position: fixed;
372
+ bottom: 16px; right: 20px;
373
+ z-index: 50;
374
+ display: flex; align-items: center;
375
+ background: #fff;
376
+ border: 0.5px solid #DEE0E3;
377
+ border-radius: 8px;
378
+ padding: 4px;
379
+ box-shadow: 0 2px 4px -4px rgba(31,35,41,.02), 0 4px 8px 0 rgba(31,35,41,.02), 0 4px 16px 4px rgba(31,35,41,.03);
380
+ font: 400 13px/1 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
381
+ color: #1F2329;
382
+ }
383
+ .cs-zoom-btn {
384
+ height: 24px; min-width: 24px;
385
+ border: 0; border-radius: 6px; padding: 0;
386
+ background: transparent; color: rgba(60,50,40,.7);
387
+ display: flex; align-items: center; justify-content: center;
388
+ cursor: pointer; font: inherit;
389
+ transition: background .12s, color .12s;
390
+ }
391
+ .cs-zoom-btn:hover { background: #1f23290d; }
392
+ .cs-zoom-btn:disabled { opacity: .35; cursor: default; background: transparent; }
393
+ .cs-zoom-toggle { position: relative; display: flex; }
394
+ .cs-zoom-trigger { padding: 0 6px; gap: 4px; }
395
+ .cs-zoom-pct { min-width: 36px; text-align: center; font-size: 12px; color: #1F2329; }
396
+ .cs-zoom-menu {
397
+ position: absolute; bottom: 100%; left: 50%;
398
+ transform: translateX(-50%); margin-bottom: 6px; min-width: 114px;
399
+ z-index: 3; padding: 4px;
400
+ background: #fff; border: 1px solid #DEE0E3; border-radius: 8px;
401
+ box-shadow: 0 4px 12px rgba(0,0,0,.10);
402
+ display: flex; flex-direction: column;
403
+ }
404
+ .cs-zoom-menu button {
405
+ border: 0; background: transparent; cursor: pointer;
406
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
407
+ padding: 5px 8px; border-radius: 6px;
408
+ font: 400 13px/1.4 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
409
+ color: #1F2329;
410
+ }
411
+ .cs-zoom-menu button:hover { background: rgba(31,35,41,.05); }
412
+
413
+ /* Fullscreen focus: a round close button top-right plus a dark pill at the bottom
414
+ (paging / zoom / copy / download).
415
+ overflow:auto is what lets you scroll a card that has been zoomed past the viewport,
416
+ and the holder centres with margin:auto — flex centring clips the top once the content
417
+ outgrows its container. */
418
+ .cs-focus {
419
+ position: fixed; inset: 0; z-index: 100;
420
+ background: rgba(16,17,20,.92);
421
+ display: grid; overflow: auto;
422
+ }
423
+ .cs-focus .cs-holder { margin: auto; padding: 56px 24px 104px; }
424
+ .cs-focus .cs-holder > section { outline: none !important; box-shadow: none !important; }
425
+ .cs-focus-close {
426
+ position: fixed; top: 20px; right: 24px; z-index: 2;
427
+ width: 36px; height: 36px; border: 0; border-radius: 50%;
428
+ background: rgba(255,255,255,.16); color: #fff;
429
+ display: flex; align-items: center; justify-content: center;
430
+ cursor: pointer; transition: background .12s;
431
+ }
432
+ .cs-focus-close:hover { background: rgba(255,255,255,.28); }
433
+ .cs-focus-bar {
434
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 2;
435
+ display: flex; align-items: center; gap: 2px;
436
+ background: rgba(28,29,33,.94); border-radius: 999px;
437
+ padding: 6px 10px;
438
+ box-shadow: 0 6px 20px rgba(0,0,0,.28);
439
+ font: 400 13px/1 ui-sans-serif, -apple-system, "PingFang SC", sans-serif; color: #f2f2f2;
440
+ }
441
+ .cs-focus-btn {
442
+ min-width: 30px; height: 30px; padding: 0 6px;
443
+ border: 0; border-radius: 8px;
444
+ background: transparent; color: #f2f2f2;
445
+ display: flex; align-items: center; justify-content: center;
446
+ cursor: pointer; font: inherit;
447
+ transition: background .12s;
448
+ }
449
+ .cs-focus-btn:hover { background: rgba(255,255,255,.14); }
450
+ .cs-focus-btn:disabled { opacity: .35; cursor: default; background: transparent; }
451
+ .cs-focus-bar .cs-focus-sep { width: 1px; height: 18px; background: rgba(255,255,255,.18); margin: 0 6px; }
452
+ .cs-focus-bar .cs-focus-txt { min-width: 46px; text-align: center; user-select: none; }
453
+
454
+ /* Destructive confirm dialog. Every value below is read from the platform's own
455
+ dialog (Universe Design ud__confirm) via getComputedStyle, so the artifact and the
456
+ host render the same thing. Rendered locally rather than delegated to the host
457
+ because the artifact is standalone and cannot import the host's component.
458
+ z-index 400 sits above the context menu (200) and the toast (300) — a modal must be
459
+ the topmost layer or the menu that opened it can paint over it. */
460
+ .cs-dialog {
461
+ position: fixed; inset: 0; z-index: 400;
462
+ background: rgba(0,0,0,.55);
463
+ display: flex; align-items: center; justify-content: center;
464
+ padding: 24px;
465
+ }
466
+ .cs-dialog-panel {
467
+ width: min(420px, 100%); box-sizing: border-box;
468
+ background: #fff; border-radius: 8px;
469
+ font: 400 14px/22px ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
470
+ color: #1F2329;
471
+ }
472
+ .cs-dialog-head { padding: 24px; }
473
+ /* The icon box and the title's 24px line box are both 24px tall and share a top edge,
474
+ which is what the platform renders. align-items: baseline would not reproduce it: an svg-only
475
+ flex item takes its bottom margin edge as its baseline, which pushes the row to 31px. */
476
+ .cs-dialog-title {
477
+ display: flex; align-items: flex-start;
478
+ font-weight: 500; font-size: 16px; line-height: 24px;
479
+ }
480
+ /* Fill the rest of the row so a long name wraps at the same width as on the platform */
481
+ .cs-dialog-title > span:last-child { flex: 1 1 auto; min-width: 0; }
482
+ .cs-dialog-icon {
483
+ flex: 0 0 auto; width: 24px; height: 24px; margin-right: 16px;
484
+ color: #FF811A;
485
+ }
486
+ .cs-dialog-icon svg { display: block; width: 24px; height: 24px; }
487
+ /* 40px = icon 24 + its 16px gap, so the body lines up with the title text */
488
+ .cs-dialog-desc { margin: 8px 0 0 40px; }
489
+ /* DOM order mirrors the platform's (delete first); row-reverse paints it on the right */
490
+ .cs-dialog-acts {
491
+ padding: 0 24px 24px;
492
+ display: flex; flex-direction: row-reverse; justify-content: flex-start;
493
+ }
494
+ .cs-dialog-acts button {
495
+ min-width: 80px; height: 32px; box-sizing: border-box;
496
+ margin-left: 12px; padding: 4px 11px;
497
+ border-radius: 6px; border: 1px solid transparent; cursor: pointer;
498
+ font-family: inherit; font-size: 14px; font-weight: 400; line-height: 22px;
499
+ }
500
+ .cs-dialog-acts .cs-btn-ghost { border-color: #D0D3D6; background: #fff; color: #1F2329; }
501
+ .cs-dialog-acts .cs-btn-ghost:hover { background: #F2F3F5; }
502
+ .cs-dialog-acts .cs-btn-danger {
503
+ color: #fff;
504
+ border-color: var(--destructive, #FF7570);
505
+ background: var(--destructive, #FF7570);
506
+ }
507
+ .cs-dialog-acts .cs-btn-danger:hover {
508
+ border-color: var(--destructive-foreground, #F54A45);
509
+ background: var(--destructive-foreground, #F54A45);
510
+ }
511
+
512
+ /* Long-press save layer: inside a WebView neither download nor copy works, so put the
513
+ image on screen and let the system's own long-press menu take over */
514
+ .cs-save {
515
+ position: fixed; inset: 0; z-index: 400;
516
+ background: rgba(16,17,20,.94);
517
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px;
518
+ padding: 24px;
519
+ }
520
+ .cs-save img {
521
+ max-width: 100%; max-height: calc(100% - 96px);
522
+ object-fit: contain; border-radius: 4px;
523
+ -webkit-touch-callout: default; touch-action: auto;
524
+ }
525
+ .cs-save p {
526
+ margin: 0; color: #f2f2f2;
527
+ font: 400 14px/1.5 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
528
+ text-align: center;
529
+ }
530
+ .cs-save button {
531
+ border: 0; border-radius: 999px; padding: 10px 22px; cursor: pointer;
532
+ background: rgba(255,255,255,.16); color: #fff; font: inherit;
533
+ }
534
+
535
+ /* Context menu: pinned to the cursor, sharing one set of items between canvas cards and
536
+ focus mode.
537
+ Resetting .cs-menu's right:0 is mandatory (that one right-aligns the header "⋯" menu):
538
+ cursor pinning writes an inline left from JS, and left plus right together stretch the
539
+ width to "cursor → right edge of the viewport". */
540
+ .cs-ctx { position: fixed; right: auto; width: fit-content; z-index: 200; }
541
+
542
+ /* Safe-area guides (?guides=1) */
543
+ .cs-safe { position: absolute; inset: 0; pointer-events: none; z-index: 9999; }
544
+ .cs-safe i {
545
+ position: absolute; left: 0; right: 0; height: var(--safe-inset);
546
+ background: repeating-linear-gradient(45deg, rgba(255,0,80,.10) 0 14px, rgba(255,0,80,.02) 14px 28px);
547
+ border-block: 1px dashed rgba(255,0,80,.5);
548
+ }
549
+ .cs-safe i:first-child { top: 0; }
550
+ .cs-safe i:last-child { bottom: 0; }
551
+ .cs-safe u {
552
+ position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
553
+ aspect-ratio: 1; width: 100%; border: 1px dashed rgba(0,120,255,.45);
554
+ }
555
+
556
+ /* Overflow markers: only in the ?guides=1 self-check mode. In the shipped state overflow
557
+ is reported through console.error instead, so nothing changes in what the user sees */
558
+ .cs-over {
559
+ position: absolute; inset: 0;
560
+ pointer-events: none; z-index: 10000;
561
+ outline: 3px solid #E63757; outline-offset: -3px;
562
+ }
563
+ .cs-over b {
564
+ position: absolute; right: 0; bottom: 0;
565
+ background: #E63757; color: #fff;
566
+ font: 600 22px/1 ui-sans-serif, -apple-system, "PingFang SC", sans-serif;
567
+ padding: 10px 14px;
568
+ }
569
+
570
+ /* Export mode: 1:1 and no chrome whatsoever */
571
+ card-stage[data-export] {
572
+ position: static; transform: none; display: block;
573
+ padding: 0; gap: 0;
574
+ }
575
+ card-stage[data-export] > section { box-shadow: none !important; }
576
+ html[data-cs-export], html[data-cs-export] body { margin: 0; padding: 0; overflow: visible; background: #fff; }
577
+ `;
578
+
579
+ // —— PNG export ——
580
+ // Lifted from design-canvas.jsx's dcExport: inline every computed style, font and image,
581
+ // put that inside an SVG foreignObject and draw it to a canvas. Scale is fixed at 1.
582
+ // Resources that failed to inline. An SVG foreignObject may not load any external
583
+ // resource, so falling back to the original URL means that image is simply blank in the
584
+ // export — which must not be silent: report it (preflight collects console errors) and let
585
+ // the caller tell the user "an image did not make it" rather than hand over a finished
586
+ // picture with a hole in it.
587
+ let inlineFailures = [];
588
+ let inlineFetchNotes = [];
589
+ const MAX_RASTER = 4096;
590
+ /**
591
+ * The right way to fetch a remote image: a fresh Image with crossOrigin='anonymous',
592
+ * drawn into a canvas and read back as a data URL — not a single fetch is issued. An
593
+ * artifact's CSP keeps img-src and connect-src as two independent allowlists: the image
594
+ * displaying at all proves img-src permits it, while connect-src often lists only the one
595
+ * font host, so fetching the image is blocked outright (reported as Failed to fetch, and
596
+ * unrelated to CORS). Same approach as the platform's
597
+ * export-shared/raster/inline-images.ts.
598
+ * crossOrigin MUST be set before src: without it the image still displays but the canvas
599
+ * is tainted and toDataURL throws.
600
+ * The cost is that the origin has to send an ACAO header; for origins that do not, this
601
+ * path also fails and we fall back to fetch / the proxy.
602
+ */
603
+ function imgViaCors(url, bust) {
604
+ return new Promise((res) => {
605
+ const im = new Image();
606
+ im.crossOrigin = 'anonymous'; // must precede src
607
+ im.onload = () => {
608
+ try {
609
+ const nw = im.naturalWidth, nh = im.naturalHeight;
610
+ if (!nw || !nh) return res(null);
611
+ const k = Math.min(1, MAX_RASTER / Math.max(nw, nh));
612
+ const c = document.createElement('canvas');
613
+ c.width = Math.max(1, Math.round(nw * k));
614
+ c.height = Math.max(1, Math.round(nh * k));
615
+ const ctx = c.getContext('2d');
616
+ ctx.drawImage(im, 0, 0, c.width, c.height);
617
+ // JPEG for photos with no alpha: roughly a third of the PNG size, and this base64
618
+ // goes into the SVG in full — a ten-plus MB string is expensive to parse on mobile.
619
+ // Icons with alpha must stay PNG, or transparent areas turn into black blocks.
620
+ const out = c.toDataURL(looksTransparent(ctx, c) ? 'image/png' : 'image/jpeg', 0.92);
621
+ // iOS WKWebView caps total canvas area; past it toDataURL does not throw, it just
622
+ // returns 'data:,'. Unvalidated, that empty string goes into the SVG as an image and
623
+ // the region comes out blank with no clue why.
624
+ res(/^data:image\//.test(out) && out.length > 128 ? out : null);
625
+ } catch {
626
+ res(null); // origin sent no ACAO, so the canvas is tainted
627
+ }
628
+ };
629
+ im.onerror = () => res(null);
630
+ // WebKit's HTTP cache is not keyed by CORS mode: if the page already loaded this image
631
+ // no-cors, the crossOrigin request reuses that ACAO-less entry and the canvas is tainted
632
+ // anyway (this is exactly why images went missing on iOS while Android and desktop were
633
+ // fine). A meaningless query parameter forces a separate cache entry.
634
+ // A signed URL may reject the extra parameter, so only bust after the first failure.
635
+ im.src = bust ? url + (url.indexOf('?') < 0 ? '?' : '&') + 'cs-cors=1' : url;
636
+ });
637
+ }
638
+ /** Whether any pixel is transparent. Sampled at 32×32: reading a 4K image at full size is
639
+ tens of MB in one go. */
640
+ function looksTransparent(ctx, canvas) {
641
+ try {
642
+ const P = 32;
643
+ const probe = document.createElement('canvas');
644
+ probe.width = P; probe.height = P;
645
+ const pc = probe.getContext('2d');
646
+ pc.drawImage(canvas, 0, 0, P, P);
647
+ const d = pc.getImageData(0, 0, P, P).data;
648
+ for (let i = 3; i < d.length; i += 4) if (d[i] !== 255) return true;
649
+ return false;
650
+ } catch {
651
+ return true; // unreadable → assume transparency: PNG is merely bigger, a wrong JPEG call turns it into black blocks
652
+ }
653
+ }
654
+
655
+ /** Draw an <img> already displayed on the page into a canvas and read a data URL. A
656
+ cross-origin image without CORS taints the canvas and throws. */
657
+ function imgToDataURL(img) {
658
+ if (!img || !img.complete || !img.naturalWidth) return null;
659
+ try {
660
+ const c = document.createElement('canvas');
661
+ c.width = img.naturalWidth;
662
+ c.height = img.naturalHeight;
663
+ c.getContext('2d').drawImage(img, 0, 0);
664
+ return c.toDataURL('image/png');
665
+ } catch (e) {
666
+ inlineFailures.push(`${img.currentSrc || img.src} → canvas readback failed: ${(e && e.name) || 'error'}`);
667
+ return null;
668
+ }
669
+ }
670
+ // The host inspector runtime injects a same-origin proxy into the artifact page (miaoda's
671
+ // snapdom screenshot path uses the same one); use it for cross-origin images. <img> and CSS
672
+ // background images load no-cors, that cache entry cannot be reused by a cors fetch, and
673
+ // when the cross-origin storage sends no Access-Control-Allow-Origin the fetch fails
674
+ // outright — displays fine, fails to inline.
675
+ // The proxy hangs off the app base, not the site root: the artifact page lives at
676
+ // /app/<appId>/ and the proxy at /spark/app/<appId>/.
677
+ // Derive a few candidates from the page location and try them in order, remembering the one
678
+ // that works instead of retrying all of them for every image.
679
+ function proxyCandidates() {
680
+ const out = [];
681
+ const m = /^\/app\/([^/]+)\//.exec(location.pathname);
682
+ if (m) out.push(`/spark/app/${m[1]}/dev/snapdom-proxy`);
683
+ const dir = location.pathname.replace(/[^/]*$/, '');
684
+ if (dir && dir !== '/') out.push(`${dir}dev/snapdom-proxy`);
685
+ out.push('/dev/snapdom-proxy');
686
+ return out;
687
+ }
688
+ let proxyBase = null; // the one that worked; null = untried, false = none reachable
689
+ const blobToDataURL = (b) =>
690
+ new Promise((res, rej) => {
691
+ const fr = new FileReader();
692
+ fr.onload = () => res(fr.result);
693
+ fr.onerror = () => rej(new Error('read blob failed'));
694
+ fr.readAsDataURL(b);
695
+ });
696
+ const fetchAsDataURL = (url, expectImage) =>
697
+ fetch(url)
698
+ .then((r) => {
699
+ if (!r.ok) throw new Error('HTTP ' + r.status);
700
+ return r.blob();
701
+ })
702
+ .then((b) => {
703
+ // An unauthenticated proxy answers 200 with a whole login page. Unvalidated, that HTML
704
+ // gets base64-inlined as an image and the region is blank with no clue why. Only check
705
+ // at call sites that expect an image: fonts (font/woff2) go through this function too,
706
+ // and an image check would mark the whole font set as failed.
707
+ if (expectImage && b.type && !/^image\//.test(b.type)) throw new Error('not an image: ' + b.type);
708
+ return blobToDataURL(b);
709
+ });
710
+
711
+ /** Try the proxy candidates in order; resolve to a data URL, throw if none work. */
712
+ async function fetchViaProxy(url, expectImage) {
713
+ if (proxyBase === false) throw new Error('proxy unavailable');
714
+ const list = proxyBase ? [proxyBase] : proxyCandidates();
715
+ let last = null;
716
+ for (const base of list) {
717
+ try {
718
+ const d = await fetchAsDataURL(`${base}?url=${encodeURIComponent(url)}`, expectImage);
719
+ proxyBase = base;
720
+ return d;
721
+ } catch (e) {
722
+ last = e;
723
+ }
724
+ }
725
+ if (!proxyBase) proxyBase = false; // none reachable, stop retrying later
726
+ throw last || new Error('proxy unavailable');
727
+ }
728
+ /**
729
+ * The image-specific path, and the order matters: CORS readback first (CSP cannot block it
730
+ * and it does not consume connect-src), then fall back to fetch / the same-origin proxy.
731
+ * Deduped by URL — one image referenced by several cards in a set is the norm.
732
+ */
733
+ const imageCache = new Map();
734
+ // Concurrency cap for image fetches. Each image builds its own canvas, iOS has a small
735
+ // total canvas budget, and letting them all run at once makes toDataURL fail silently
736
+ // (returning 'data:,'). The platform's inline-images caps at 6; this is more conservative.
737
+ const IMG_POOL = 3;
738
+ let imgInFlight = 0;
739
+ const imgQueue = [];
740
+ function withImgSlot(fn) {
741
+ return new Promise((res) => {
742
+ const run = () => {
743
+ imgInFlight += 1;
744
+ fn().then((v) => {
745
+ imgInFlight -= 1;
746
+ const next = imgQueue.shift();
747
+ if (next) next();
748
+ res(v);
749
+ });
750
+ };
751
+ if (imgInFlight < IMG_POOL) run();
752
+ else imgQueue.push(run);
753
+ });
754
+ }
755
+ function resolveImage(url) {
756
+ const hit = imageCache.get(url);
757
+ if (hit) return hit;
758
+ const job = withImgSlot(() => imgViaCors(url))
759
+ .then((d) => d || withImgSlot(() => imgViaCors(url, true)).then((d2) => {
760
+ if (d2) inlineFetchNotes.push(`${url} → first CORS attempt hit a poisoned cache entry, cache-bust succeeded`);
761
+ return d2;
762
+ }))
763
+ .then((d) => {
764
+ if (d) return d;
765
+ inlineFetchNotes.push(`${url} → both CORS attempts failed, falling back to fetch/proxy`);
766
+ return toDataURL(url, { image: true });
767
+ });
768
+ imageCache.set(url, job);
769
+ return job;
770
+ }
771
+
772
+ const toDataURL = async (url, opts) => {
773
+ const expectImage = !!(opts && opts.image);
774
+ try {
775
+ return await fetchAsDataURL(url, expectImage);
776
+ } catch (e1) {
777
+ // Same-origin proxy retry. Without that endpoint (opened locally, a different host) this
778
+ // just counts as a second failure and does not affect the main path
779
+ try {
780
+ const viaProxy = await fetchViaProxy(url, expectImage);
781
+ inlineFetchNotes.push(`${url} → direct fetch failed (${(e1 && e1.message) || 'unknown'}), same-origin proxy succeeded`);
782
+ return viaProxy;
783
+ } catch (e2) {
784
+ const why = `direct ${(e1 && e1.message) || 'unknown'} / proxy ${(e2 && e2.message) || 'unknown'}`;
785
+ // Callers that have a canvas fallback do their own bookkeeping, so do not double-count
786
+ if (!(opts && opts.silent)) inlineFailures.push(`${url} → ${why}`);
787
+ else inlineFetchNotes.push(`${url} → ${why}, falling back to canvas`);
788
+ return url;
789
+ }
790
+ }
791
+ };
792
+
793
+ async function collectFontCss() {
794
+ // Reading cssRules off a cross-origin stylesheet (fonts.googleapis.com and the like)
795
+ // throws SecurityError, so the CSS text has to be fetched, @font-face extracted by regex
796
+ // and @import followed recursively — merely try/catching past it makes every cross-origin
797
+ // font fall back to a system font in the exported PNG. Same approach as dcExport.
798
+ const rules = [], pending = [], seen = new Set();
799
+ const scrapeCss = (href) => {
800
+ if (seen.has(href)) return;
801
+ seen.add(href);
802
+ pending.push(fetch(href).then((r) => r.text()).then((css) => {
803
+ for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) rules.push({ css: m, base: href });
804
+ for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
805
+ scrapeCss(new URL(m[1], href).href);
806
+ }).catch(() => {}));
807
+ };
808
+ const walk = (list, base) => {
809
+ for (const r of list) {
810
+ if (r.type === CSSRule.FONT_FACE_RULE) rules.push({ css: r.cssText, base });
811
+ else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
812
+ const ibase = r.styleSheet.href || base;
813
+ try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
814
+ } else if (r.cssRules) walk(r.cssRules, base);
815
+ }
816
+ };
817
+ for (const ss of document.styleSheets) {
818
+ const base = ss.href || location.href;
819
+ try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
820
+ }
821
+ while (pending.length) await pending.shift();
822
+ return (await Promise.all(rules.map(async (rule) => {
823
+ let out = rule.css, m;
824
+ const re = /url\((['"]?)([^'")]+)\1\)/g;
825
+ while ((m = re.exec(rule.css))) {
826
+ if (m[2].indexOf('data:') === 0) continue;
827
+ let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
828
+ out = out.split(m[0]).join('url("' + (await toDataURL(abs)) + '")');
829
+ }
830
+ return out;
831
+ }))).join('\n');
832
+ }
833
+
834
+ // cloneStyled registers these while recursing and the pseudo-elements are materialised
835
+ // afterwards in one pass (inserting children mid-recursion would disturb the traversal)
836
+ let pseudoStubs = [];
837
+ const PSEUDO_SKIP = new Set(['content', 'position', 'display']);
838
+ function materializePseudos() {
839
+ for (const { src, dst } of pseudoStubs) {
840
+ for (const pseudo of ['::before', '::after']) {
841
+ const pcs = getComputedStyle(src, pseudo);
842
+ const content = pcs.content;
843
+ if (!content || content === 'none' || content === 'normal') continue;
844
+ const box = document.createElement('div');
845
+ box.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
846
+ let st = '';
847
+ for (let i = 0; i < pcs.length; i++) {
848
+ const k = pcs[i];
849
+ if (PSEUDO_SKIP.has(k)) continue;
850
+ st += k + ':' + pcs.getPropertyValue(k) + ';';
851
+ }
852
+ // display / position are set separately: the computed display of a pseudo-element that
853
+ // was never generated can be none, while content already proves this one renders
854
+ st += `display:${pcs.display === 'none' ? 'block' : pcs.display};`;
855
+ st += `position:${pcs.position};animation:none;transition:none;`;
856
+ box.setAttribute('style', st);
857
+ // Only string content is honoured; counter()/attr()/open-quote cannot be reproduced, so
858
+ // the box is left empty (its background and border still render)
859
+ const m = /^\s*(['"])([\s\S]*)\1\s*$/.exec(content);
860
+ if (m) box.textContent = m[2];
861
+ if (pseudo === '::before') dst.insertBefore(box, dst.firstChild);
862
+ else dst.appendChild(box);
863
+ }
864
+ }
865
+ pseudoStubs = [];
866
+ }
867
+
868
+ const cloneStyled = (src) => {
869
+ if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
870
+ // Self-check elements like guides and overflow markers stay out of both the export and
871
+ // the focus overlay
872
+ if (src.nodeType === 1 && /\bcs-(safe|over)\b/.test(src.className || '')) return document.createTextNode('');
873
+ const dst = src.cloneNode(false);
874
+ if (src.nodeType === 1) {
875
+ const cs = getComputedStyle(src);
876
+ let txt = '';
877
+ for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
878
+ dst.setAttribute('style', txt + 'animation:none;transition:none;');
879
+ // ::before / ::after do not exist in the export at all: only font CSS is injected into
880
+ // the SVG, the author stylesheet is absent, and a pseudo-element has no node to clone.
881
+ // They are materialised back as real divs (::before first, ::after last), and their
882
+ // background images go through the inlining pass below like everything else.
883
+ pseudoStubs.push({ src, dst });
884
+ if (src.tagName === 'CANVAS') {
885
+ try {
886
+ const im = document.createElement('img');
887
+ im.src = src.toDataURL();
888
+ im.setAttribute('style', txt);
889
+ return im;
890
+ } catch { /* a tainted canvas cannot be read back */ }
891
+ }
892
+ }
893
+ for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
894
+ return dst;
895
+ };
896
+
897
+ async function renderCardPng(section, w, h) {
898
+ inlineFailures = [];
899
+ inlineFetchNotes = [];
900
+ // Cloning before the fonts finish downloading bakes the fallback font into the PNG
901
+ try { await document.fonts.ready; } catch { /* export anyway if font loading failed */ }
902
+ const fontCss = await collectFontCss();
903
+ pseudoStubs = [];
904
+ const clone = cloneStyled(section);
905
+ materializePseudos();
906
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
907
+ clone.style.transform = 'none';
908
+ // The first-frame blackout is a visibility:hidden on card-stage. cloneStyled copies each
909
+ // node's computed visibility into its own inline style, so overriding the root is not
910
+ // enough — the whole tree has to be cleared.
911
+ clone.style.visibility = 'visible';
912
+ clone.style.borderRadius = '0';
913
+ for (const el of clone.querySelectorAll('*')) {
914
+ if (el.style && el.style.visibility === 'hidden') el.style.visibility = 'visible';
915
+ }
916
+ clone.style.margin = '0';
917
+ clone.style.outline = 'none';
918
+ clone.style.boxShadow = 'none';
919
+
920
+ const jobs = [];
921
+ // Record where each inlined image sits in the card, for the decode check further down.
922
+ // Geometry can only be measured on the source elements — the clone is not in the document
923
+ // and has no layout. Coordinates are converted to export pixels (card width → w).
924
+ const secRect = section.getBoundingClientRect();
925
+ const toExportRect = (el) => {
926
+ const r = el.getBoundingClientRect();
927
+ const k = w / (secRect.width || 1);
928
+ return {
929
+ x: (r.left - secRect.left) * k,
930
+ y: (r.top - secRect.top) * k,
931
+ w: r.width * k,
932
+ h: r.height * k,
933
+ };
934
+ };
935
+ const imageRegions = [];
936
+ // Two paths per image: fetch it into a data URL first; if that fails, fall back to
937
+ // drawing the <img> already displayed on the page into a canvas — the image is visibly on
938
+ // the canvas, so the browser already holds a decoded bitmap and there is no need to fetch
939
+ // it again. That canvas path only works same-origin or with CORS headers; a cross-origin
940
+ // image without CORS taints the canvas and throws, and only then is it genuinely lost.
941
+ // Only when both fail does it count as an inlineFailure.
942
+ const srcImgs = [...section.querySelectorAll('img')];
943
+ const cloneImgs = [...clone.querySelectorAll('img')];
944
+ // Index alignment follows from cloneStyled preserving structure; if it does not hold, stay
945
+ // on the fetch path rather than pairing the wrong elements
946
+ const aligned =
947
+ srcImgs.length === cloneImgs.length &&
948
+ srcImgs.every((el, i) => el.getAttribute('src') === cloneImgs[i].getAttribute('src'));
949
+ if (!aligned && srcImgs.length) {
950
+ console.error('[card-stage] clone/source <img> indices are misaligned; image inlining loses its canvas fallback');
951
+ }
952
+ cloneImgs.forEach((el, i) => {
953
+ const raw = el.getAttribute('src');
954
+ if (!raw || raw.indexOf('data:') === 0) return;
955
+ const live = aligned ? srcImgs[i] : null;
956
+ jobs.push(
957
+ resolveImage(el.src).then((d) => {
958
+ if (d && d !== el.src) {
959
+ el.setAttribute('src', d);
960
+ if (live) imageRegions.push({ url: el.src, rect: toExportRect(live) });
961
+ return;
962
+ }
963
+ // Last resort: the <img> already displayed on the page (works same-origin; a
964
+ // cross-origin image with no ACAO taints the canvas)
965
+ const viaCanvas = imgToDataURL(live);
966
+ if (viaCanvas) { el.setAttribute('src', viaCanvas); return; }
967
+ inlineFailures.push(`${el.src} → CORS / fetch / proxy / canvas all failed`);
968
+ }),
969
+ );
970
+ // srcset outranks src, so without clearing it the browser still goes for the remote
971
+ // candidate
972
+ el.removeAttribute('srcset');
973
+ });
974
+ const cloneAll = [clone, ...clone.querySelectorAll('*')];
975
+ const srcAll = [section, ...section.querySelectorAll('*')];
976
+ const bgAligned = cloneAll.length === srcAll.length;
977
+ cloneAll.forEach((el, i) => {
978
+ const bg = el.style && el.style.backgroundImage;
979
+ if (!bg) return;
980
+ const re = /url\(["']?([^"')]+)["']?\)/g;
981
+ let m;
982
+ while ((m = re.exec(bg))) {
983
+ // m is the while loop's shared variable and is already overwritten (to null) by the
984
+ // next iteration when the callback runs — it has to be captured locally
985
+ const rawUrl = m[1];
986
+ const tok = m[0];
987
+ if (rawUrl.indexOf('data:') === 0) continue;
988
+ const liveEl = bgAligned ? srcAll[i] : null;
989
+ jobs.push(resolveImage(rawUrl).then((d) => {
990
+ if (!d || d === rawUrl) return;
991
+ el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")');
992
+ if (liveEl) imageRegions.push({ url: rawUrl, rect: toExportRect(liveEl) });
993
+ }));
994
+ }
995
+ });
996
+ await Promise.all(jobs);
997
+
998
+ // Decode the inlined data URLs once at page level first. Blink's SVG rasteriser reuses the
999
+ // shared decoded-image cache, so after this step the very first drawImage is already
1000
+ // complete (the stability poll below exits on its first round). WebKit's SVG-as-image is
1001
+ // an isolated document that shares no cache, which cannot be helped and is what the poll
1002
+ // is for. Same idea as IMAGE_DECODE_BUDGET_MS in the platform's setup.ts: a bounded wait,
1003
+ // not a guarantee.
1004
+ const dataUrls = new Set();
1005
+ for (const el of clone.querySelectorAll('img')) {
1006
+ const v = el.getAttribute('src');
1007
+ if (v && v.indexOf('data:image/') === 0) dataUrls.add(v);
1008
+ }
1009
+ for (const el of [clone, ...clone.querySelectorAll('*')]) {
1010
+ const bg = el.style && el.style.backgroundImage;
1011
+ if (!bg) continue;
1012
+ for (const m of bg.matchAll(/url\(["']?(data:image\/[^"')]+)["']?\)/g)) dataUrls.add(m[1]);
1013
+ }
1014
+ if (dataUrls.size) {
1015
+ const decodes = [...dataUrls].map((u) => {
1016
+ const im = new Image();
1017
+ im.src = u;
1018
+ return typeof im.decode === 'function' ? im.decode().catch(() => {}) : Promise.resolve();
1019
+ });
1020
+ // A 3s budget: a stalled decode still has to produce a picture rather than hang the
1021
+ // download button
1022
+ await Promise.race([
1023
+ Promise.all(decodes),
1024
+ new Promise((res) => setTimeout(res, 3000)),
1025
+ ]);
1026
+ }
1027
+
1028
+ const xml = new XMLSerializer().serializeToString(clone);
1029
+ const svg =
1030
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
1031
+ '" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
1032
+ (fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
1033
+
1034
+ // The SVG has to go through a data: URL and must not be swapped for a Blob URL — WebKit
1035
+ // treats an SVG served from blob: as origin-unclean, so once drawn into a canvas both
1036
+ // toBlob and getImageData throw SecurityError and the whole export path is dead on
1037
+ // Safari / iOS (verified). The size penalty is the price.
1038
+ const img = new Image();
1039
+ await new Promise((res, rej) => {
1040
+ img.onload = res;
1041
+ img.onerror = () => rej(new Error('svg load failed'));
1042
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
1043
+ });
1044
+ // When an SVG is loaded as an image, onload lies about the images inside it: it fires once
1045
+ // the outer document is parsed while the images in the foreignObject are still decoding
1046
+ // (very visible in WebKit — a second or two on iOS). img.decode() only covers the outer
1047
+ // layer and cannot be trusted, and waiting a fixed number of frames is betting on timing.
1048
+ //
1049
+ // The spec has no "inner resources are ready" event. createImageBitmap is the only
1050
+ // guaranteed signal, and WebKit refuses SVG blobs outright (it throws Cannot decode). So
1051
+ // the only option is to verify the RESULT.
1052
+ // The criterion is NOT "the picture stopped changing" — two consecutive undecoded rounds
1053
+ // would read as stable — but "the region of every inlined image is no longer one flat
1054
+ // colour": before decoding it shows the card's background, and after decoding it must
1055
+ // carry more than one colour.
1056
+ //
1057
+ // The poll rasterises at 256 wide only: drawImage rasterises at whatever size you scale
1058
+ // to, which costs on the order of a hundredth of the full size. Never re-draw at
1059
+ // 1080×1440 in a loop — that redoes the foreignObject's layout and paint N times. This
1060
+ // size is enough because images occupy a large share of a card, so they still change the
1061
+ // sampled region at 256.
1062
+ const PROBE_W = 256;
1063
+ const PROBE_H = Math.max(1, Math.round((PROBE_W * h) / w));
1064
+ const probe = document.createElement('canvas');
1065
+ probe.width = PROBE_W; probe.height = PROBE_H;
1066
+ const px = probe.getContext('2d');
1067
+ const sk = PROBE_W / w;
1068
+ // Images too small to judge (icons, rules) cover only a few pixels in a 256-wide probe, so
1069
+ // they are skipped
1070
+ const checkable = imageRegions
1071
+ .map((it) => ({
1072
+ url: it.url,
1073
+ x: Math.max(0, Math.round(it.rect.x * sk)),
1074
+ y: Math.max(0, Math.round(it.rect.y * sk)),
1075
+ w: Math.round(it.rect.w * sk),
1076
+ h: Math.round(it.rect.h * sk),
1077
+ }))
1078
+ .filter((it) => it.w >= 4 && it.h >= 4);
1079
+ /** Whether this region still holds a single colour (= the image has not been drawn yet). */
1080
+ const stillFlat = (r) => {
1081
+ const d = px.getImageData(r.x, r.y, Math.min(r.w, PROBE_W - r.x), Math.min(r.h, PROBE_H - r.y)).data;
1082
+ if (!d.length) return false;
1083
+ const r0 = d[0], g0 = d[1], b0 = d[2];
1084
+ for (let i = 4; i < d.length; i += 4) {
1085
+ if (Math.abs(d[i] - r0) > 6 || Math.abs(d[i + 1] - g0) > 6 || Math.abs(d[i + 2] - b0) > 6) return false;
1086
+ }
1087
+ return true;
1088
+ };
1089
+ const tick = () => new Promise((res) => {
1090
+ // rAF does not fire while the page is hidden (the ?export=1 screenshot path may well run
1091
+ // in the background), so pair it with a timeout
1092
+ const t = setTimeout(res, 120);
1093
+ requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(t); res(); }));
1094
+ });
1095
+ let notReady = checkable;
1096
+ // Capped at 12 rounds (~1.5s): even without a verdict it has to produce a picture rather
1097
+ // than hang the download
1098
+ for (let i = 0; i < 12 && notReady.length; i += 1) {
1099
+ if (i) await tick();
1100
+ px.clearRect(0, 0, PROBE_W, PROBE_H);
1101
+ px.drawImage(img, 0, 0, PROBE_W, PROBE_H);
1102
+ notReady = notReady.filter((r) => stillFlat(r));
1103
+ }
1104
+ if (notReady.length) {
1105
+ // Name the specific image: still flat after the full budget usually means the inlined
1106
+ // data URL itself is broken rather than a timing problem
1107
+ inlineFailures.push(...notReady.map((r) => `${r.url} → inlined, but that region was still flat at export time (decode unfinished, or the image itself is broken)`));
1108
+ }
1109
+ // Draw at full size exactly once
1110
+ const cv = document.createElement('canvas');
1111
+ cv.width = w; cv.height = h;
1112
+ cv.getContext('2d').drawImage(img, 0, 0);
1113
+ if (inlineFetchNotes.length) {
1114
+ console.warn(`[card-stage] ${inlineFetchNotes.length} resource(s) failed a direct fetch and used a fallback:\n` + inlineFetchNotes.join('\n'));
1115
+ }
1116
+ if (inlineFailures.length) {
1117
+ console.error(
1118
+ `[card-stage] ${inlineFailures.length} resource(s) could not be inlined; those areas are blank in the export:\n` +
1119
+ inlineFailures.join('\n'),
1120
+ );
1121
+ }
1122
+ const missed = inlineFailures.length;
1123
+ // Pass a truncated reason to the caller: a WebView shows no console, so the message itself
1124
+ // has to reveal whether it was cross-origin, a 404 or something else
1125
+ const why = inlineFailures.length ? inlineFailures[0].slice(-90) : '';
1126
+ return new Promise((res) => cv.toBlob((b) => {
1127
+ if (b) { b.csInlineMissed = missed; b.csInlineWhy = why; } // the caller warns the user from this instead of handing over a picture with holes
1128
+ res(b);
1129
+ }, 'image/png'));
1130
+ }
1131
+
1132
+ const DOWNLOAD_SVG =
1133
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
1134
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
1135
+ '<path d="M8 2.5v7.5m0 0 3.2-3.2M8 10 4.8 6.8M2.8 13.5h10.4"/></svg>';
1136
+ // zoomAt clamps the zoom to [0.05, 2]; the pill's disabled states use the same range
1137
+ const MIN_PCT = 5;
1138
+ const MAX_PCT = 200;
1139
+ const ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
1140
+ const ZOOM_IN_SVG =
1141
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
1142
+ '<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="currentColor"/></svg>';
1143
+ const ZOOM_OUT_SVG =
1144
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
1145
+ '<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="currentColor"/></svg>';
1146
+ /**
1147
+ * Mark a node as SDK chrome. The host inspector runtime skips these when a long press
1148
+ * creates a comment (NON_COMMENTABLE_SELECTOR in element-collector.ts), and exempted
1149
+ * elements get `-webkit-touch-callout: default` back — which is exactly what
1150
+ * long-press-to-save needs.
1151
+ * None of the component's own UI (toolbars, card headers, menus, the focus scrim, the
1152
+ * long-press save layer) should be commentable.
1153
+ */
1154
+ function csChrome(el) {
1155
+ el.setAttribute('data-miaoda-chrome', '');
1156
+ return el;
1157
+ }
1158
+
1159
+ /** Put the button in a busy state (disabled + spinner) for the duration of the click and
1160
+ restore it afterwards. Rendering starts at a few hundred ms, and with no feedback the tap
1161
+ reads as ignored. */
1162
+ async function withBusy(btn, fn) {
1163
+ btn.disabled = true;
1164
+ btn.classList.add('is-busy');
1165
+ try {
1166
+ return await fn();
1167
+ } finally {
1168
+ btn.disabled = false;
1169
+ btn.classList.remove('is-busy');
1170
+ }
1171
+ }
1172
+
1173
+ /** Whether files can be handed to the system share sheet. If it feature-detects inside a
1174
+ WebView, use it; otherwise fall through to long-press-to-save. */
1175
+ function canShareFiles() {
1176
+ try {
1177
+ if (!navigator.share || !navigator.canShare) return false;
1178
+ return navigator.canShare({ files: [new File(['probe'], 'probe.png', { type: 'image/png' })] });
1179
+ } catch {
1180
+ return false;
1181
+ }
1182
+ }
1183
+
1184
+ /* WarningFilled, copied verbatim from the platform icon set so the dialog's icon is
1185
+ the same glyph, not a lookalike. The bar and dot are reverse-wound subpaths, so the
1186
+ nonzero fill rule punches them out of the disc. */
1187
+ const CS_ICON_WARN =
1188
+ '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1189
+ '<path d="M23 12c0 6.075-4.925 11-11 11S1 18.075 1 12 5.925 1 12 1s11 4.925 11 11ZM12 7a1 1 0 0 0-1 1v5a1 1 ' +
1190
+ '0 1 0 2 0V8a1 1 0 0 0-1-1Zm0 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z" fill="currentColor"/></svg>';
1191
+ const CLOSE_SVG =
1192
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" ' +
1193
+ 'stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
1194
+ const CHEV_L_SVG =
1195
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" ' +
1196
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3.5L5.5 8l4.5 4.5"/></svg>';
1197
+ const CHEV_R_SVG =
1198
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" ' +
1199
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5L10.5 8 6 12.5"/></svg>';
1200
+ const CARET_SVG =
1201
+ '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">' +
1202
+ '<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1203
+ const TICK_SVG =
1204
+ '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">' +
1205
+ '<path d="M1.8 6.2 4.4 8.8 10 3.2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1206
+ // Character-for-character identical to design-canvas's .dc-kebab / .dc-expand: expand is a
1207
+ // diagonal double arrow, not corner brackets
1208
+ const MORE_SVG =
1209
+ '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">' +
1210
+ '<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>';
1211
+ const COPY_SVG =
1212
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' +
1213
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
1214
+ '<rect x="9" y="9" width="11" height="11" rx="2.5"/><path d="M15 5.5A2.5 2.5 0 0 0 12.5 3H6.5A3.5 3.5 0 0 0 3 6.5v6A2.5 2.5 0 0 0 5.5 15"/></svg>';
1215
+ const DOWNLOAD_M_SVG =
1216
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' +
1217
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
1218
+ '<path d="M12 3.5v11m0 0 4.2-4.2M12 14.5 7.8 10.3M4 19.5h16"/></svg>';
1219
+ // Toast icons: a filled disc with a white glyph (the spec shows a solid green disc with a
1220
+ // tick, not a stroked tick).
1221
+ // Colours measured off the spec: success #32A645; failure reuses the menu's destructive
1222
+ // #F54A45.
1223
+ const TOAST_OK_SVG =
1224
+ '<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">' +
1225
+ '<circle cx="12" cy="12" r="10" fill="#32A645"/>' +
1226
+ '<path d="M7.6 12.4l2.9 2.9L16.6 9.2" fill="none" stroke="#fff" stroke-width="2.2" ' +
1227
+ 'stroke-linecap="round" stroke-linejoin="round"/></svg>';
1228
+ const TOAST_ERR_SVG =
1229
+ '<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">' +
1230
+ '<circle cx="12" cy="12" r="10" fill="#F54A45"/>' +
1231
+ '<path d="M12 7v6" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>' +
1232
+ '<circle cx="12" cy="16.8" r="1.2" fill="#fff"/></svg>';
1233
+ const EXPAND_SVG =
1234
+ '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" ' +
1235
+ 'stroke-linecap="round" aria-hidden="true">' +
1236
+ '<path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>';
1237
+
1238
+ class CardStage extends HTMLElement {
1239
+ connectedCallback() {
1240
+ if (this.dataset.ready) return;
1241
+ this.dataset.ready = '1';
1242
+ // The script is usually loaded synchronously from <head>, at which point the child
1243
+ // <section>s are not parsed yet — wait for the document before initialising
1244
+ if (document.readyState === 'loading') {
1245
+ document.addEventListener('DOMContentLoaded', () => this.init(), { once: true });
1246
+ } else {
1247
+ this.init();
1248
+ }
1249
+ }
1250
+
1251
+ async init() {
1252
+ // Safety net: even if init throws halfway, the page has to become visible — one flash
1253
+ // beats staying blank forever
1254
+ const ready = () => document.documentElement.setAttribute('data-cs-ready', '');
1255
+ setTimeout(ready, 3000);
1256
+ if (!document.getElementById(STYLE_ID)) {
1257
+ const style = document.createElement('style');
1258
+ style.id = STYLE_ID;
1259
+ style.textContent = CSS;
1260
+ document.head.appendChild(style);
1261
+ }
1262
+
1263
+ const ratio = this.getAttribute('ratio') || '3:4';
1264
+ const parsed = parseRatio(ratio);
1265
+ if (!parsed) {
1266
+ console.error(
1267
+ `[card-stage] ratio="${ratio}" is unusable: either it is not written as "W:H", or the long edge exceeds ${MAX_EDGE}px.` +
1268
+ ' Fell back to 3:4 — use a supported ratio, or give width / height directly.',
1269
+ );
1270
+ }
1271
+ const [defW, defH] = parsed || RATIOS['3:4'];
1272
+ this.W = Number(this.getAttribute('width')) || defW;
1273
+ this.H = Number(this.getAttribute('height')) || defH;
1274
+
1275
+ const params = new URLSearchParams(location.search);
1276
+ this.exportMode = params.get('export') === '1';
1277
+ this.only = params.has('card') ? Number(params.get('card')) : null;
1278
+ // Guides and index labels are for self-checking and stay hidden by default — what the
1279
+ // user sees is the finished artifact
1280
+ this.devChrome = params.get('guides') === '1';
1281
+
1282
+ const sections = [...this.children].filter((el) => el.tagName === 'SECTION');
1283
+ // Source-structure fingerprint: once the agent regenerates this artifact (the card count
1284
+ // or the labels changed), previously saved deletes and renames no longer line up with
1285
+ // positions and the whole lot is discarded. Same semantics as design-canvas's srcKey.
1286
+ this.srcKey = sections.length + '#' + sections.map((s) => s.dataset.label || '').join('|');
1287
+ sections.forEach((section, i) => {
1288
+ section.dataset.cardKey = section.dataset.label || 'card-' + (i + 1);
1289
+ });
1290
+
1291
+ // Read the sidecar before laying out: rendering first and deleting after shows a flash
1292
+ const state = await this.loadState();
1293
+ const fresh = state.srcKey === this.srcKey;
1294
+ this._hiddenKeys = fresh && Array.isArray(state.hidden) ? state.hidden.slice() : [];
1295
+ this._labelOverrides = fresh && state.labels && typeof state.labels === 'object' ? { ...state.labels } : {};
1296
+ const kept = [];
1297
+ for (const section of sections) {
1298
+ if (this._hiddenKeys.includes(section.dataset.cardKey)) {
1299
+ section.remove();
1300
+ continue;
1301
+ }
1302
+ const saved = this._labelOverrides[section.dataset.cardKey];
1303
+ if (saved) section.dataset.label = saved;
1304
+ kept.push(section);
1305
+ }
1306
+
1307
+ kept.forEach((section, i) => {
1308
+ const n = i + 1;
1309
+ section.dataset.cardIndex = String(n);
1310
+ if (this.only !== null && this.only !== n) {
1311
+ section.remove();
1312
+ return;
1313
+ }
1314
+ section.style.width = this.W + 'px';
1315
+ section.style.height = this.H + 'px';
1316
+ // Frame-dependent spacing is computed from these two variables; hard-coded px break as
1317
+ // soon as the frame changes
1318
+ section.style.setProperty('--cs-w', this.W + 'px');
1319
+ section.style.setProperty('--cs-h', this.H + 'px');
1320
+
1321
+ if (this.devChrome && !this.exportMode) {
1322
+ const safe = document.createElement('div');
1323
+ safe.className = 'cs-safe';
1324
+ safe.innerHTML = '<i></i><u></u><i></i>';
1325
+ section.appendChild(safe);
1326
+ }
1327
+ });
1328
+
1329
+ if (this.exportMode) {
1330
+ this.setAttribute('data-export', '');
1331
+ document.documentElement.setAttribute('data-cs-export', '');
1332
+ ready();
1333
+ return;
1334
+ }
1335
+
1336
+ document.documentElement.setAttribute('data-cs-canvas', '');
1337
+ // The world's ground colour belongs to the product and authors must not paint it — the
1338
+ // !important in the stylesheet cannot outrank a more specific author rule, and only an
1339
+ // inline important is the specificity ceiling.
1340
+ this.style.setProperty('background', 'transparent', 'important');
1341
+ this.zoom = 1;
1342
+ this.panX = 0;
1343
+ this.panY = 0;
1344
+ if (csIsMobile) {
1345
+ // A completely different browsing mode from desktop: one card per screen, swipe to
1346
+ // page, copy / download in the bottom bar.
1347
+ // No editing chrome (headers, zoom pill) is rendered at all, and no focus overlay is
1348
+ // needed either — a card already fills the screen.
1349
+ document.documentElement.setAttribute('data-cs-mobile', '');
1350
+ this.mobileInit();
1351
+ } else {
1352
+ // Give it a wrapping width, otherwise the cards form one long strip and "fit" comes out
1353
+ // as a single-digit percentage
1354
+ const gap = 120;
1355
+ const cols = Math.min(4, Math.max(1, this.cards().length));
1356
+ this.style.width = cols * this.W + (cols + 1) * gap + 'px';
1357
+ this.buildHeads();
1358
+ this.buildZoomBar();
1359
+ this.wireCanvas();
1360
+ this.fit();
1361
+ // The host's preview iframe may not have its real size yet when it mounts, and the fit
1362
+ // computed at that moment gets floored to MIN_PCT — 5%, leaving a handful of thumbnails
1363
+ // on the canvas. Recompute whenever the size changes, until the user frames the view
1364
+ // themselves; the "fit" button and the 0 key do not count as framing, since following
1365
+ // the viewport is exactly what they ask for.
1366
+ addEventListener('resize', () => {
1367
+ if (this.userFramed) { this.placeHeads(); return; }
1368
+ this.fit();
1369
+ });
1370
+ }
1371
+ ready();
1372
+ void this.reportOverflow();
1373
+ }
1374
+
1375
+ // —— sidecar: persisting deletes / renames ——
1376
+ /**
1377
+ * Only read inside the host iframe: with no host nobody ever wrote this file, so requesting
1378
+ * it just earns a 404, and preflight counts a same-origin >=400 as a hard failure
1379
+ * (sameOriginRequestFailures in probe.sh) — every card artifact would fail falsely once.
1380
+ * The cost is that opening the artifact outside the host (direct link, downloaded zip) does
1381
+ * not restore deletes and renames — but those two actions are only possible in the host
1382
+ * anyway.
1383
+ */
1384
+ async loadState() {
1385
+ if (typeof window === 'undefined' || window.parent === window) return {};
1386
+ try {
1387
+ const r = await fetch('./' + STATE_FILE, { cache: 'no-store' });
1388
+ if (!r.ok) return {};
1389
+ const s = await r.json();
1390
+ return s && typeof s === 'object' ? s : {};
1391
+ } catch {
1392
+ return {}; // unreachable, or not JSON (a dev server's SPA fallback answers index.html) — treat both as empty state
1393
+ }
1394
+ }
1395
+
1396
+ saveState() {
1397
+ hostWriteFile(STATE_FILE, JSON.stringify({
1398
+ srcKey: this.srcKey,
1399
+ hidden: this._hiddenKeys,
1400
+ labels: this._labelOverrides,
1401
+ }));
1402
+ }
1403
+
1404
+ /**
1405
+ * Cards are static images at a fixed frame, so content past the frame IS a defect — but
1406
+ * `overflow: hidden` clips it silently and the source gives nothing away. Measuring it here
1407
+ * and reporting through console.error makes overflow a hard pre-commit gate for free:
1408
+ * console errors are one of preflight's three runtime signals, so preflight itself needs no
1409
+ * geometry checks at all. With ?guides=1 it is also outlined in red with the overflow
1410
+ * amount, for a human self-check.
1411
+ */
1412
+ async reportOverflow() {
1413
+ // Font substitution changes text height, so measuring before the fonts load is a
1414
+ // guaranteed false negative
1415
+ try { await document.fonts.ready; } catch { /* measure anyway if font loading failed */ }
1416
+ await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)));
1417
+
1418
+ const TOL = 2; // sub-pixel rounding tolerance
1419
+ for (const section of this.cards()) {
1420
+ const n = Number(section.dataset.cardIndex);
1421
+ const overY = section.scrollHeight - section.clientHeight;
1422
+ const overX = section.scrollWidth - section.clientWidth;
1423
+ if (overY <= TOL && overX <= TOL) continue;
1424
+
1425
+ const label = section.dataset.label ? `「${section.dataset.label}」` : '';
1426
+ const parts = [];
1427
+ if (overY > TOL) parts.push(`${Math.round(overY)}px past the frame vertically (content ${section.scrollHeight} > frame ${this.H})`);
1428
+ if (overX > TOL) parts.push(`${Math.round(overX)}px past the frame horizontally (content ${section.scrollWidth} > frame ${this.W})`);
1429
+ console.error(`[card-stage] card ${n}${label} overflows its frame and that part will be clipped on export: ${parts.join('; ')}`);
1430
+
1431
+ if (this.devChrome) {
1432
+ const mark = document.createElement('div');
1433
+ mark.className = 'cs-over';
1434
+ mark.innerHTML = `<b>+${Math.round(Math.max(overY, overX))}px</b>`;
1435
+ section.appendChild(mark);
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ cards() {
1441
+ return [...this.querySelectorAll(':scope > section')];
1442
+ }
1443
+
1444
+ cardAt(n) {
1445
+ return this.querySelector(`:scope > section[data-card-index="${n}"]`);
1446
+ }
1447
+
1448
+ /** Render card n to a PNG Blob at the frame's native size (1-indexed). Triggers no
1449
+ download; this is the entry point for external export paths. */
1450
+ renderCard(n) {
1451
+ const section = this.cardAt(n);
1452
+ if (!section) return Promise.reject(new Error('no card ' + n));
1453
+ return renderCardPng(section, this.W, this.H);
1454
+ }
1455
+
1456
+ /**
1457
+ * The download filename IS the card label, e.g. 「01 封面.png」. It deliberately omits the
1458
+ * artifact name: that name can be changed on the platform side and document.title inside
1459
+ * the sandbox never sees the new value, so including it would simply be wrong.
1460
+ * Duplicate labels get a browser-appended "(1)" — the label is the user's to edit, so this
1461
+ * does not rename anything on their behalf.
1462
+ */
1463
+ fileName(n) {
1464
+ const section = this.cardAt(n);
1465
+ const label = ((section && section.dataset.label) || '').trim();
1466
+ const base = label || `卡片 ${String(n).padStart(2, '0')}`;
1467
+ return `${base.replace(/[^\p{L}\p{N}\s._-]+/gu, '_')}.png`;
1468
+ }
1469
+
1470
+ /**
1471
+ * Render and save card n (1-indexed). A false return means the user cancelled in the share
1472
+ * sheet, which is how the caller decides whether to show a success toast.
1473
+ */
1474
+ async downloadCard(n) {
1475
+ const name = this.fileName(n);
1476
+ const blob = await this.renderCard(n);
1477
+ // Only in-app WebViews need the detour: there a blob: handed to the host is rejected as
1478
+ // a 0-byte download (observed as "0 / 160.71 kB download failed"). <a download> works
1479
+ // fine in ordinary mobile browsers — do not replace it with the share sheet or the
1480
+ // long-press layer.
1481
+ if (csIsMobile && csInAppWebView) {
1482
+ if (canShareFiles()) {
1483
+ try {
1484
+ await navigator.share({ files: [new File([blob], name, { type: blob.type })], title: name });
1485
+ return true;
1486
+ } catch (e) {
1487
+ if (e && e.name === 'AbortError') return false; // the user cancelled; not a failure
1488
+ // share exists but refuses to run (a stale gesture, or a WebView shipping a stub
1489
+ // that only throws). That is not a verdict of failure — fall through to the
1490
+ // long-press layer, which works everywhere
1491
+ }
1492
+ }
1493
+ // Return false: this only hands the image to the user, nothing is saved yet, so the
1494
+ // caller must not report success
1495
+ await this.showSaveSheet(blob);
1496
+ return false;
1497
+ }
1498
+ const a = document.createElement('a');
1499
+ a.download = name;
1500
+ a.href = URL.createObjectURL(blob);
1501
+ a.click();
1502
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1503
+ return true;
1504
+ }
1505
+
1506
+ /**
1507
+ * Long-press save layer. Inside a WebView this is the only universally available path for
1508
+ * saving or copying an image — the system's own long-press menu already offers "save image"
1509
+ * and "copy image". It uses data:, not blob:, because the host handling that long press
1510
+ * also has to be able to read the bytes.
1511
+ */
1512
+ async showSaveSheet(blob) {
1513
+ const dataUrl = await new Promise((res, rej) => {
1514
+ const fr = new FileReader();
1515
+ fr.onload = () => res(fr.result);
1516
+ fr.onerror = () => rej(new Error('read blob failed'));
1517
+ fr.readAsDataURL(blob);
1518
+ });
1519
+ const sheet = document.createElement('div');
1520
+ sheet.className = 'cs-save';
1521
+ const img = document.createElement('img');
1522
+ img.src = dataUrl;
1523
+ img.alt = '';
1524
+ // A few hundred KB of data URL takes a second or two to decode on iOS, so attaching it
1525
+ // directly shows the user an empty frame first. Decode before attaching: the sheet appears
1526
+ // slightly later but arrives complete. Without decode support, behave as before.
1527
+ if (typeof img.decode === 'function') {
1528
+ try { await img.decode(); } catch { /* show the sheet even if decoding failed */ }
1529
+ }
1530
+ const hint = document.createElement('p');
1531
+ hint.textContent = '长按图片,选择「保存图片」或「复制图片」';
1532
+ // A WebView shows no console, so a missing image has to be stated on screen — otherwise
1533
+ // the user saves a picture with holes without ever knowing
1534
+ if (blob && blob.csInlineMissed) {
1535
+ // Include the reason too: with no console in a WebView, leaving it out means another
1536
+ // trip to the device
1537
+ hint.textContent += `(${blob.csInlineMissed} 张图未载入:${blob.csInlineWhy || '未知'})`;
1538
+ }
1539
+ const close = document.createElement('button');
1540
+ close.textContent = '关闭';
1541
+ close.onclick = () => sheet.remove();
1542
+ sheet.append(img, hint, close);
1543
+ sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.remove(); });
1544
+ document.body.appendChild(csChrome(sheet));
1545
+ this._saveSheet = sheet;
1546
+ }
1547
+
1548
+ /** Download every card, one at a time. Serial — running them in parallel trips the
1549
+ browser's multiple-download blocker. */
1550
+ async downloadAll(onProgress) {
1551
+ const cards = this.cards();
1552
+ for (let i = 0; i < cards.length; i++) {
1553
+ if (onProgress) onProgress(i + 1, cards.length);
1554
+ await this.downloadCard(Number(cards[i].dataset.cardIndex));
1555
+ }
1556
+ }
1557
+
1558
+ // —— The header above each card: editable label + download + expand ——
1559
+ buildHeads() {
1560
+ this.headFor = new Map();
1561
+ this.heads = this.cards().map((section, i) => {
1562
+ const n = i + 1;
1563
+ const head = document.createElement('div');
1564
+ head.className = 'cs-head';
1565
+
1566
+ const label = document.createElement('span');
1567
+ label.className = 'cs-label';
1568
+ label.contentEditable = 'true';
1569
+ label.spellcheck = false;
1570
+ label.textContent = section.dataset.label || `卡片 ${n}`;
1571
+ label.addEventListener('blur', () => {
1572
+ section.dataset.label = label.textContent.trim();
1573
+ this._labelOverrides[section.dataset.cardKey] = section.dataset.label;
1574
+ this.saveState();
1575
+ });
1576
+ label.addEventListener('keydown', (e) => {
1577
+ if (e.key === 'Enter') { e.preventDefault(); label.blur(); }
1578
+ });
1579
+
1580
+ const acts = document.createElement('span');
1581
+ acts.className = 'cs-acts';
1582
+
1583
+ // Card indices shift after a delete, so every action reads the current one at click
1584
+ // time — an n captured in the closure goes stale
1585
+ const more = document.createElement('button');
1586
+ more.className = 'cs-iconbtn';
1587
+ more.innerHTML = MORE_SVG;
1588
+ more.title = '更多';
1589
+ let menu = null;
1590
+ const closeMenu = () => { if (menu) { menu.remove(); menu = null; } };
1591
+ more.onclick = (e) => {
1592
+ e.stopPropagation();
1593
+ if (menu) { closeMenu(); return; }
1594
+ menu = document.createElement('div');
1595
+ menu.className = 'cs-menu';
1596
+ menu.append(...this.buildMenuItems(Number(section.dataset.cardIndex), closeMenu));
1597
+ head.appendChild(menu);
1598
+ };
1599
+ addEventListener('pointerdown', (e) => {
1600
+ if (menu && !head.contains(e.target)) closeMenu();
1601
+ }, true);
1602
+
1603
+ const expand = document.createElement('button');
1604
+ expand.className = 'cs-iconbtn';
1605
+ expand.innerHTML = EXPAND_SVG;
1606
+ expand.title = '放大查看';
1607
+ expand.onclick = (e) => { e.stopPropagation(); this.openFocus(Number(section.dataset.cardIndex)); };
1608
+ acts.append(more, expand);
1609
+
1610
+ head.append(label, acts);
1611
+ this.appendChild(csChrome(head));
1612
+ this.headFor.set(section, head);
1613
+
1614
+ // The header is an absolutely-positioned sibling, so a CSS :hover on it cannot reach the
1615
+ // card (design-canvas wraps both in a [data-dc-slot]); the only option is to listen on
1616
+ // both sides and sync a shared class across
1617
+ const setHot = (on) => {
1618
+ head.classList.toggle('cs-hot', on);
1619
+ section.classList.toggle('cs-hot', on);
1620
+ };
1621
+ head.addEventListener('mouseenter', () => setHot(true));
1622
+ head.addEventListener('mouseleave', () => setHot(false));
1623
+ section.addEventListener('mouseenter', () => setHot(true));
1624
+ section.addEventListener('mouseleave', () => setHot(false));
1625
+
1626
+ return head;
1627
+ });
1628
+ this.placeHeads();
1629
+ }
1630
+
1631
+ /**
1632
+ * Destructive confirm. Resolves true only if the user picks delete.
1633
+ * Cancel paths: the cancel button, Esc, clicking the scrim — all resolve false, so a stray
1634
+ * click never destroys a card.
1635
+ * Focus goes to cancel, not delete: Enter right after opening must not delete.
1636
+ */
1637
+ confirmDelete(name) {
1638
+ return new Promise((resolve) => {
1639
+ const scrim = document.createElement('div');
1640
+ scrim.className = 'cs-dialog';
1641
+ scrim.innerHTML =
1642
+ '<div class="cs-dialog-panel" role="alertdialog" aria-modal="true">' +
1643
+ '<div class="cs-dialog-head">' +
1644
+ '<div class="cs-dialog-title">' +
1645
+ '<span class="cs-dialog-icon" aria-hidden="true">' + CS_ICON_WARN + '</span>' +
1646
+ `<span>确定删除“${name}”吗?</span>` +
1647
+ '</div>' +
1648
+ '<div class="cs-dialog-desc">删除后内容无法恢复,请确认操作</div>' +
1649
+ '</div>' +
1650
+ '<div class="cs-dialog-acts">' +
1651
+ '<button type="button" class="cs-btn-danger">删除</button>' +
1652
+ '<button type="button" class="cs-btn-ghost">取消</button>' +
1653
+ '</div></div>';
1654
+ const done = (ok) => {
1655
+ removeEventListener('keydown', onKey, true);
1656
+ scrim.remove();
1657
+ resolve(ok);
1658
+ };
1659
+ const onKey = (e) => {
1660
+ if (e.key !== 'Escape') return;
1661
+ // Capture phase + stopPropagation: the focus overlay also listens for Esc, and
1662
+ // without this one press would close both the dialog and the overlay behind it.
1663
+ e.stopPropagation();
1664
+ done(false);
1665
+ };
1666
+ const confirm = scrim.querySelector('.cs-btn-danger');
1667
+ const cancel = scrim.querySelector('.cs-btn-ghost');
1668
+ cancel.onclick = () => done(false);
1669
+ confirm.onclick = () => done(true);
1670
+ scrim.addEventListener('click', (e) => { if (e.target === scrim) done(false); });
1671
+ addEventListener('keydown', onKey, true);
1672
+ document.body.appendChild(csChrome(scrim));
1673
+ cancel.focus();
1674
+ });
1675
+ }
1676
+
1677
+ /** Delete one card: remove it from the view and write the sidecar, so it stays gone
1678
+ across a reload */
1679
+ deleteCard(n) {
1680
+ const section = this.cardAt(n);
1681
+ if (!section) return;
1682
+ if (this.focusIndex === n) this.closeFocus();
1683
+ const key = section.dataset.cardKey;
1684
+ const head = this.headFor && this.headFor.get(section);
1685
+ if (head) head.remove();
1686
+ if (this.headFor) this.headFor.delete(section);
1687
+ section.remove();
1688
+ if (key && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
1689
+ this.saveState();
1690
+
1691
+ const cards = this.cards();
1692
+ cards.forEach((s, i) => { s.dataset.cardIndex = String(i + 1); });
1693
+ this.heads = cards.map((s) => this.headFor.get(s));
1694
+ this.placeHeads();
1695
+ this.toast('已删除');
1696
+ }
1697
+
1698
+ /**
1699
+ * Copy the given card to the clipboard. The host iframe must carry
1700
+ * allow="clipboard-write"; without it the browser refuses outright — so this states the
1701
+ * problem and points back to download rather than failing silently.
1702
+ */
1703
+ async copyCard(n) {
1704
+ try {
1705
+ if (!navigator.clipboard || !window.ClipboardItem) throw new Error('clipboard unsupported');
1706
+ // Mobile in-app WebViews (Android Feishu and friends) have no image clipboard path at
1707
+ // all: Android's ClipData has no image type, Chromium can only write one through a
1708
+ // FileProvider the embedder registers to produce a content:// URI, and a WebView does
1709
+ // not register one — write() resolves while the clipboard stays empty (verified on
1710
+ // device). Writing text/html with an embedded data: image does land, but only targets
1711
+ // that parse HTML (Feishu Docs) turn it back into an image; a chat composer still gets
1712
+ // text, and it needs pre-rendering to survive the gesture check — the payoff does not
1713
+ // justify that machinery, so point back to download instead.
1714
+ // The bottom bar hides the copy button in this environment, so this branch covers an
1715
+ // external caller invoking copyCard directly
1716
+ if (csAndroidInAppWebView) {
1717
+ this.toast('复制不可用,请使用下载', false);
1718
+ return false;
1719
+ }
1720
+ // Hand the Promise to ClipboardItem instead of awaiting the blob first: clipboard.write
1721
+ // has to be initiated in the synchronous part of the user gesture, and awaiting the
1722
+ // render (which itself waits on fonts and fetches images) expires the gesture — WebKit
1723
+ // then throws NotAllowedError outright. The spec allows a Promise here and Blink accepts
1724
+ // it too, so one code path covers both engines.
1725
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': this.renderCard(n) })]);
1726
+ this.toast('复制成功');
1727
+ return true;
1728
+ } catch {
1729
+ // A WebView may not even throw (it just drops the write), so this failure toast only
1730
+ // covers explicit rejections
1731
+ this.toast('复制失败,请改用下载', false);
1732
+ return false;
1733
+ }
1734
+ }
1735
+
1736
+ /** Result feedback. One node is reused, so rapid clicks do not stack overlays */
1737
+ toast(text, ok = true) {
1738
+ if (!this._toast) {
1739
+ this._toast = document.createElement('div');
1740
+ this._toast.className = 'cs-toast';
1741
+ document.body.appendChild(csChrome(this._toast));
1742
+ }
1743
+ const el = this._toast;
1744
+ el.innerHTML = ok ? TOAST_OK_SVG : TOAST_ERR_SVG;
1745
+ const span = document.createElement('span');
1746
+ span.textContent = text;
1747
+ el.appendChild(span);
1748
+ requestAnimationFrame(() => el.classList.add('on'));
1749
+ clearTimeout(this._toastTimer);
1750
+ this._toastTimer = setTimeout(() => el.classList.remove('on'), 1600);
1751
+ }
1752
+
1753
+ placeHeads() {
1754
+ const inv = 1 / this.zoom;
1755
+ this.style.setProperty('--cs-inv', String(inv));
1756
+ if (!this.heads) return;
1757
+ this.cards().forEach((section, i) => {
1758
+ const head = this.heads[i];
1759
+ if (!head) return;
1760
+ head.style.left = section.offsetLeft - 4 + 'px';
1761
+ // transform-origin is bottom left, so the box's visual bottom edge is pinned at
1762
+ // top + 24 (world units) — put that 4 screen pixels above the card
1763
+ head.style.top = section.offsetTop - 24 - 4 * inv + 'px';
1764
+ // After counter-scaling, screen width equals world width, so the world width has to be
1765
+ // the card's SCREEN width, i.e. (W + 4) * zoom. Multiplying by inv instead would blow it
1766
+ // up by 1/zoom² and stretch it across the whole viewport.
1767
+ head.style.width = (this.W + 4) * this.zoom + 'px';
1768
+ head.style.transform = `scale(${inv})`;
1769
+ });
1770
+ }
1771
+
1772
+ // —— Canvas: wheel to zoom, drag empty space to pan ——
1773
+ wireCanvas() {
1774
+ this.applyTransform();
1775
+
1776
+ addEventListener('wheel', (e) => {
1777
+ if (this.overlay) return;
1778
+ e.preventDefault();
1779
+ if (e.ctrlKey || e.metaKey) {
1780
+ this.zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY / 240));
1781
+ } else {
1782
+ this.userFramed = true;
1783
+ this.panX -= e.deltaX;
1784
+ this.panY -= e.deltaY;
1785
+ this.applyTransform();
1786
+ }
1787
+ }, { passive: false });
1788
+
1789
+ let dragging = false, lastX = 0, lastY = 0;
1790
+ addEventListener('pointerdown', (e) => {
1791
+ if (this.overlay) return;
1792
+ if (e.target.closest('.cs-zoom, .cs-label, .cs-iconbtn')) return;
1793
+ // Dragging on card content does not pan, so it never fights text selection
1794
+ const onCard = !!e.target.closest('card-stage > section');
1795
+ if (onCard) return;
1796
+ dragging = true; lastX = e.clientX; lastY = e.clientY;
1797
+ document.body.style.cursor = 'grabbing';
1798
+ });
1799
+ addEventListener('pointermove', (e) => {
1800
+ if (!dragging) return;
1801
+ this.userFramed = true;
1802
+ this.panX += e.clientX - lastX;
1803
+ this.panY += e.clientY - lastY;
1804
+ lastX = e.clientX; lastY = e.clientY;
1805
+ this.applyTransform();
1806
+ });
1807
+ addEventListener('pointerup', () => {
1808
+ dragging = false;
1809
+ document.body.style.cursor = '';
1810
+ });
1811
+
1812
+ // A click on a card opens focus mode (but not when the click lands on a link, button or
1813
+ // the label)
1814
+ this.addEventListener('click', (e) => {
1815
+ const section = e.target.closest('card-stage > section');
1816
+ if (!section) return;
1817
+ if (e.target.closest('a, button, input, textarea, [contenteditable]')) return;
1818
+ this.openFocus(Number(section.dataset.cardIndex));
1819
+ });
1820
+
1821
+ // Right-click on a canvas card shares one menu with focus mode and the header "⋯"
1822
+ this.addEventListener('contextmenu', (e) => {
1823
+ const section = e.target.closest('card-stage > section');
1824
+ if (!section) return;
1825
+ e.preventDefault();
1826
+ this.openContextMenu(Number(section.dataset.cardIndex), e.clientX, e.clientY);
1827
+ });
1828
+ addEventListener('pointerdown', (e) => {
1829
+ if (this._ctxMenu && !this._ctxMenu.contains(e.target)) this.closeContextMenu();
1830
+ }, true);
1831
+
1832
+ addEventListener('keydown', (e) => {
1833
+ if (e.target.closest('input, textarea, [contenteditable]')) return;
1834
+ if (e.key === 'Escape' && this._ctxMenu) { this.closeContextMenu(); return; }
1835
+ if (this.focusIndex) {
1836
+ if (e.key === 'Escape') this.closeFocus();
1837
+ if (e.key === 'ArrowLeft') this.openFocus(this.focusIndex - 1);
1838
+ if (e.key === 'ArrowRight') this.openFocus(this.focusIndex + 1);
1839
+ return;
1840
+ }
1841
+ if (e.key === '0') this.fit();
1842
+ if (e.key === '=' || e.key === '+') this.zoomBy(1.2);
1843
+ if (e.key === '-') this.zoomBy(1 / 1.2);
1844
+ if (this.devChrome && e.key === 'g') this.toggleGuides();
1845
+ });
1846
+ }
1847
+
1848
+ applyTransform() {
1849
+ this.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
1850
+ this.syncZoomBar();
1851
+ this.placeHeads();
1852
+ }
1853
+
1854
+ zoomAt(cx, cy, factor) {
1855
+ this.userFramed = true;
1856
+ const next = Math.min(2, Math.max(0.05, this.zoom * factor));
1857
+ const k = next / this.zoom;
1858
+ // Zoom around the cursor: the world coordinate under it stays put
1859
+ this.panX = cx - (cx - this.panX) * k;
1860
+ this.panY = cy - (cy - this.panY) * k;
1861
+ this.zoom = next;
1862
+ this.applyTransform();
1863
+ }
1864
+
1865
+ zoomBy(factor) {
1866
+ this.zoomAt(innerWidth / 2, innerHeight / 2, factor);
1867
+ }
1868
+
1869
+ fit() {
1870
+ // Measure the content at 1× first, then solve for the zoom that fills the viewport
1871
+ this.zoom = 1;
1872
+ this.panX = 0;
1873
+ this.panY = 0;
1874
+ this.style.transform = 'none';
1875
+ const w = this.scrollWidth;
1876
+ const h = this.scrollHeight;
1877
+ const z = Math.min((innerWidth - 40) / w, (innerHeight - 40) / h, 1);
1878
+ this.zoom = Math.max(MIN_PCT / 100, z);
1879
+ this.panX = (innerWidth - w * this.zoom) / 2;
1880
+ this.panY = (innerHeight - h * this.zoom) / 2;
1881
+ this.applyTransform();
1882
+ }
1883
+
1884
+ // —— Mobile: one card per screen, swipe to page ——
1885
+ // Deliberately unlike design-canvas's mobile mode (pan / pinch across the whole canvas):
1886
+ // cards are finished images meant to be viewed and saved one at a time, so the unit of
1887
+ // browsing IS one card. Paging reuses the canvas's existing world transform rather than
1888
+ // introducing a second layout mechanism — the cards stay direct children of card-stage.
1889
+ mobileInit() {
1890
+ this.style.width = 'auto';
1891
+ this.page = 1;
1892
+ this.buildMobileBar();
1893
+ this.layoutMobile();
1894
+ this.wireSwipe();
1895
+ addEventListener('resize', () => this.layoutMobile());
1896
+ addEventListener('orientationchange', () => setTimeout(() => this.layoutMobile(), 80));
1897
+ }
1898
+
1899
+ /** Height taken by the bottom bar (dots + two buttons + safe area); cards centre clear
1900
+ of it */
1901
+ get mobileBarH() {
1902
+ return 132;
1903
+ }
1904
+
1905
+ layoutMobile() {
1906
+ const availH = Math.max(160, innerHeight - this.mobileBarH);
1907
+ // Fill the width but stay height-constrained too, so tall frames like 9:16 do not
1908
+ // overflow a short screen (there the card ends up narrower than the viewport)
1909
+ this.zoom = Math.min(innerWidth / this.W, (availH - 24) / this.H);
1910
+ // The inter-card gap has to exceed the side margin, or the next card's edge peeks into
1911
+ // the screen — in a one-card-per-screen mode that sliver reads as a rendering artefact.
1912
+ // This parks the neighbouring card exactly 16px off screen:
1913
+ // next card's left edge = viewport - margin + gap, and that must be >= viewport + 16,
1914
+ // so gap >= margin + 16
1915
+ // The margin is derived from the actual rendered width: zero when width-constrained, and
1916
+ // non-zero when height-constrained (the card is then narrower than the viewport).
1917
+ // --cs-gap is in world units, so divide by zoom to convert back.
1918
+ const sideMargin = Math.max(0, (innerWidth - this.W * this.zoom) / 2);
1919
+ this.style.setProperty('--cs-gap', (sideMargin + 16) / this.zoom + 'px');
1920
+ this.goToPage(this.page, false);
1921
+ }
1922
+
1923
+ goToPage(n, animate = true) {
1924
+ const cards = this.cards();
1925
+ if (!cards.length) return;
1926
+ this.page = Math.min(cards.length, Math.max(1, n));
1927
+ const section = this.cardAt(this.page) || cards[this.page - 1];
1928
+ const availH = Math.max(160, innerHeight - this.mobileBarH);
1929
+ // Both axes have to subtract the card's offset inside the world (card-stage carries its
1930
+ // own --cs-gap padding); computing only (availH - cardHeight)/2 folds that padding into
1931
+ // the centring and sinks the card by a full padding
1932
+ this.panX = innerWidth / 2 - (section.offsetLeft + this.W / 2) * this.zoom;
1933
+ this.panY = (availH - this.H * this.zoom) / 2 - section.offsetTop * this.zoom;
1934
+ this.style.transition = animate ? 'transform .26s cubic-bezier(.22,.61,.36,1)' : 'none';
1935
+ this.applyTransform();
1936
+ if (animate) {
1937
+ clearTimeout(this._slideTimer);
1938
+ this._slideTimer = setTimeout(() => { this.style.transition = 'none'; }, 300);
1939
+ }
1940
+ this.syncDots();
1941
+ }
1942
+
1943
+ wireSwipe() {
1944
+ let startX = 0;
1945
+ let startY = 0;
1946
+ let basePan = 0;
1947
+ let dragging = false;
1948
+ let axis = null;
1949
+ let startedAt = 0;
1950
+
1951
+ this.addEventListener('pointerdown', (e) => {
1952
+ dragging = true;
1953
+ axis = null;
1954
+ startX = e.clientX;
1955
+ startY = e.clientY;
1956
+ basePan = this.panX;
1957
+ startedAt = Date.now();
1958
+ this.style.transition = 'none';
1959
+ try { this.setPointerCapture(e.pointerId); } catch { /* capture failed: fall back to the ordinary event flow */ }
1960
+ });
1961
+
1962
+ this.addEventListener('pointermove', (e) => {
1963
+ if (!dragging) return;
1964
+ const dx = e.clientX - startX;
1965
+ const dy = e.clientY - startY;
1966
+ // Lock the axis first: vertical gestures belong to the host page's scrolling, so do not
1967
+ // compete for them
1968
+ if (axis === null && Math.hypot(dx, dy) > 8) axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
1969
+ if (axis !== 'x') return;
1970
+ this.panX = basePan + dx;
1971
+ this.applyTransform();
1972
+ });
1973
+
1974
+ const end = (e) => {
1975
+ if (!dragging) return;
1976
+ dragging = false;
1977
+ try { this.releasePointerCapture(e.pointerId); } catch { /* as above */ }
1978
+ const dx = e.clientX - startX;
1979
+ // Both a flick (fast, with some travel) and a drag past the threshold page the deck;
1980
+ // anything else springs back.
1981
+ // Both thresholds are relative to the viewport width — mixing in absolute pixels would
1982
+ // let a tiny quick swipe page the deck on a wide viewport
1983
+ const flick = Date.now() - startedAt < 300 && Math.abs(dx) > innerWidth * 0.06;
1984
+ if (axis === 'x' && (Math.abs(dx) > innerWidth * 0.18 || flick)) {
1985
+ this.goToPage(this.page + (dx < 0 ? 1 : -1));
1986
+ } else {
1987
+ this.goToPage(this.page);
1988
+ }
1989
+ };
1990
+
1991
+ this.addEventListener('pointerup', end);
1992
+ this.addEventListener('pointercancel', end);
1993
+ }
1994
+
1995
+ buildMobileBar() {
1996
+ const bar = document.createElement('div');
1997
+ bar.className = 'cs-bar';
1998
+
1999
+ this.dots = document.createElement('div');
2000
+ this.dots.className = 'cs-dots';
2001
+ bar.appendChild(this.dots);
2002
+
2003
+ const acts = document.createElement('div');
2004
+ acts.className = 'cs-macts';
2005
+
2006
+ // Images cannot reach the system clipboard inside Android in-app WebViews, so tapping
2007
+ // this would always fail: the button is omitted there and saving goes through download.
2008
+ if (!csAndroidInAppWebView) {
2009
+ const copy = document.createElement('button');
2010
+ copy.innerHTML = COPY_SVG;
2011
+ copy.setAttribute('aria-label', '复制图片');
2012
+ copy.onclick = () => withBusy(copy, () => this.copyCard(this.page));
2013
+ acts.appendChild(copy);
2014
+ }
2015
+
2016
+ const dl = document.createElement('button');
2017
+ dl.innerHTML = DOWNLOAD_M_SVG;
2018
+ dl.setAttribute('aria-label', '下载');
2019
+ dl.onclick = () => withBusy(dl, async () => {
2020
+ try {
2021
+ if (await this.downloadCard(this.page)) this.toast('图片下载成功');
2022
+ } catch {
2023
+ this.toast('下载失败', false);
2024
+ }
2025
+ });
2026
+ acts.appendChild(dl);
2027
+ bar.appendChild(acts);
2028
+ document.body.appendChild(csChrome(bar));
2029
+ this.mobileBar = bar;
2030
+ this.syncDots();
2031
+ }
2032
+
2033
+ syncDots() {
2034
+ if (!this.dots) return;
2035
+ const total = this.cards().length;
2036
+ while (this.dots.children.length > total) this.dots.lastElementChild.remove();
2037
+ while (this.dots.children.length < total) this.dots.appendChild(document.createElement('i'));
2038
+ [...this.dots.children].forEach((dot, i) => {
2039
+ dot.className = i + 1 === this.page ? 'on' : '';
2040
+ dot.onclick = () => this.goToPage(i + 1);
2041
+ });
2042
+ }
2043
+
2044
+ toggleGuides() {
2045
+ this.guides = !this.guides;
2046
+ this.querySelectorAll('.cs-safe').forEach((el) => {
2047
+ el.style.display = this.guides ? '' : 'none';
2048
+ });
2049
+ }
2050
+
2051
+ pctValue() {
2052
+ return Math.round(this.zoom * 100);
2053
+ }
2054
+
2055
+ /** Zoom to a given percentage around the viewport centre; used by the pill and the presets */
2056
+ zoomToPct(pct) {
2057
+ this.zoomAt(innerWidth / 2, innerHeight / 2, pct / 100 / this.zoom);
2058
+ }
2059
+
2060
+ syncZoomBar() {
2061
+ if (!this.pct) return;
2062
+ const pct = this.pctValue();
2063
+ this.pct.textContent = pct + '%';
2064
+ if (this.zoomInBtn) this.zoomInBtn.disabled = pct >= MAX_PCT;
2065
+ if (this.zoomOutBtn) this.zoomOutBtn.disabled = pct <= MIN_PCT;
2066
+ }
2067
+
2068
+ // —— Bottom-right zoom pill (zoom only; export belongs to the product side) ——
2069
+ // Zoom out / percentage (opens the presets upward) / zoom in, in the same order as the
2070
+ // focus-mode bar at the bottom.
2071
+ // Plus and minus snap to whole 10% steps rather than multiplying by a fixed factor.
2072
+ buildZoomBar() {
2073
+ const bar = document.createElement('div');
2074
+ bar.className = 'cs-zoom';
2075
+
2076
+ this.zoomInBtn = document.createElement('button');
2077
+ this.zoomInBtn.className = 'cs-zoom-btn';
2078
+ this.zoomInBtn.title = '放大';
2079
+ this.zoomInBtn.innerHTML = ZOOM_IN_SVG;
2080
+ this.zoomInBtn.onclick = () =>
2081
+ this.zoomToPct(Math.min(MAX_PCT, (Math.floor(this.pctValue() / 10) + 1) * 10));
2082
+
2083
+ const toggle = document.createElement('div');
2084
+ toggle.className = 'cs-zoom-toggle';
2085
+ const trigger = document.createElement('button');
2086
+ trigger.className = 'cs-zoom-btn cs-zoom-trigger';
2087
+ this.pct = document.createElement('span');
2088
+ this.pct.className = 'cs-zoom-pct';
2089
+ this.pct.textContent = '100%';
2090
+ trigger.innerHTML = CARET_SVG;
2091
+ trigger.insertBefore(this.pct, trigger.firstChild);
2092
+
2093
+ let menu = null;
2094
+ const closeMenu = () => { if (menu) { menu.remove(); menu = null; } };
2095
+ trigger.onclick = (e) => {
2096
+ e.stopPropagation();
2097
+ if (menu) { closeMenu(); return; }
2098
+ menu = document.createElement('div');
2099
+ menu.className = 'cs-zoom-menu';
2100
+ // "Fit" sits first among the presets: design-canvas has no such entry (it only fits
2101
+ // automatically on load), but card frames are large and users zoom back and forth a lot,
2102
+ // and tucking it into the dropdown costs no extra button slot
2103
+ const rows = [['适应', null]].concat(ZOOM_PRESETS.map((p) => [p + '%', p]));
2104
+ for (const [text, pct] of rows) {
2105
+ const item = document.createElement('button');
2106
+ const label = document.createElement('span');
2107
+ label.textContent = text;
2108
+ item.appendChild(label);
2109
+ if (pct !== null && pct === this.pctValue()) item.insertAdjacentHTML('beforeend', TICK_SVG);
2110
+ item.onclick = (ev) => {
2111
+ ev.stopPropagation();
2112
+ closeMenu();
2113
+ if (pct === null) this.fit();
2114
+ else this.zoomToPct(pct);
2115
+ };
2116
+ menu.appendChild(item);
2117
+ }
2118
+ toggle.appendChild(menu);
2119
+ };
2120
+ addEventListener('pointerdown', (e) => {
2121
+ if (menu && !toggle.contains(e.target)) closeMenu();
2122
+ }, true);
2123
+ toggle.appendChild(trigger);
2124
+
2125
+ this.zoomOutBtn = document.createElement('button');
2126
+ this.zoomOutBtn.className = 'cs-zoom-btn';
2127
+ this.zoomOutBtn.title = '缩小';
2128
+ this.zoomOutBtn.innerHTML = ZOOM_OUT_SVG;
2129
+ this.zoomOutBtn.onclick = () =>
2130
+ this.zoomToPct(Math.max(MIN_PCT, (Math.ceil(this.pctValue() / 10) - 1) * 10));
2131
+
2132
+ bar.append(this.zoomOutBtn, toggle, this.zoomInBtn);
2133
+ document.body.appendChild(csChrome(bar));
2134
+ this.zoomBar = bar;
2135
+ this.syncZoomBar();
2136
+ }
2137
+
2138
+ /**
2139
+ * The three menu items (download / copy image / delete). The header "⋯", the canvas
2140
+ * right-click and the focus-mode right-click all share this one list; writing it three
2141
+ * times would inevitably drift (the delete confirmation is the easiest thing to end up
2142
+ * wired in only one of them).
2143
+ */
2144
+ buildMenuItems(n, closeMenu) {
2145
+ const dl = document.createElement('button');
2146
+ dl.textContent = '下载';
2147
+ dl.onclick = async (e) => {
2148
+ e.stopPropagation();
2149
+ closeMenu();
2150
+ try {
2151
+ if (await this.downloadCard(n)) this.toast('图片下载成功');
2152
+ } catch {
2153
+ this.toast('下载失败', false);
2154
+ }
2155
+ };
2156
+
2157
+ const cp = document.createElement('button');
2158
+ cp.textContent = '复制图片';
2159
+ cp.onclick = (e) => {
2160
+ e.stopPropagation();
2161
+ closeMenu();
2162
+ void this.copyCard(n);
2163
+ };
2164
+
2165
+ const del = document.createElement('button');
2166
+ del.className = 'danger';
2167
+ del.textContent = '删除';
2168
+ del.onclick = async (e) => {
2169
+ e.stopPropagation();
2170
+ // Close the menu first: the dialog is modal, leaving the menu open behind it reads
2171
+ // as two overlapping popups.
2172
+ closeMenu();
2173
+ const label = (this.cardAt(n) && this.cardAt(n).dataset.label) || `卡片 ${n}`;
2174
+ if (await this.confirmDelete(label)) this.deleteCard(n);
2175
+ };
2176
+
2177
+ // Copy before download: when publishing, pasting into a chat or a doc is the more
2178
+ // frequent action
2179
+ return [cp, dl, document.createElement('hr'), del];
2180
+ }
2181
+
2182
+ /** Open the context menu at the cursor. Used by both canvas cards and focus mode */
2183
+ openContextMenu(n, x, y) {
2184
+ this.closeContextMenu();
2185
+ const menu = document.createElement('div');
2186
+ menu.className = 'cs-menu cs-ctx';
2187
+ menu.append(...this.buildMenuItems(n, () => this.closeContextMenu()));
2188
+ menu.addEventListener('pointerdown', (e) => e.stopPropagation());
2189
+ document.body.appendChild(csChrome(menu));
2190
+ // Attach before measuring, so the menu can be clamped inside the viewport
2191
+ const r = menu.getBoundingClientRect();
2192
+ menu.style.left = Math.max(8, Math.min(x, innerWidth - r.width - 8)) + 'px';
2193
+ menu.style.top = Math.max(8, Math.min(y, innerHeight - r.height - 8)) + 'px';
2194
+ this._ctxMenu = menu;
2195
+ }
2196
+
2197
+ closeContextMenu() {
2198
+ if (this._ctxMenu) this._ctxMenu.remove();
2199
+ this._ctxMenu = null;
2200
+ }
2201
+
2202
+ // —— Fullscreen focus ——
2203
+ // Shaped after the design spec: a round close button top-right plus a dark pill at the
2204
+ // bottom (paging / zoom / copy / download).
2205
+ openFocus(n) {
2206
+ const total = this.cards().length;
2207
+ if (n < 1 || n > total) return;
2208
+ this.closeFocus();
2209
+ this.focusIndex = n;
2210
+
2211
+ const src = this.cardAt(n);
2212
+ const overlay = document.createElement('div');
2213
+ overlay.className = 'cs-focus';
2214
+
2215
+ const holder = document.createElement('div');
2216
+ holder.className = 'cs-holder';
2217
+ // The clone hangs off an overlay on body, so it is no longer a child of card-stage and
2218
+ // every author selector prefixed with it (`card-stage > section`, `card-stage h2`, …)
2219
+ // stops matching.
2220
+ // Restoring the section's own styles is not enough: rules that sit on descendants, like
2221
+ // fonts, are lost just the same.
2222
+ // Reusing the export path's cloneStyled to inline the computed style node by node keeps
2223
+ // the fidelity identical to the export.
2224
+ pseudoStubs = [];
2225
+ const clone = cloneStyled(src);
2226
+ materializePseudos();
2227
+ // The clone stays in the SAME document, so any author class selector without a
2228
+ // card-stage prefix still matches it: .x::before gets painted a second time by the author
2229
+ // stylesheet and stacks with the stub materializePseudos left behind, giving two flex
2230
+ // items and squeezing the line into a wrap (observed as the cover's bottom tag row
2231
+ // breaking with doubled dots). Stripping the classes makes author rules miss entirely,
2232
+ // leaving the inline values as the only description of the style — the same premise the
2233
+ // export path runs under.
2234
+ // ids are kept: SVG internals reference them through url(#id).
2235
+ clone.removeAttribute('class');
2236
+ for (const el of clone.querySelectorAll('[class]')) el.removeAttribute('class');
2237
+ clone.style.margin = '0';
2238
+ clone.style.boxShadow = 'none';
2239
+ clone.style.outline = 'none';
2240
+ clone.style.transformOrigin = 'top left';
2241
+ clone.removeAttribute('data-card-index');
2242
+ const frame = document.createElement('div');
2243
+ frame.style.position = 'relative';
2244
+ frame.appendChild(clone);
2245
+ holder.appendChild(frame);
2246
+
2247
+ // Focus mode keeps its own zoom, unrelated to the canvas zoom: fill the visible area on
2248
+ // entry, then let the user adjust
2249
+ this.focusFit = Math.min((innerWidth - 120) / this.W, (innerHeight - 200) / this.H, 1);
2250
+ this.focusZoom = this.focusFit;
2251
+ this._focusFrame = frame;
2252
+ this._focusClone = clone;
2253
+
2254
+ const close = document.createElement('button');
2255
+ close.className = 'cs-focus-close';
2256
+ close.title = '关闭';
2257
+ close.innerHTML = CLOSE_SVG;
2258
+ close.onclick = (e) => { e.stopPropagation(); this.closeFocus(); };
2259
+
2260
+ const bar = document.createElement('div');
2261
+ bar.className = 'cs-focus-bar';
2262
+ bar.addEventListener('pointerdown', (e) => e.stopPropagation());
2263
+
2264
+ const mk = (html, title, onClick) => {
2265
+ const b = document.createElement('button');
2266
+ b.className = 'cs-focus-btn';
2267
+ b.title = title;
2268
+ b.innerHTML = html;
2269
+ b.onclick = (e) => { e.stopPropagation(); onClick(b); };
2270
+ return b;
2271
+ };
2272
+
2273
+ const prev = mk(CHEV_L_SVG, '上一张', () => this.openFocus(this.focusIndex - 1));
2274
+ const counter = document.createElement('span');
2275
+ counter.className = 'cs-focus-txt';
2276
+ counter.textContent = `${n}/${total}`;
2277
+ const next = mk(CHEV_R_SVG, '下一张', () => this.openFocus(this.focusIndex + 1));
2278
+ prev.disabled = n === 1;
2279
+ next.disabled = n === total;
2280
+
2281
+ const zoomOut = mk(ZOOM_OUT_SVG, '缩小', () => this.setFocusZoom(this.focusZoom / 1.25));
2282
+ this._focusPct = document.createElement('span');
2283
+ this._focusPct.className = 'cs-focus-txt';
2284
+ const zoomIn = mk(ZOOM_IN_SVG, '放大', () => this.setFocusZoom(this.focusZoom * 1.25));
2285
+
2286
+ const copy = mk(COPY_SVG, '复制图片', (b) => withBusy(b, () => this.copyCard(this.focusIndex)));
2287
+ const download = mk(DOWNLOAD_M_SVG, '下载', (b) => withBusy(b, async () => {
2288
+ try {
2289
+ if (await this.downloadCard(this.focusIndex)) this.toast('图片下载成功');
2290
+ } catch {
2291
+ this.toast('下载失败', false);
2292
+ }
2293
+ }));
2294
+
2295
+ const sep = () => {
2296
+ const s = document.createElement('span');
2297
+ s.className = 'cs-focus-sep';
2298
+ return s;
2299
+ };
2300
+ bar.append(prev, counter, next, sep(), zoomOut, this._focusPct, zoomIn, sep(), copy, download);
2301
+
2302
+ overlay.append(holder, close, bar);
2303
+ overlay.addEventListener('click', (e) => {
2304
+ if (e.target === overlay || e.target === holder) this.closeFocus();
2305
+ });
2306
+ // Focus mode's right-click shares the canvas menu
2307
+ clone.addEventListener('contextmenu', (e) => {
2308
+ e.preventDefault();
2309
+ this.openContextMenu(this.focusIndex, e.clientX, e.clientY);
2310
+ });
2311
+ // Ctrl/⌘ plus the wheel zooms; a bare wheel is left to scrolling the card into view
2312
+ overlay.addEventListener('wheel', (e) => {
2313
+ if (!(e.ctrlKey || e.metaKey)) return;
2314
+ e.preventDefault();
2315
+ this.setFocusZoom(this.focusZoom * Math.exp(-e.deltaY / 300));
2316
+ }, { passive: false });
2317
+
2318
+ document.body.appendChild(csChrome(overlay));
2319
+ this.overlay = overlay;
2320
+ this.applyFocusZoom();
2321
+ }
2322
+
2323
+ setFocusZoom(z) {
2324
+ this.focusZoom = Math.min(3, Math.max(0.05, z));
2325
+ this.applyFocusZoom();
2326
+ }
2327
+
2328
+ applyFocusZoom() {
2329
+ if (!this._focusFrame) return;
2330
+ this._focusFrame.style.width = this.W * this.focusZoom + 'px';
2331
+ this._focusFrame.style.height = this.H * this.focusZoom + 'px';
2332
+ this._focusClone.style.transform = `scale(${this.focusZoom})`;
2333
+ if (this._focusPct) this._focusPct.textContent = Math.round(this.focusZoom * 100) + '%';
2334
+ }
2335
+
2336
+ closeFocus() {
2337
+ this.closeContextMenu();
2338
+ if (this.overlay) this.overlay.remove();
2339
+ this.overlay = null;
2340
+ this.focusIndex = null;
2341
+ this._focusFrame = null;
2342
+ this._focusClone = null;
2343
+ this._focusPct = null;
2344
+ }
2345
+
2346
+ }
2347
+
2348
+ if (!customElements.get('card-stage')) customElements.define('card-stage', CardStage);
2349
+ })();