@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.
- package/package.json +1 -1
- package/scripts/card-stage.js +2349 -0
- package/scripts/deck-stage.js +14 -8
|
@@ -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
|
+
})();
|