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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,118 +1,1828 @@
1
1
  /* BEGIN USAGE */
2
- // deck-stage.js — PPT/演示画布(纯 JS Web Component)
3
- // Exports (to window): <deck-stage> custom element
4
- //
5
- // Usage:
6
- // <deck-stage width="1920" height="1080">
7
- // <section data-label="封面"><h1>标题</h1></section>
8
- // <section data-label="目标">...</section>
9
- // </deck-stage>
10
- // element.goTo(n) // 跳到第 n 页(0-based)
11
- // element.addEventListener('slidechange', e => e.detail.index)
12
- //
13
- // 约束(不可变契约):标签名 deck-stage;固定画布 width×height(默认 1920×1080);
14
- // postMessage 协议 miaoda:deck:* / miaoda:canvas:*;导航 UI 带 data-miaoda-chrome。
15
- // 定制面:每个 <section> 内的内容与样式由 AI 自由编写。
2
+ /**
3
+ * <deck-stage> — reusable web component for HTML decks.
4
+ *
5
+ * Handles:
6
+ * (a) speaker notes — reads <script type="application/json" id="speaker-notes">
7
+ * and posts miaoda:deck:slide-changed to the parent window on nav.
8
+ * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
9
+ * On touch devices, tapping the left/right half of the stage goes
10
+ * prev/next — taps on links, buttons and other interactive slide
11
+ * content are left alone.
12
+ * (c) press R to reset to slide 0 (with a tasteful keyboard hint).
13
+ * (d) bottom-center overlay showing slide count + hints, fades out on idle.
14
+ * (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
15
+ * scaled with `transform: scale()` to fit the viewport, letterboxed.
16
+ * Set the `noscale` attribute to render at authored size (1:1) — the
17
+ * PPTX exporter sets this so its DOM capture sees unscaled geometry.
18
+ * (f) print — `@media print` lays every slide out as its own page at the
19
+ * design size, so the browser's Print → Save as PDF produces a clean
20
+ * one-page-per-slide PDF with no extra setup.
21
+ * (g) thumbnail rail — resizable left-hand column of per-slide thumbnails
22
+ * (static clones). Click to navigate; ↑/↓ with a thumbnail focused to
23
+ * step between slides; drag to reorder; right-click for
24
+ * Skip / Move up / Move down / Delete (opens a Cancel/Delete confirm
25
+ * dialog). Drag the rail's right edge to resize; width persists to
26
+ * localStorage. Skipped slides carry `data-deck-skip`, are dimmed in
27
+ * the rail, omitted from prev/next navigation, and hidden at print.
28
+ * The rail is suppressed in presenting mode, in the host's Preview
29
+ * mode (ViewerMode='none'), on `noscale`, on narrow viewports
30
+ * (≤640px), and via the `no-rail` attribute. Rail mutations dispatch
31
+ * a `deckchange`
32
+ * CustomEvent on the element: detail = {action, from, to, slide}.
33
+ *
34
+ * Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
35
+ * `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
36
+ * form inputs, React trees) is preserved across navigation.
37
+ *
38
+ * Lifecycle event — the component dispatches a `slidechange` CustomEvent on
39
+ * itself whenever the active slide changes (including the initial mount).
40
+ * The event bubbles and composes out of shadow DOM, so you can listen on
41
+ * the <deck-stage> element or on document:
42
+ *
43
+ * document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
44
+ * e.detail.index // new 0-based index
45
+ * e.detail.previousIndex // previous index, or -1 on init
46
+ * e.detail.total // total slide count
47
+ * e.detail.slide // the new active slide element
48
+ * e.detail.previousSlide // the prior slide element, or null on init
49
+ * e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
50
+ * });
51
+ *
52
+ * Persistence: none at the deck level. The host app keeps the current slide
53
+ * in its own URL (?slide=) and re-delivers it via location.hash on load, so a
54
+ * bare load with no hash always starts at slide 1.
55
+ *
56
+ * Usage:
57
+ * <style>deck-stage:not(:defined){visibility:hidden}</style>
58
+ * <deck-stage width="1920" height="1080">
59
+ * <section data-label="Title">...</section>
60
+ * <section data-label="Agenda">...</section>
61
+ * </deck-stage>
62
+ * <script src="deck-stage.js"></script>
63
+ *
64
+ * The :not(:defined) rule prevents a flash of the first slide at its
65
+ * authored styles before this script runs and attaches the shadow root.
66
+ *
67
+ * Slides are the direct element children of <deck-stage>. Each slide is
68
+ * automatically tagged with:
69
+ * - data-screen-label="NN Label" (1-indexed, for comment flow)
70
+ * - data-miaoda-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
71
+ *
72
+ * Speaker notes stay in sync because the component posts miaoda:deck:slide-changed
73
+ * to the parent — just include the #speaker-notes script tag if asked for notes.
74
+ *
75
+ * Authoring guidance:
76
+ * - Write slide bodies as static HTML inside <deck-stage>, with sizing via
77
+ * CSS custom properties in a <style> block rather than JS constants.
78
+ * Static slide markup is what lets the user click a heading in edit mode
79
+ * and retype it directly; a slide rendered through <script type="text/babel">,
80
+ * React, or a loop over a JS array has to round-trip every tweak through a
81
+ * chat message instead. Reach for script-generated slides only when the
82
+ * content genuinely needs interactive behaviour static HTML can't express.
83
+ * - Do NOT set position/inset/width/height on the slide <section> elements —
84
+ * the component absolutely positions every slotted child for you.
85
+ * - Entrance animations: make the visible end-state the base style and
86
+ * animate *from* hidden, so print and reduced-motion show content.
87
+ * Gate the animation on [data-deck-active] and the motion query, e.g.
88
+ * `@media (prefers-reduced-motion:no-preference){ [data-deck-active] .x{animation:fade-in .5s both} }`.
89
+ * Avoid infinite decorative loops on slide content.
90
+ */
16
91
  /* END USAGE */
17
92
 
18
- (function () {
19
- if (customElements.get('deck-stage')) return;
93
+ (() => {
94
+ if (typeof window === 'undefined') return;
95
+ const DESIGN_W_DEFAULT = 1920;
96
+ const DESIGN_H_DEFAULT = 1080;
97
+ const OVERLAY_HIDE_MS = 1800;
98
+ const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
99
+ const FINE_POINTER_MQ = matchMedia('(hover: hover) and (pointer: fine)');
100
+ const NARROW_MQ = matchMedia('(max-width: 640px)');
101
+ // Slide-authored controls that should keep a tap instead of it navigating.
102
+ const INTERACTIVE_SEL = 'a[href], button, input, select, textarea, summary, label, video[controls], audio[controls], [role="button"], [onclick], [tabindex]:not([tabindex^="-"]), [contenteditable]:not([contenteditable="false" i])';
103
+
104
+ const pad2 = (n) => String(n).padStart(2, '0');
105
+
106
+ // Label precedence: data-label → data-screen-label (number stripped) → first heading → "Slide".
107
+ const getSlideLabel = (el) => {
108
+ const explicit = el.getAttribute('data-label');
109
+ if (explicit) return explicit;
110
+
111
+ const existing = el.getAttribute('data-screen-label');
112
+ if (existing) return existing.replace(/^\s*\d+\s*/, '').trim() || existing;
113
+
114
+ const h = el.querySelector('h1, h2, h3, [data-title]');
115
+ const t = h && (h.textContent || '').trim().slice(0, 40);
116
+ if (t) return t;
117
+
118
+ return 'Slide';
119
+ };
120
+
121
+ const stylesheet = `
122
+ :host {
123
+ position: fixed;
124
+ inset: 0;
125
+ display: block;
126
+ background: #fff;
127
+ color: #fff;
128
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
129
+ overflow: hidden;
130
+ -webkit-tap-highlight-color: transparent;
131
+ }
132
+ /* connectedCallback holds this until document.fonts.ready (capped 2s) so
133
+ * the first visible paint has the deck's real typography + final rail
134
+ * layout. opacity (not visibility) so the active slide can't un-hide
135
+ * itself via the ::slotted([data-deck-active]) visibility:visible rule.
136
+ * Only the stage/rail hide — the black :host background stays, so the
137
+ * iframe doesn't flash the page's default white. */
138
+ :host([data-fonts-pending]) .stage,
139
+ :host([data-fonts-pending]) .rail { opacity: 0; pointer-events: none; }
140
+
141
+ .stage {
142
+ position: absolute;
143
+ inset: 0;
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ background: #F8F9FA;
148
+ }
149
+
150
+ .canvas {
151
+ position: relative;
152
+ transform-origin: center center;
153
+ flex-shrink: 0;
154
+ background: #fff;
155
+ will-change: transform;
156
+ }
157
+
158
+ /* Slides live in light DOM (via <slot>) so authored CSS still applies.
159
+ We absolutely position each slotted child to stack them. */
160
+ ::slotted(*) {
161
+ position: absolute !important;
162
+ inset: 0 !important;
163
+ width: 100% !important;
164
+ height: 100% !important;
165
+ box-sizing: border-box !important;
166
+ overflow: hidden;
167
+ opacity: 0;
168
+ pointer-events: none;
169
+ visibility: hidden;
170
+ }
171
+ ::slotted([data-deck-active]) {
172
+ opacity: 1;
173
+ pointer-events: auto;
174
+ visibility: visible;
175
+ }
176
+
177
+ .overlay {
178
+ position: fixed;
179
+ left: 50%;
180
+ bottom: 22px;
181
+ transform: translate(-50%, 6px) scale(0.92);
182
+ filter: blur(6px);
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 4px;
186
+ padding: 4px;
187
+ background: #000;
188
+ color: #fff;
189
+ border-radius: 999px;
190
+ font-size: 12px;
191
+ font-feature-settings: "tnum" 1;
192
+ letter-spacing: 0.01em;
193
+ opacity: 0;
194
+ pointer-events: none;
195
+ transition: opacity 260ms ease, transform 260ms cubic-bezier(.2,.8,.2,1), filter 260ms ease;
196
+ transform-origin: center bottom;
197
+ z-index: 2147483000;
198
+ user-select: none;
199
+ }
200
+ .overlay[data-visible] {
201
+ opacity: 1;
202
+ pointer-events: auto;
203
+ transform: translate(-50%, 0) scale(1);
204
+ filter: blur(0);
205
+ }
206
+
207
+ .btn {
208
+ appearance: none;
209
+ -webkit-appearance: none;
210
+ background: transparent;
211
+ border: 0;
212
+ margin: 0;
213
+ padding: 0;
214
+ color: inherit;
215
+ font: inherit;
216
+ cursor: default;
217
+ display: inline-flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ height: 28px;
221
+ min-width: 28px;
222
+ border-radius: 999px;
223
+ color: rgba(255,255,255,0.72);
224
+ transition: background 140ms ease, color 140ms ease;
225
+ -webkit-tap-highlight-color: transparent;
226
+ }
227
+ .btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
228
+ .btn:active { background: rgba(255,255,255,0.18); }
229
+ .btn:focus { outline: none; }
230
+ .btn:focus-visible { outline: none; }
231
+ .btn::-moz-focus-inner { border: 0; }
232
+ .btn svg { width: 14px; height: 14px; display: block; }
233
+ .btn.reset {
234
+ font-size: 11px;
235
+ font-weight: 500;
236
+ letter-spacing: 0.02em;
237
+ padding: 0 10px 0 12px;
238
+ gap: 6px;
239
+ color: rgba(255,255,255,0.72);
240
+ }
241
+ .btn.reset .kbd {
242
+ display: inline-flex;
243
+ align-items: center;
244
+ justify-content: center;
245
+ min-width: 16px;
246
+ height: 16px;
247
+ padding: 0 4px;
248
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
249
+ font-size: 10px;
250
+ line-height: 1;
251
+ color: rgba(255,255,255,0.88);
252
+ background: rgba(255,255,255,0.12);
253
+ border-radius: 4px;
254
+ }
255
+
256
+ .count {
257
+ font-variant-numeric: tabular-nums;
258
+ color: #fff;
259
+ font-weight: 500;
260
+ padding: 0 8px;
261
+ min-width: 42px;
262
+ text-align: center;
263
+ font-size: 12px;
264
+ }
265
+ .count .sep { color: rgba(255,255,255,0.45); margin: 0 3px; font-weight: 400; }
266
+ .count .total { color: rgba(255,255,255,0.55); }
267
+
268
+ .divider {
269
+ width: 1px;
270
+ height: 14px;
271
+ background: rgba(255,255,255,0.18);
272
+ margin: 0 2px;
273
+ }
274
+
275
+ /* ── Thumbnail rail ──────────────────────────────────────────────────
276
+ Fixed column on the left; each thumbnail is a static deep-clone of
277
+ the light-DOM slide scaled into a 16:9 (or design-aspect) frame. The
278
+ stage re-fits around it (see _fit); hidden during present / noscale
279
+ / print so capture geometry and fullscreen output are unchanged. */
280
+ .rail {
281
+ position: fixed;
282
+ left: 0;
283
+ top: 0;
284
+ bottom: 0;
285
+ width: var(--deck-rail-w, 192px);
286
+ background: #fff;
287
+ border-right: 0.5px solid #DEE0E3;
288
+ overflow-y: auto;
289
+ overflow-x: hidden;
290
+ padding: 16px 10px;
291
+ box-sizing: border-box;
292
+ display: flex;
293
+ flex-direction: column;
294
+ gap: 12px;
295
+ z-index: 2147482500;
296
+ scrollbar-width: thin;
297
+ scrollbar-color: rgba(0,0,0,0.18) transparent;
298
+ }
299
+ .rail::-webkit-scrollbar { width: 8px; }
300
+ .rail::-webkit-scrollbar-track { background: transparent; margin: 2px; }
301
+ .rail::-webkit-scrollbar-thumb {
302
+ background: rgba(0,0,0,0.18);
303
+ border-radius: 4px;
304
+ border: 2px solid transparent;
305
+ background-clip: content-box;
306
+ }
307
+ .rail::-webkit-scrollbar-thumb:hover {
308
+ background: rgba(0,0,0,0.28);
309
+ border: 2px solid transparent;
310
+ background-clip: content-box;
311
+ }
312
+ :host([no-rail]) .rail,
313
+ :host([noscale]) .rail { display: none; }
314
+ .rail[data-presenting] { display: none; }
315
+ @media (max-width: 640px) {
316
+ .rail, .rail-resize { display: none; }
317
+ }
318
+ /* User-driven show/hide (the TweaksPanel toggle) slides instead of
319
+ popping. Transitions are gated on :host([data-rail-anim]) — set only
320
+ for the 200ms around the toggle — so window-resize and rail-width
321
+ drag (which also call _fit) don't lag behind the cursor. */
322
+ .rail[data-user-hidden] { transform: translateX(-100%); }
323
+ :host([data-rail-anim]) .rail { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
324
+ :host([data-rail-anim]) .stage { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
325
+ :host([data-rail-anim]) .canvas { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
326
+ /* transition shorthand replaces rather than merges — repeat the base
327
+ .overlay opacity/transform/filter transitions so visibility changes
328
+ during the 200ms toggle window still fade instead of popping. */
329
+ :host([data-rail-anim]) .overlay {
330
+ transition: margin-left 200ms cubic-bezier(.3,.7,.4,1),
331
+ opacity 260ms ease,
332
+ transform 260ms cubic-bezier(.2,.8,.2,1),
333
+ filter 260ms ease;
334
+ }
335
+
336
+ .thumb {
337
+ position: relative;
338
+ display: flex;
339
+ align-items: flex-start;
340
+ gap: 8px;
341
+ cursor: pointer;
342
+ user-select: none;
343
+ }
344
+ .thumb .num {
345
+ width: 16px;
346
+ flex-shrink: 0;
347
+ font-size: 12px;
348
+ font-weight: 400;
349
+ text-align: right;
350
+ color: #646A73;
351
+ padding-top: 2px;
352
+ font-variant-numeric: tabular-nums;
353
+ }
354
+ .thumb .frame {
355
+ position: relative;
356
+ flex: 1;
357
+ min-width: 0;
358
+ aspect-ratio: var(--deck-aspect);
359
+ background: #fff;
360
+ border-radius: 4px;
361
+ outline: 2px solid transparent;
362
+ outline-offset: 0;
363
+ overflow: hidden;
364
+ transition: outline-color 120ms ease;
365
+ }
366
+ .thumb:hover .frame { outline-color: rgba(0,0,0,0.15); }
367
+ .thumb { outline: none; }
368
+ .thumb:focus-visible .frame { outline-color: #336DF4; }
369
+ .thumb[data-current] .num { color: #1F2329; }
370
+ .thumb[data-current] .frame { outline-color: #1F2329; }
371
+ .thumb[data-dragging] { opacity: 0.35; }
372
+ .thumb::before {
373
+ content: '';
374
+ position: absolute;
375
+ left: 24px;
376
+ right: 0;
377
+ height: 3px;
378
+ border-radius: 2px;
379
+ background: #336DF4;
380
+ opacity: 0;
381
+ pointer-events: none;
382
+ }
383
+ .thumb[data-drop="before"]::before { top: -8px; opacity: 1; }
384
+ .thumb[data-drop="after"]::before { bottom: -8px; opacity: 1; }
385
+ .thumb[data-skip] .frame { opacity: 0.35; }
386
+ .thumb[data-skip] .frame::after {
387
+ content: '已跳过';
388
+ position: absolute;
389
+ inset: 0;
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ background: rgba(0,0,0,0.45);
394
+ color: #fff;
395
+ font-size: 10px;
396
+ font-weight: 500;
397
+ letter-spacing: 0.04em;
398
+ }
399
+
400
+ .ctxmenu {
401
+ position: fixed;
402
+ min-width: 150px;
403
+ padding: 4px;
404
+ background: #242424;
405
+ border: 1px solid rgba(255,255,255,0.12);
406
+ border-radius: 7px;
407
+ box-shadow: 0 8px 24px rgba(0,0,0,0.45);
408
+ z-index: 2147483100;
409
+ display: none;
410
+ font-size: 12px;
411
+ }
412
+ .ctxmenu[data-open] { display: block; }
413
+ .ctxmenu button {
414
+ display: block;
415
+ width: 100%;
416
+ appearance: none;
417
+ border: 0;
418
+ background: transparent;
419
+ color: #e8e8e8;
420
+ font: inherit;
421
+ text-align: left;
422
+ padding: 6px 10px;
423
+ border-radius: 4px;
424
+ cursor: pointer;
425
+ }
426
+ .ctxmenu button:hover:not(:disabled) { background: rgba(255,255,255,0.08); }
427
+ .ctxmenu button:disabled { opacity: 0.35; cursor: default; }
428
+ .ctxmenu hr {
429
+ border: 0;
430
+ border-top: 1px solid rgba(255,255,255,0.1);
431
+ margin: 4px 2px;
432
+ }
433
+
434
+ .rail-resize {
435
+ position: fixed;
436
+ left: calc(var(--deck-rail-w, 192px) - 3px);
437
+ top: 0;
438
+ bottom: 0;
439
+ width: 6px;
440
+ cursor: col-resize;
441
+ z-index: 2147482600;
442
+ touch-action: none;
443
+ }
444
+ .rail-resize:hover,
445
+ .rail-resize[data-dragging] { background: rgba(255,255,255,0.12); }
446
+ :host([no-rail]) .rail-resize,
447
+ :host([noscale]) .rail-resize,
448
+ .rail[data-presenting] + .rail-resize,
449
+ .rail[data-user-hidden] + .rail-resize { display: none; }
450
+
451
+ /* Delete-confirm popup — matches the SPA's ConfirmDialog layout
452
+ (title + message body, depressed footer with Cancel / Delete). */
453
+ .confirm-backdrop {
454
+ position: fixed;
455
+ inset: 0;
456
+ background: rgba(0,0,0,0.45);
457
+ z-index: 2147483200;
458
+ display: none;
459
+ align-items: center;
460
+ justify-content: center;
461
+ }
462
+ .confirm-backdrop[data-open] { display: flex; }
463
+ .confirm {
464
+ width: 320px;
465
+ max-width: calc(100vw - 32px);
466
+ background: #2a2a2a;
467
+ color: #e8e8e8;
468
+ border: 1px solid rgba(255,255,255,0.12);
469
+ border-radius: 12px;
470
+ box-shadow: 0 12px 32px rgba(0,0,0,0.5);
471
+ overflow: hidden;
472
+ font-family: inherit;
473
+ animation: deck-confirm-in 0.18s ease;
474
+ }
475
+ @keyframes deck-confirm-in {
476
+ from { opacity: 0; transform: scale(0.96); }
477
+ to { opacity: 1; transform: scale(1); }
478
+ }
479
+ .confirm .body { padding: 20px 20px 16px; }
480
+ .confirm .title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
481
+ .confirm .msg { font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.65); }
482
+ .confirm .footer {
483
+ padding: 14px 20px;
484
+ background: #1f1f1f;
485
+ border-top: 1px solid rgba(255,255,255,0.08);
486
+ display: flex;
487
+ justify-content: flex-end;
488
+ gap: 8px;
489
+ }
490
+ .confirm button {
491
+ appearance: none;
492
+ font: inherit;
493
+ font-size: 13px;
494
+ font-weight: 500;
495
+ padding: 8px 16px;
496
+ border-radius: 8px;
497
+ cursor: pointer;
498
+ }
499
+ .confirm .cancel {
500
+ background: transparent;
501
+ border: 0;
502
+ color: rgba(255,255,255,0.8);
503
+ }
504
+ .confirm .cancel:hover { background: rgba(255,255,255,0.08); }
505
+ .confirm .danger {
506
+ background: #c96442;
507
+ border: 1px solid rgba(0,0,0,0.15);
508
+ color: #fff;
509
+ box-shadow: 0 1px 3px rgba(166,50,68,0.3), 0 2px 6px rgba(166,50,68,0.18);
510
+ }
511
+ .confirm .danger:hover { background: #b5563a; }
512
+
513
+ /* ── Print: one page per slide, no chrome ────────────────────────────
514
+ The screen layout stacks every slide at inset:0 inside a scaled
515
+ canvas; for print we want them in document flow at the authored
516
+ design size so the browser paginates one slide per sheet. The
517
+ @page size is set from the width/height attributes via the inline
518
+ <style id="deck-stage-print-page"> that connectedCallback injects
519
+ into <head> (the @page at-rule has no effect inside shadow DOM). */
520
+ @media print {
521
+ :host {
522
+ position: static;
523
+ inset: auto;
524
+ background: none;
525
+ overflow: visible;
526
+ color: inherit;
527
+ }
528
+ .stage { position: static; display: block; }
529
+ .canvas {
530
+ transform: none !important;
531
+ width: auto !important;
532
+ height: auto !important;
533
+ background: none;
534
+ will-change: auto;
535
+ }
536
+ ::slotted(*) {
537
+ position: relative !important;
538
+ inset: auto !important;
539
+ width: var(--deck-design-w) !important;
540
+ height: var(--deck-design-h) !important;
541
+ box-sizing: border-box !important;
542
+ opacity: 1 !important;
543
+ visibility: visible !important;
544
+ pointer-events: auto;
545
+ break-after: page;
546
+ page-break-after: always;
547
+ break-inside: avoid;
548
+ overflow: hidden;
549
+ }
550
+ /* :last-child alone isn't enough once data-deck-skip hides the
551
+ trailing slide(s) — the last *visible* slide still carries
552
+ break-after:page and prints a blank sheet. _markLastVisible()
553
+ maintains data-deck-last-visible on the last non-skipped slide. */
554
+ ::slotted(*:last-child),
555
+ ::slotted([data-deck-last-visible]) {
556
+ break-after: auto;
557
+ page-break-after: auto;
558
+ }
559
+ ::slotted([data-deck-skip]) { display: none !important; }
560
+ .overlay, .rail, .rail-resize, .ctxmenu, .confirm-backdrop { display: none !important; }
561
+ }
562
+ `;
20
563
 
21
564
  class DeckStage extends HTMLElement {
22
- connectedCallback() {
23
- const W = Number(this.getAttribute('width')) || 1920;
24
- const H = Number(this.getAttribute('height')) || 1080;
25
- this._w = W;
26
- this._h = H;
565
+ static get observedAttributes() { return ['width', 'height', 'noscale', 'no-rail']; }
566
+
567
+ constructor() {
568
+ super();
569
+ this._root = this.attachShadow({ mode: 'open' });
27
570
  this._index = 0;
571
+ this._slides = [];
572
+ this._notes = [];
573
+ this._hideTimer = null;
574
+ this._mouseIdleTimer = null;
575
+ this._menuIndex = -1;
28
576
 
29
- // 把 light-dom 的 <section> 收为幻灯片
30
- this._slides = Array.from(this.querySelectorAll(':scope > section'));
31
-
32
- const shadow = this.attachShadow({ mode: 'open' });
33
- shadow.innerHTML = `
34
- <style>
35
- :host { position: fixed; inset: 0; overflow: hidden; background: #000; }
36
- .stage { position: absolute; top: 50%; left: 50%; width: ${W}px; height: ${H}px;
37
- transform-origin: center; }
38
- ::slotted(section) { position: absolute; inset: 0; box-sizing: border-box; }
39
- ::slotted(section[hidden]) { display: none; }
40
- .nav { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
41
- display: flex; gap: 8px; z-index: 10; }
42
- .nav button { all: unset; cursor: pointer; padding: 6px 12px; border-radius: 8px;
43
- background: rgba(255,255,255,.12); color: #fff; font: 13px system-ui; }
44
- @media print {
45
- :host { position: static; }
46
- .stage { position: static; transform: none !important; width: ${W}px; height: ${H}px;
47
- page-break-after: always; }
48
- ::slotted(section[hidden]) { display: block !important; }
49
- .nav { display: none; }
50
- }
51
- </style>
52
- <div class="stage"><slot></slot></div>
53
- <div class="nav" data-miaoda-chrome>
54
- <button data-act="prev">‹</button><button data-act="next">›</button>
55
- </div>
56
- `;
577
+ this._onKey = this._onKey.bind(this);
578
+ this._onResize = this._onResize.bind(this);
579
+ this._onSlotChange = this._onSlotChange.bind(this);
580
+ this._onMouseMove = this._onMouseMove.bind(this);
581
+ this._onTap = this._onTap.bind(this);
582
+ this._onMessage = this._onMessage.bind(this);
583
+ // Capture-phase close so a click anywhere dismisses the menu, but
584
+ // ignore clicks that land inside the menu itself — otherwise the
585
+ // capture handler runs before the menu's own (bubble) handler and
586
+ // clears _menuIndex out from under it.
587
+ this._onDocClick = (e) => {
588
+ if (this._menu && e.composedPath && e.composedPath().includes(this._menu)) return;
589
+ this._closeMenu();
590
+ };
591
+ }
57
592
 
58
- this._stage = shadow.querySelector('.stage');
59
- shadow.querySelector('[data-act="prev"]').onclick = () => this.goTo(this._index - 1);
60
- shadow.querySelector('[data-act="next"]').onclick = () => this.goTo(this._index + 1);
593
+ get designWidth() {
594
+ return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
595
+ }
596
+ get designHeight() {
597
+ return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
598
+ }
61
599
 
62
- this._onResize = () => this._fit();
600
+ connectedCallback() {
601
+ // Presenter-view popup loads deckUrl?_snthumb=...#N for its prev/cur/
602
+ // next thumbnails — the rail has no business rendering inside those
603
+ // (wrong scale, and it offsets the stage so the thumb shows a gutter).
604
+ if (/[?&]_snthumb=/.test(location.search)) this.setAttribute('no-rail', '');
605
+ this._render();
606
+ this._loadNotes();
607
+ this._syncPrintPageRule();
608
+ window.addEventListener('keydown', this._onKey);
63
609
  window.addEventListener('resize', this._onResize);
64
- this._onKey = (e) => {
65
- if (e.key === 'ArrowRight') this.goTo(this._index + 1);
66
- if (e.key === 'ArrowLeft') this.goTo(this._index - 1);
610
+ window.addEventListener('mousemove', this._onMouseMove, { passive: true });
611
+ window.addEventListener('message', this._onMessage);
612
+ window.addEventListener('click', this._onDocClick, true);
613
+ this.addEventListener('click', this._onTap);
614
+ // Print lays every slide out as its own page, so [data-deck-active]-
615
+ // gated entrance styles need the attribute on every slide (not just
616
+ // the current one) or their content prints at the hidden base style.
617
+ // The transient freeze style lands BEFORE the attributes so any
618
+ // attribute-keyed transition fires at 0s (changing transition-
619
+ // duration after a transition has started doesn't affect it).
620
+ this._onBeforePrint = () => {
621
+ if (this._freezeStyle) this._freezeStyle.remove();
622
+ this._freezeStyle = document.createElement('style');
623
+ this._freezeStyle.textContent = '*,*::before,*::after{transition-duration:0s !important}';
624
+ document.head.appendChild(this._freezeStyle);
625
+ this._slides.forEach((s) => s.setAttribute('data-deck-active', ''));
67
626
  };
68
- window.addEventListener('keydown', this._onKey);
69
-
70
- // 顺序铁律:先注册监听,再宣告可用
71
- window.addEventListener('message', (e) => {
72
- if (e.data && e.data.type === 'miaoda:canvas:set-zoom') this._fit(e.data.zoom);
627
+ this._onAfterPrint = () => {
628
+ this._applyIndex({ showOverlay: false, broadcast: false });
629
+ if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
630
+ };
631
+ window.addEventListener('beforeprint', this._onBeforePrint);
632
+ window.addEventListener('afterprint', this._onAfterPrint);
633
+ // Initial collection + layout happens via slotchange, which fires on mount.
634
+ this._enableRail();
635
+ // Hold the stage hidden until webfonts are ready so the first visible
636
+ // paint has the deck's real typography — the :not(:defined) guard in
637
+ // the page HTML only covers custom-element upgrade, not font load.
638
+ // Capped so a 404'd font URL can't blank the deck indefinitely.
639
+ this.setAttribute('data-fonts-pending', '');
640
+ const reveal = () => this.removeAttribute('data-fonts-pending');
641
+ // rAF first: fonts.ready is a pre-resolved promise until layout has
642
+ // resolved the slotted text's font-family and pushed a FontFace into
643
+ // 'loading'. Reading it here in connectedCallback (parse-time) would
644
+ // settle the race in a microtask before any font fetch starts.
645
+ requestAnimationFrame(() => {
646
+ Promise.race([
647
+ document.fonts ? document.fonts.ready : Promise.resolve(),
648
+ new Promise((r) => setTimeout(r, 2000)),
649
+ ]).then(reveal, reveal);
73
650
  });
74
- this._post('miaoda:canvas:present', {});
651
+ }
75
652
 
76
- // 初始页可来自 URL ?slide=
77
- const init = Number(new URLSearchParams(location.search).get('slide')) || 1;
653
+ _enableRail() {
654
+ // Idempotent — host may post miaoda:deck:rail-enabled multiple times.
655
+ // no-rail guard keeps the observers/stylesheet walk off the cheap path
656
+ // for presenter-popup thumbnail iframes (up to 9 per view).
657
+ if (this._railEnabled || this.hasAttribute('no-rail')) return;
658
+ this._railEnabled = true;
659
+ // Per-viewer preference — restored alongside rail width. Default on;
660
+ // only a stored '0' (from the TweaksPanel toggle) hides it.
661
+ this._railVisible = true;
662
+ try {
663
+ if (localStorage.getItem('deck-stage.railVisible') === '0') this._railVisible = false;
664
+ } catch (e) {}
665
+ // Live thumbnail updates: watch the light-DOM slides for content
666
+ // edits and re-clone just the affected thumb(s), debounced. Ignore
667
+ // the data-deck-* / data-screen-label / data-miaoda-validate attributes
668
+ // this component itself writes so nav and skip don't trigger
669
+ // spurious refreshes.
670
+ const OWN_ATTRS = /^data-(deck-|screen-label$|om-validate$)/;
671
+ this._liveDirty = new Set();
672
+ this._liveObserver = new MutationObserver((records) => {
673
+ for (const r of records) {
674
+ if (r.type === 'attributes' && OWN_ATTRS.test(r.attributeName || '')) continue;
675
+ let n = r.target;
676
+ while (n && n.parentElement !== this) n = n.parentElement;
677
+ if (n && this._slideSet && this._slideSet.has(n)) this._liveDirty.add(n);
678
+ }
679
+ if (this._liveDirty.size && !this._liveTimer) {
680
+ this._liveTimer = setTimeout(() => {
681
+ this._liveTimer = null;
682
+ this._liveDirty.forEach((s) => this._refreshThumb(s));
683
+ this._liveDirty.clear();
684
+ }, 200);
685
+ }
686
+ });
687
+ this._liveObserver.observe(this, {
688
+ subtree: true, childList: true, characterData: true, attributes: true,
689
+ });
690
+ // Lazy thumbnail materialization — clone the slide only when its
691
+ // frame scrolls into (or near) the rail viewport. rootMargin gives
692
+ // ~4 thumbs of pre-load so fast scrolling doesn't flash blanks.
693
+ this._railObserver = new IntersectionObserver((entries) => {
694
+ entries.forEach((e) => {
695
+ if (e.isIntersecting && e.target.__deckThumb) {
696
+ this._materialize(e.target.__deckThumb);
697
+ }
698
+ });
699
+ }, { root: this._rail, rootMargin: '400px 0px' });
700
+ // Tweaks typically change CSS vars / attrs OUTSIDE <deck-stage>
701
+ // (on <html>, <body>, a wrapper div, or a <style> tag), which
702
+ // _liveObserver can't see. Re-snapshot author CSS (constructable
703
+ // sheet is shared by reference, so one replaceSync updates every
704
+ // thumb shadow root) and re-sync each thumb host's attrs + custom
705
+ // properties. In-slide DOM mutations are _liveObserver's job.
706
+ // Debounced so slider drags don't thrash.
707
+ this._onTweakChange = () => {
708
+ clearTimeout(this._tweakTimer);
709
+ this._tweakTimer = setTimeout(() => {
710
+ this._snapshotAuthorCss();
711
+ // One getComputedStyle for the whole batch — each
712
+ // getPropertyValue read below reuses the same computed style
713
+ // as long as nothing invalidates layout between thumbs.
714
+ const cs = getComputedStyle(this);
715
+ (this._thumbs || []).forEach((t) => {
716
+ if (t.host) this._syncThumbHostAttrs(t.host, cs);
717
+ });
718
+ }, 120);
719
+ };
720
+ window.addEventListener('tweakchange', this._onTweakChange);
721
+ this._snapshotAuthorCss();
722
+ // Build the rail now that it's enabled — slotchange already fired,
723
+ // so _renderRail's early-return skipped the initial build.
724
+ this._syncRailHidden();
725
+ this._renderRail();
78
726
  this._fit();
79
- this.goTo(init - 1);
727
+ }
728
+
729
+ /** Snapshot document stylesheets into a constructable sheet that each
730
+ * thumbnail's nested shadow root adopts — so author CSS styles the
731
+ * cloned slide content without touching this component's chrome.
732
+ * Cross-origin sheets throw on .cssRules — skip them. Re-callable:
733
+ * the existing constructable sheet is reused via replaceSync so every
734
+ * already-adopted shadow root picks up the fresh CSS without re-adopt. */
735
+ _snapshotAuthorCss() {
736
+ // :root in an adopted sheet inside a shadow root matches nothing
737
+ // (only the document root qualifies), so author rules like
738
+ // `:root[data-voice="modern"] .serif` never reach the clones.
739
+ // Rewrite :root → :host and mirror <html>'s data-*/class/lang onto
740
+ // each thumb host (see _syncThumbHostAttrs) so the same selectors
741
+ // match inside the thumbnail's shadow tree.
742
+ const authorCss = Array.from(document.styleSheets).map((sh) => {
743
+ try {
744
+ return Array.from(sh.cssRules).map((r) => r.cssText).join('\n');
745
+ } catch (e) { return ''; }
746
+ }).join('\n')
747
+ // The shadow host is featureless outside the functional :host(...)
748
+ // form, so any compound on :root — [attr], .class, #id, :pseudo —
749
+ // must become :host(<compound>) not :host<compound>. Same for the
750
+ // html type selector (Tailwind class-strategy dark mode emits
751
+ // html.dark; Pico uses html[data-theme]), which has nothing to
752
+ // match inside the thumb's shadow tree.
753
+ .replace(/:root((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)/g, ':host($1)')
754
+ .replace(/:root\b/g, ':host')
755
+ .replace(/(^|[\s,>~+(}])html((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
756
+ .replace(/(^|[\s,>~+(}])html(?![-\w])/g, '$1:host');
757
+ // Every custom property the author references. _syncThumbHostAttrs
758
+ // mirrors each one's *computed* value at <deck-stage> onto the
759
+ // thumb host so the live value wins over the :host default above
760
+ // regardless of which ancestor the tweak wrote to (<html>, <body>,
761
+ // a wrapper div, or the deck-stage element itself all inherit
762
+ // down to getComputedStyle(this)).
763
+ this._authorVars = new Set(authorCss.match(/--[\w-]+/g) || []);
764
+ try {
765
+ if (!this._adoptedSheet) this._adoptedSheet = new CSSStyleSheet();
766
+ this._adoptedSheet.replaceSync(authorCss);
767
+ } catch (e) {
768
+ this._adoptedSheet = null;
769
+ this._authorCss = authorCss;
770
+ }
771
+ }
772
+
773
+ _syncThumbHostAttrs(host, cs) {
774
+ const de = document.documentElement;
775
+ // setAttribute overwrites but can't delete — an attr removed from
776
+ // <html> (toggleAttribute off, classList emptied) would linger on
777
+ // the host and :host([data-*]) / :host(.foo) rules would keep
778
+ // matching. Remove stale mirrored attrs first; iterate backward
779
+ // because removeAttribute mutates the live NamedNodeMap.
780
+ for (let i = host.attributes.length - 1; i >= 0; i--) {
781
+ const n = host.attributes[i].name;
782
+ if ((n.startsWith('data-') || n === 'class' || n === 'lang')
783
+ && !de.hasAttribute(n)) {
784
+ host.removeAttribute(n);
785
+ }
786
+ }
787
+ for (const a of de.attributes) {
788
+ if (a.name.startsWith('data-') || a.name === 'class' || a.name === 'lang') {
789
+ host.setAttribute(a.name, a.value);
790
+ }
791
+ }
792
+ // The :root→:host rewrite in _snapshotAuthorCss pins each custom
793
+ // property to its stylesheet default on the thumb host, shadowing
794
+ // the live value that would otherwise inherit. Tweaks can write the
795
+ // live value on any ancestor — <html>, <body>, a wrapper div, the
796
+ // deck-stage element — so read it as the *computed* value at
797
+ // <deck-stage> (which sees the whole inheritance chain) rather than
798
+ // trying to guess which element the author wrote to. Inline on the
799
+ // host beats the :host{} rule. remove-stale covers vars dropped
800
+ // from the stylesheet between snapshots.
801
+ const vars = this._authorVars || new Set();
802
+ for (let i = host.style.length - 1; i >= 0; i--) {
803
+ const p = host.style[i];
804
+ if (p.startsWith('--') && !vars.has(p)) host.style.removeProperty(p);
805
+ }
806
+ const live = cs || getComputedStyle(this);
807
+ vars.forEach((p) => {
808
+ const v = live.getPropertyValue(p);
809
+ if (v) host.style.setProperty(p, v.trim());
810
+ else host.style.removeProperty(p);
811
+ });
80
812
  }
81
813
 
82
814
  disconnectedCallback() {
83
- window.removeEventListener('resize', this._onResize);
84
815
  window.removeEventListener('keydown', this._onKey);
816
+ window.removeEventListener('resize', this._onResize);
817
+ window.removeEventListener('mousemove', this._onMouseMove);
818
+ window.removeEventListener('message', this._onMessage);
819
+ window.removeEventListener('click', this._onDocClick, true);
820
+ window.removeEventListener('beforeprint', this._onBeforePrint);
821
+ window.removeEventListener('afterprint', this._onAfterPrint);
822
+ if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
823
+ this.removeEventListener('click', this._onTap);
824
+ if (this._hideTimer) clearTimeout(this._hideTimer);
825
+ if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
826
+ if (this._liveTimer) clearTimeout(this._liveTimer);
827
+ if (this._tweakTimer) clearTimeout(this._tweakTimer);
828
+ if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
829
+ if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
830
+ if (this._liveObserver) this._liveObserver.disconnect();
831
+ if (this._railObserver) this._railObserver.disconnect();
832
+ if (this._onTweakChange) window.removeEventListener('tweakchange', this._onTweakChange);
833
+ }
834
+
835
+ attributeChangedCallback() {
836
+ if (this._canvas) {
837
+ this._canvas.style.width = this.designWidth + 'px';
838
+ this._canvas.style.height = this.designHeight + 'px';
839
+ this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
840
+ this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
841
+ if (this._rail) {
842
+ this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
843
+ }
844
+ this._fit();
845
+ this._scaleThumbs();
846
+ this._syncPrintPageRule();
847
+ }
848
+ }
849
+
850
+ _render() {
851
+ const style = document.createElement('style');
852
+ style.textContent = stylesheet;
853
+
854
+ const stage = document.createElement('div');
855
+ stage.className = 'stage';
856
+
857
+ const canvas = document.createElement('div');
858
+ canvas.className = 'canvas';
859
+ canvas.style.width = this.designWidth + 'px';
860
+ canvas.style.height = this.designHeight + 'px';
861
+ canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
862
+ canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
863
+
864
+ const slot = document.createElement('slot');
865
+ slot.addEventListener('slotchange', this._onSlotChange);
866
+ canvas.appendChild(slot);
867
+ stage.appendChild(canvas);
868
+
869
+ // Overlay: compact, solid black, with clickable controls.
870
+ const overlay = document.createElement('div');
871
+ overlay.className = 'overlay export-hidden';
872
+ overlay.setAttribute('role', 'toolbar');
873
+ overlay.setAttribute('aria-label', 'Deck controls');
874
+ overlay.setAttribute('data-miaoda-chrome', '');
875
+ overlay.innerHTML = `
876
+ <button class="btn prev" type="button" aria-label="Previous slide" title="上一页(←)">
877
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3L5 8l5 5"/></svg>
878
+ </button>
879
+ <span class="count" aria-live="polite"><span class="current">1</span><span class="sep">/</span><span class="total">1</span></span>
880
+ <button class="btn next" type="button" aria-label="Next slide" title="下一页(→)">
881
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
882
+ </button>
883
+ <span class="divider"></span>
884
+ <button class="btn reset" type="button" aria-label="Reset to first slide" title="重置(R)">重置<span class="kbd">R</span></button>
885
+ `;
886
+
887
+ overlay.querySelector('.prev').addEventListener('click', () => this._advance(-1, 'click'));
888
+ overlay.querySelector('.next').addEventListener('click', () => this._advance(1, 'click'));
889
+ overlay.querySelector('.reset').addEventListener('click', () => this._go(0, 'click'));
890
+
891
+ // Thumbnail rail + context menu. Thumbnails are populated in
892
+ // _renderRail() after _collectSlides().
893
+ const rail = document.createElement('div');
894
+ rail.className = 'rail export-hidden';
895
+ rail.setAttribute('data-miaoda-chrome', '');
896
+ rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
897
+ // Edge auto-scroll while dragging a thumb near the rail's top/bottom
898
+ // so off-screen drop targets are reachable. Native dragover fires
899
+ // continuously while the pointer is stationary, so a per-event nudge
900
+ // (ramped by edge proximity) is enough — no rAF loop needed.
901
+ rail.addEventListener('dragover', (e) => {
902
+ if (this._dragFrom == null) return;
903
+ const r = rail.getBoundingClientRect();
904
+ const EDGE = 40;
905
+ const dt = e.clientY - r.top;
906
+ const db = r.bottom - e.clientY;
907
+ if (dt < EDGE) rail.scrollTop -= Math.ceil((EDGE - dt) / 3);
908
+ else if (db < EDGE) rail.scrollTop += Math.ceil((EDGE - db) / 3);
909
+ });
910
+
911
+ const menu = document.createElement('div');
912
+ menu.className = 'ctxmenu export-hidden';
913
+ menu.setAttribute('data-miaoda-chrome', '');
914
+ menu.innerHTML = `
915
+ <button type="button" data-act="skip">跳过此页</button>
916
+ <button type="button" data-act="up">上移</button>
917
+ <button type="button" data-act="down">下移</button>
918
+ <hr>
919
+ <button type="button" data-act="delete">删除此页</button>
920
+ `;
921
+ menu.addEventListener('click', (e) => {
922
+ const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
923
+ if (!act) return;
924
+ const i = this._menuIndex;
925
+ this._closeMenu();
926
+ if (act === 'skip') this._toggleSkip(i);
927
+ else if (act === 'up') this._moveSlide(i, i - 1);
928
+ else if (act === 'down') this._moveSlide(i, i + 1);
929
+ else if (act === 'delete') this._openConfirm(i);
930
+ });
931
+ menu.addEventListener('contextmenu', (e) => e.preventDefault());
932
+
933
+ // Rail resize handle — drag to set --deck-rail-w, persisted to
934
+ // localStorage so the width survives reloads.
935
+ const resize = document.createElement('div');
936
+ resize.className = 'rail-resize export-hidden';
937
+ resize.setAttribute('data-miaoda-chrome', '');
938
+ resize.addEventListener('pointerdown', (e) => {
939
+ e.preventDefault();
940
+ resize.setPointerCapture(e.pointerId);
941
+ resize.setAttribute('data-dragging', '');
942
+ const move = (ev) => this._setRailWidth(ev.clientX);
943
+ const up = () => {
944
+ resize.removeEventListener('pointermove', move);
945
+ resize.removeEventListener('pointerup', up);
946
+ resize.removeEventListener('pointercancel', up);
947
+ resize.removeAttribute('data-dragging');
948
+ try { localStorage.setItem('deck-stage.railWidth', String(this._railPx)); } catch (err) {}
949
+ };
950
+ resize.addEventListener('pointermove', move);
951
+ resize.addEventListener('pointerup', up);
952
+ resize.addEventListener('pointercancel', up);
953
+ });
954
+
955
+ // Delete-confirm dialog — mirrors the SPA's ConfirmDialog layout.
956
+ const confirm = document.createElement('div');
957
+ confirm.className = 'confirm-backdrop export-hidden';
958
+ confirm.setAttribute('data-miaoda-chrome', '');
959
+ confirm.innerHTML = `
960
+ <div class="confirm" role="dialog" aria-modal="true">
961
+ <div class="body">
962
+ <div class="title">删除此页?</div>
963
+ <div class="msg">该页将从演示文稿中移除。</div>
964
+ </div>
965
+ <div class="footer">
966
+ <button type="button" class="cancel">取消</button>
967
+ <button type="button" class="danger">删除</button>
968
+ </div>
969
+ </div>
970
+ `;
971
+ confirm.addEventListener('click', (e) => {
972
+ if (e.target === confirm) this._closeConfirm();
973
+ });
974
+ confirm.querySelector('.cancel').addEventListener('click', () => this._closeConfirm());
975
+ confirm.querySelector('.danger').addEventListener('click', () => {
976
+ const i = this._confirmIndex;
977
+ this._closeConfirm();
978
+ this._deleteSlide(i);
979
+ });
980
+
981
+ this._root.append(style, rail, resize, stage, overlay, menu, confirm);
982
+ this._canvas = canvas;
983
+ this._stage = stage;
984
+ this._slot = slot;
985
+ this._overlay = overlay;
986
+ this._rail = rail;
987
+ this._resize = resize;
988
+ this._menu = menu;
989
+ this._confirm = confirm;
990
+ this._countEl = overlay.querySelector('.current');
991
+ this._totalEl = overlay.querySelector('.total');
992
+
993
+ // Restore persisted rail width.
994
+ let rw = 192;
995
+ try {
996
+ const s = localStorage.getItem('deck-stage.railWidth');
997
+ if (s) rw = parseInt(s, 10) || rw;
998
+ } catch (err) {}
999
+ this._setRailWidth(rw);
1000
+ this._syncRailHidden();
1001
+ }
1002
+
1003
+ _setRailWidth(px) {
1004
+ const w = Math.max(120, Math.min(360, Math.round(px)));
1005
+ this._railPx = w;
1006
+ this.style.setProperty('--deck-rail-w', w + 'px');
1007
+ this._fit();
1008
+ // _scaleThumbs forces a sync layout (frame.offsetWidth) then writes
1009
+ // N transforms. During a resize drag this runs per-pointermove;
1010
+ // coalesce to one per frame.
1011
+ if (!this._scaleRaf) {
1012
+ this._scaleRaf = requestAnimationFrame(() => {
1013
+ this._scaleRaf = null;
1014
+ this._scaleThumbs();
1015
+ });
1016
+ }
85
1017
  }
86
1018
 
87
- _fit(zoom) {
88
- const scale = zoom || Math.min(innerWidth / this._w, innerHeight / this._h);
89
- this._stage.style.transform = `translate(-50%, -50%) scale(${scale})`;
90
- this._post('miaoda:canvas:zoom', { zoom: scale });
1019
+ /** @page must live in the document stylesheet — it's a no-op inside
1020
+ * shadow DOM. Inject/update a single <head> style tag so the print
1021
+ * sheet matches the design size and Save-as-PDF yields one slide per
1022
+ * page with no margins. */
1023
+ _syncPrintPageRule() {
1024
+ const id = 'deck-stage-print-page';
1025
+ let tag = document.getElementById(id);
1026
+ if (!tag) {
1027
+ tag = document.createElement('style');
1028
+ tag.id = id;
1029
+ document.head.appendChild(tag);
1030
+ }
1031
+ tag.textContent =
1032
+ '@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
1033
+ '@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
1034
+ '* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } ' +
1035
+ // Jump authored animations/transitions to their end state so print
1036
+ // never captures mid-entrance — pairs with the beforeprint handler
1037
+ // in connectedCallback that sets data-deck-active on every slide.
1038
+ '*, *::before, *::after { animation-delay: -99s !important; animation-duration: .001s !important; ' +
1039
+ 'animation-iteration-count: 1 !important; animation-fill-mode: both !important; ' +
1040
+ 'animation-play-state: running !important; transition-duration: 0s !important; } }';
91
1041
  }
92
1042
 
93
- _post(type, extra) {
1043
+ _onSlotChange() {
1044
+ // Rail mutations (delete/move) already reconcile synchronously and
1045
+ // emit slidechange with reason 'api'; skip the async slotchange that
1046
+ // would otherwise re-broadcast with reason 'init'.
1047
+ if (this._squelchSlotChange) { this._squelchSlotChange = false; return; }
1048
+ this._collectSlides();
1049
+ this._restoreIndex();
1050
+ this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
1051
+ this._fit();
1052
+ }
1053
+
1054
+ _collectSlides() {
1055
+ const assigned = this._slot.assignedElements({ flatten: true });
1056
+ this._slides = assigned.filter((el) => {
1057
+ // Skip template/style/script nodes even if someone slots them.
1058
+ const tag = el.tagName;
1059
+ return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
1060
+ });
1061
+ this._slideSet = new Set(this._slides);
1062
+
1063
+ this._slides.forEach((slide, i) => {
1064
+ const n = i + 1;
1065
+ slide.setAttribute('data-screen-label', `${pad2(n)} ${getSlideLabel(slide)}`);
1066
+
1067
+ // Validation attribute for comment flow / auto-checks.
1068
+ if (!slide.hasAttribute('data-miaoda-validate')) {
1069
+ slide.setAttribute('data-miaoda-validate', VALIDATE_ATTR);
1070
+ }
1071
+
1072
+ slide.setAttribute('data-deck-slide', String(i));
1073
+ });
1074
+
1075
+ if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
1076
+ if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
1077
+ this._markLastVisible();
1078
+ this._renderRail();
1079
+ }
1080
+
1081
+ /** Tag the last non-skipped slide so print CSS can drop its
1082
+ * break-after (see the @media print comment above — :last-child
1083
+ * alone matches a hidden skipped slide). */
1084
+ _markLastVisible() {
1085
+ let last = null;
1086
+ this._slides.forEach((s) => {
1087
+ s.removeAttribute('data-deck-last-visible');
1088
+ if (!s.hasAttribute('data-deck-skip')) last = s;
1089
+ });
1090
+ if (last) last.setAttribute('data-deck-last-visible', '');
1091
+ }
1092
+
1093
+ _loadNotes() {
1094
+ const tag = document.getElementById('speaker-notes');
1095
+ if (!tag) { this._notes = []; return; }
94
1096
  try {
95
- window.parent.postMessage({ type, ...extra }, '*');
96
- } catch (_) {}
1097
+ const parsed = JSON.parse(tag.textContent || '[]');
1098
+ if (Array.isArray(parsed)) this._notes = parsed;
1099
+ } catch (e) {
1100
+ console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
1101
+ this._notes = [];
1102
+ }
97
1103
  }
98
1104
 
99
- goTo(n) {
1105
+ _restoreIndex() {
1106
+ // The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
1107
+ // the iframe src. No hash → slide 1; the deck itself keeps no position
1108
+ // state across loads.
1109
+ const h = (location.hash || '').match(/^#(\d+)$/);
1110
+ if (h) {
1111
+ const n = parseInt(h[1], 10) - 1;
1112
+ if (n >= 0 && n < this._slides.length) this._index = n;
1113
+ }
1114
+ }
1115
+
1116
+ _applyIndex({ showOverlay = true, broadcast = true, reason = 'init' } = {}) {
100
1117
  if (!this._slides.length) return;
101
- this._index = Math.max(0, Math.min(n, this._slides.length - 1));
102
- this._slides.forEach((el, i) => {
103
- el.hidden = i !== this._index;
1118
+ const prev = this._prevIndex == null ? -1 : this._prevIndex;
1119
+ const curr = this._index;
1120
+ // Keep the iframe's own hash in sync so an in-iframe location.reload()
1121
+ // (reload banner path in viewer-handle.ts) lands on the current slide,
1122
+ // not the stale deep-link hash from initial load.
1123
+ try { history.replaceState(null, '', '#' + (curr + 1)); } catch (e) {}
1124
+ this._slides.forEach((s, i) => {
1125
+ if (i === curr) s.setAttribute('data-deck-active', '');
1126
+ else s.removeAttribute('data-deck-active');
104
1127
  });
105
- this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: this._index } }));
106
- this._post('miaoda:deck:slide-changed', { index: this._index });
107
- const u = new URL(location.href);
108
- u.searchParams.set('slide', String(this._index + 1));
109
- history.replaceState(null, '', u);
1128
+ if (this._countEl) this._countEl.textContent = String(curr + 1);
1129
+ // Follow-scroll on every navigation (init deep-link, keyboard, click,
1130
+ // tap, external goTo) — the only time we *don't* want the rail to
1131
+ // track current is after a rail-internal mutation, where _renderRail
1132
+ // has already restored the user's scroll position and yanking back to
1133
+ // current would undo it.
1134
+ this._syncRail(reason !== 'mutation');
1135
+
1136
+ if (broadcast) {
1137
+ // (1) Parent-window postMessage for speaker-notes renderers.
1138
+ try { window.parent.postMessage({ type: 'miaoda:deck:slide-changed', index: curr, total: this._slides.length, skipped: this._skippedIndices() }, '*'); } catch (e) {}
1139
+
1140
+ // (2) In-page CustomEvent on the <deck-stage> element itself.
1141
+ // Bubbles and composes out of shadow DOM so slide code can listen:
1142
+ // document.querySelector('deck-stage').addEventListener('slidechange', e => {
1143
+ // e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
1144
+ // });
1145
+ const detail = {
1146
+ index: curr,
1147
+ previousIndex: prev,
1148
+ total: this._slides.length,
1149
+ slide: this._slides[curr] || null,
1150
+ previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
1151
+ reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
1152
+ };
1153
+ this.dispatchEvent(new CustomEvent('slidechange', {
1154
+ detail,
1155
+ bubbles: true,
1156
+ composed: true,
1157
+ }));
1158
+ }
1159
+
1160
+ this._prevIndex = curr;
1161
+ if (showOverlay) this._flashOverlay();
1162
+ }
1163
+
1164
+ _flashOverlay() {
1165
+ // Host posts miaoda:deck:presenting while in fullscreen/tab presentation
1166
+ // mode — suppress the nav footer entirely (both hover and slide-change
1167
+ // flash) so the audience sees clean slides.
1168
+ if (!this._overlay || this._presenting) return;
1169
+ this._overlay.setAttribute('data-visible', '');
1170
+ if (this._hideTimer) clearTimeout(this._hideTimer);
1171
+ this._hideTimer = setTimeout(() => {
1172
+ this._overlay.removeAttribute('data-visible');
1173
+ }, OVERLAY_HIDE_MS);
1174
+ }
1175
+
1176
+ _railWidth() {
1177
+ // State-based, no offsetWidth: the first _fit() can run before the
1178
+ // rail has had layout on some load paths, and a 0 there paints the
1179
+ // slide full-width for one frame before the post-slotchange _fit()
1180
+ // corrects it.
1181
+ if (!this._railEnabled || !this._railVisible || this.hasAttribute('no-rail')
1182
+ || this.hasAttribute('noscale') || this._presenting || this._previewMode
1183
+ || NARROW_MQ.matches) return 0;
1184
+ return this._railPx || 0;
1185
+ }
1186
+
1187
+ _fit() {
1188
+ if (!this._canvas) return;
1189
+ const stage = this._canvas.parentElement;
1190
+ // PPTX export sets noscale so the DOM capture sees authored-size
1191
+ // geometry — the scaled canvas is in shadow DOM, so the exporter's
1192
+ // resetTransformSelector can't reach .canvas.style.transform directly.
1193
+ if (this.hasAttribute('noscale')) {
1194
+ this._canvas.style.transform = 'none';
1195
+ if (stage) stage.style.left = '0';
1196
+ if (this._overlay) this._overlay.style.marginLeft = '0';
1197
+ return;
1198
+ }
1199
+ const rw = this._railWidth();
1200
+ if (stage) stage.style.left = rw + 'px';
1201
+ // Overlay is centred on the viewport via left:50% + translate(-50%);
1202
+ // marginLeft shifts the centre by rw/2 so it lands in the middle of
1203
+ // the [rw, innerWidth] stage region.
1204
+ if (this._overlay) this._overlay.style.marginLeft = (rw / 2) + 'px';
1205
+ const vw = window.innerWidth - rw;
1206
+ const vh = window.innerHeight;
1207
+ const s = Math.min(vw / this.designWidth, vh / this.designHeight);
1208
+ this._canvas.style.transform = `scale(${s})`;
1209
+ }
1210
+
1211
+ _onResize() {
1212
+ this._fit();
1213
+ // Crossing the narrow-viewport breakpoint reveals the rail — rerun the
1214
+ // thumbnail scale the same way _setRailWidth does.
1215
+ if (!this._scaleRaf) {
1216
+ this._scaleRaf = requestAnimationFrame(() => {
1217
+ this._scaleRaf = null;
1218
+ this._scaleThumbs();
1219
+ });
1220
+ }
110
1221
  }
111
1222
 
112
- get index() {
113
- return this._index;
1223
+ _onMouseMove() {
1224
+ // Keep overlay visible while mouse moves; hide after idle.
1225
+ this._flashOverlay();
114
1226
  }
1227
+
1228
+ _onMessage(e) {
1229
+ const d = e.data;
1230
+ if (d && d.type === 'miaoda:deck:presenting' && typeof d.on === 'boolean') {
1231
+ this._presenting = d.on;
1232
+ if (this._presenting && this._overlay) {
1233
+ this._overlay.removeAttribute('data-visible');
1234
+ if (this._hideTimer) clearTimeout(this._hideTimer);
1235
+ }
1236
+ this._syncRailHidden();
1237
+ this._closeMenu();
1238
+ this._closeConfirm();
1239
+ this._fit();
1240
+ this._scaleThumbs();
1241
+ }
1242
+ // Host's Preview segment (ViewerMode='none'): the rail's drag-reorder /
1243
+ // right-click skip-delete affordances are editing chrome, so hide it
1244
+ // while the user is just looking at the deck. Same hard-hide path as
1245
+ // presenting; independent of the user's _railVisible preference so
1246
+ // returning to Edit restores whatever they had.
1247
+ if (d && d.type === 'miaoda:deck:preview-mode' && typeof d.on === 'boolean') {
1248
+ if (d.on === this._previewMode) return;
1249
+ this._previewMode = d.on;
1250
+ this._syncRailHidden();
1251
+ this._closeMenu();
1252
+ this._closeConfirm();
1253
+ this._fit();
1254
+ this._scaleThumbs();
1255
+ }
1256
+ // Per-viewer show/hide, driven by the TweaksPanel's auto-injected
1257
+ // "Thumbnail rail" toggle (or any author script). Independent of
1258
+ // whether the Tweaks panel itself is open — closing the panel
1259
+ // doesn't change rail visibility. Persists alongside rail width.
1260
+ if (d && d.type === 'miaoda:deck:rail-visible' && typeof d.on === 'boolean') {
1261
+ if (d.on === this._railVisible) return;
1262
+ this._railVisible = d.on;
1263
+ try { localStorage.setItem('deck-stage.railVisible', d.on ? '1' : '0'); } catch (e) {}
1264
+ // Notify the parent so its toolbar state stays in sync.
1265
+ try { window.parent.postMessage({ type: 'miaoda:deck:rail-visible-changed', on: d.on }, '*'); } catch (e) {}
1266
+ // Arm the transition, commit it, then flip state — otherwise the
1267
+ // browser coalesces both writes and nothing animates on show.
1268
+ this.setAttribute('data-rail-anim', '');
1269
+ void (this._rail && this._rail.offsetHeight);
1270
+ this._syncRailHidden();
1271
+ this._fit();
1272
+ this._scaleThumbs();
1273
+ clearTimeout(this._railAnimTimer);
1274
+ this._railAnimTimer = setTimeout(() => this.removeAttribute('data-rail-anim'), 220);
1275
+ }
1276
+ if (d && d.type === 'miaoda:deck:rail-enabled') this._enableRail();
1277
+ if (d && d.type === 'miaoda:deck:goto' && typeof d.index === 'number') this._go(d.index | 0, 'api');
1278
+ }
1279
+
1280
+ _syncRailHidden() {
1281
+ if (!this._rail) return;
1282
+ // data-presenting is the hard hide (display:none) for flag-off,
1283
+ // presentation mode, and the host's Preview segment — instant, no
1284
+ // transition. data-user-hidden is the soft hide (translateX(-100%))
1285
+ // for the viewer's rail toggle, so show/hide slides under
1286
+ // :host([data-rail-anim]).
1287
+ const hard = !this._railEnabled || this._presenting || this._previewMode;
1288
+ if (hard) this._rail.setAttribute('data-presenting', '');
1289
+ else this._rail.removeAttribute('data-presenting');
1290
+ if (!this._railVisible) this._rail.setAttribute('data-user-hidden', '');
1291
+ else this._rail.removeAttribute('data-user-hidden');
1292
+ // translateX hide leaves thumbs (tabIndex=0) in the tab order —
1293
+ // inert keeps them unfocusable while the rail is off-screen.
1294
+ this._rail.inert = hard || !this._railVisible;
1295
+ }
1296
+
1297
+ _onTap(e) {
1298
+ // Touch-only — keyboard + the overlay toolbar cover nav on desktop.
1299
+ if (FINE_POINTER_MQ.matches) return;
1300
+ // Only taps that land on the stage (slide content or letterbox); the
1301
+ // overlay / rail / menus are siblings with their own click handlers.
1302
+ const path = e.composedPath();
1303
+ if (!this._stage || !path.includes(this._stage)) return;
1304
+ // Let interactive slide content keep the tap. composedPath (not
1305
+ // e.target.closest) so we see through open shadow roots — a <button>
1306
+ // inside a slide-authored custom element retargets e.target to the
1307
+ // host but still appears in the composed path.
1308
+ if (e.defaultPrevented) return;
1309
+ for (const n of path) {
1310
+ if (n === this._stage) break;
1311
+ if (n.matches && n.matches(INTERACTIVE_SEL)) return;
1312
+ }
1313
+ e.preventDefault();
1314
+ const rw = this._railWidth();
1315
+ const mid = rw + (window.innerWidth - rw) / 2;
1316
+ this._advance(e.clientX < mid ? -1 : 1, 'tap');
1317
+ }
1318
+
1319
+ _onKey(e) {
1320
+ // Ignore when the user is typing.
1321
+ const t = e.target;
1322
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
1323
+ // Confirm dialog swallows nav keys while open; Escape cancels. Enter
1324
+ // is left to the focused button's native activation so Tab→Cancel
1325
+ // →Enter activates Cancel, not the window-level confirm path.
1326
+ if (this._confirm && this._confirm.hasAttribute('data-open')) {
1327
+ if (e.key === 'Escape') { this._closeConfirm(); e.preventDefault(); }
1328
+ return;
1329
+ }
1330
+ if (e.key === 'Escape' && this._menu && this._menu.hasAttribute('data-open')) {
1331
+ this._closeMenu();
1332
+ e.preventDefault();
1333
+ return;
1334
+ }
1335
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1336
+
1337
+ const key = e.key;
1338
+ let handled = true;
1339
+
1340
+ if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
1341
+ this._advance(1, 'keyboard');
1342
+ } else if (key === 'ArrowLeft' || key === 'PageUp') {
1343
+ this._advance(-1, 'keyboard');
1344
+ } else if (key === 'Home') {
1345
+ this._go(0, 'keyboard');
1346
+ } else if (key === 'End') {
1347
+ this._go(this._slides.length - 1, 'keyboard');
1348
+ } else if (key === 'r' || key === 'R') {
1349
+ this._go(0, 'keyboard');
1350
+ } else if (/^[0-9]$/.test(key)) {
1351
+ // 1..9 jump to that slide; 0 jumps to 10.
1352
+ const n = key === '0' ? 9 : parseInt(key, 10) - 1;
1353
+ if (n < this._slides.length) this._go(n, 'keyboard');
1354
+ } else {
1355
+ handled = false;
1356
+ }
1357
+
1358
+ if (handled) {
1359
+ e.preventDefault();
1360
+ this._flashOverlay();
1361
+ }
1362
+ }
1363
+
1364
+ _go(i, reason = 'api') {
1365
+ if (!this._slides.length) return;
1366
+ const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
1367
+ if (clamped === this._index) {
1368
+ this._flashOverlay();
1369
+ return;
1370
+ }
1371
+ this._index = clamped;
1372
+ this._applyIndex({ showOverlay: true, broadcast: true, reason });
1373
+ }
1374
+
1375
+ /** Step forward/back skipping any slide marked data-deck-skip. Falls
1376
+ * back to _go's clamp-at-ends behaviour (flash overlay) when there's
1377
+ * nothing further in that direction. */
1378
+ _advance(dir, reason) {
1379
+ if (!this._slides.length) return;
1380
+ let i = this._index + dir;
1381
+ while (i >= 0 && i < this._slides.length && this._slides[i].hasAttribute('data-deck-skip')) {
1382
+ i += dir;
1383
+ }
1384
+ if (i < 0 || i >= this._slides.length) { this._flashOverlay(); return; }
1385
+ this._go(i, reason);
1386
+ }
1387
+
1388
+ // ── Thumbnail rail ────────────────────────────────────────────────────
1389
+ //
1390
+ // Thumbs are keyed by slide element and reused across _renderRail()
1391
+ // calls, so a reorder/delete is an O(changed) DOM shuffle instead of an
1392
+ // O(N) teardown-and-re-clone. Each thumb starts as a lightweight shell
1393
+ // (num + empty frame); the clone is materialized lazily by an
1394
+ // IntersectionObserver when the frame scrolls into (or near) view, so
1395
+ // only visible-ish slides pay the clone + image-decode cost.
1396
+
1397
+ _renderRail() {
1398
+ if (!this._rail || !this._railEnabled) { this._thumbs = []; return; }
1399
+ // FLIP: record each *materialized* thumb's top before the reconcile.
1400
+ // Off-screen (non-materialized) thumbs don't need the animation and
1401
+ // skipping their getBoundingClientRect saves a forced layout per
1402
+ // off-screen thumb on large decks.
1403
+ const prevTops = new Map();
1404
+ (this._thumbs || []).forEach(({ thumb, slide, host }) => {
1405
+ if (host) prevTops.set(slide, thumb.getBoundingClientRect().top);
1406
+ });
1407
+ const st = this._rail.scrollTop;
1408
+
1409
+ // Reconcile: reuse thumbs that already exist for a slide, create
1410
+ // shells for new slides, drop thumbs for removed slides.
1411
+ const bySlide = new Map();
1412
+ (this._thumbs || []).forEach((t) => bySlide.set(t.slide, t));
1413
+ const next = [];
1414
+ this._slides.forEach((slide) => {
1415
+ let t = bySlide.get(slide);
1416
+ if (t) bySlide.delete(slide);
1417
+ else t = this._makeThumb(slide);
1418
+ next.push(t);
1419
+ });
1420
+ // Orphans — slides removed since last render.
1421
+ bySlide.forEach((t) => {
1422
+ if (this._railObserver) this._railObserver.unobserve(t.frame);
1423
+ t.thumb.remove();
1424
+ });
1425
+ // Put thumbs into document order to match _slides. insertBefore on
1426
+ // an already-correctly-placed node is a no-op, so this is cheap
1427
+ // when nothing moved.
1428
+ next.forEach((t, i) => {
1429
+ const want = t.thumb;
1430
+ const at = this._rail.children[i];
1431
+ if (at !== want) this._rail.insertBefore(want, at || null);
1432
+ t.i = i;
1433
+ t.num.textContent = String(i + 1);
1434
+ if (t.slide.hasAttribute('data-deck-skip')) t.thumb.setAttribute('data-skip', '');
1435
+ else t.thumb.removeAttribute('data-skip');
1436
+ });
1437
+ this._thumbs = next;
1438
+
1439
+ this._rail.scrollTop = st;
1440
+ if (prevTops.size) {
1441
+ const moved = [];
1442
+ this._thumbs.forEach(({ thumb, slide }) => {
1443
+ const old = prevTops.get(slide);
1444
+ if (old == null) return;
1445
+ const dy = old - thumb.getBoundingClientRect().top;
1446
+ if (Math.abs(dy) < 1) return;
1447
+ thumb.style.transition = 'none';
1448
+ thumb.style.transform = `translateY(${dy}px)`;
1449
+ moved.push(thumb);
1450
+ });
1451
+ if (moved.length) {
1452
+ // Commit the inverted positions before flipping the transition
1453
+ // on — otherwise the browser coalesces both style writes and
1454
+ // nothing animates.
1455
+ void this._rail.offsetHeight;
1456
+ moved.forEach((t) => {
1457
+ t.style.transition = 'transform 180ms cubic-bezier(.2,.7,.3,1)';
1458
+ t.style.transform = '';
1459
+ });
1460
+ setTimeout(() => moved.forEach((t) => { t.style.transition = ''; }), 220);
1461
+ }
1462
+ }
1463
+ requestAnimationFrame(() => this._scaleThumbs());
1464
+ this._syncRail(false);
1465
+ }
1466
+
1467
+ /** Create a lightweight thumb shell for one slide. The clone is
1468
+ * materialized later by the IntersectionObserver. Event handlers
1469
+ * look up the thumb's *current* index (via _thumbs.indexOf) so the
1470
+ * same element can be reused across reorders. */
1471
+ _makeThumb(slide) {
1472
+ const thumb = document.createElement('div');
1473
+ thumb.className = 'thumb';
1474
+ thumb.tabIndex = 0;
1475
+ const num = document.createElement('div');
1476
+ num.className = 'num';
1477
+ const frame = document.createElement('div');
1478
+ frame.className = 'frame';
1479
+ thumb.append(num, frame);
1480
+
1481
+ const entry = { thumb, num, frame, slide, clone: null, host: null, i: -1 };
1482
+ // entry.i is refreshed on every _renderRail reconcile pass, so
1483
+ // handlers read the thumb's current position without an O(N) scan.
1484
+ const idx = () => entry.i;
1485
+
1486
+ thumb.addEventListener('click', () => this._go(idx(), 'click'));
1487
+ // ↑/↓ step through the rail when a thumb has focus. _go clamps at the
1488
+ // ends and _applyIndex→_syncRail scrolls the new current thumb into
1489
+ // view; we move focus to it (preventScroll — _syncRail already
1490
+ // scrolled) so a held key walks the whole list. stopPropagation keeps
1491
+ // this out of the window-level _onKey nav handler.
1492
+ thumb.addEventListener('keydown', (e) => {
1493
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
1494
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1495
+ e.preventDefault();
1496
+ e.stopPropagation();
1497
+ this._go(idx() + (e.key === 'ArrowDown' ? 1 : -1), 'keyboard');
1498
+ const cur = this._thumbs && this._thumbs[this._index];
1499
+ if (cur) cur.thumb.focus({ preventScroll: true });
1500
+ });
1501
+ thumb.addEventListener('contextmenu', (e) => {
1502
+ e.preventDefault();
1503
+ this._openMenu(idx(), e.clientX, e.clientY);
1504
+ });
1505
+ thumb.draggable = true;
1506
+ thumb.addEventListener('dragstart', (e) => {
1507
+ this._dragFrom = idx();
1508
+ thumb.setAttribute('data-dragging', '');
1509
+ e.dataTransfer.effectAllowed = 'move';
1510
+ try { e.dataTransfer.setData('text/plain', String(this._dragFrom)); } catch (err) {}
1511
+ });
1512
+ thumb.addEventListener('dragend', () => {
1513
+ thumb.removeAttribute('data-dragging');
1514
+ this._clearDrop();
1515
+ this._dragFrom = null;
1516
+ });
1517
+ thumb.addEventListener('dragover', (e) => {
1518
+ if (this._dragFrom == null) return;
1519
+ e.preventDefault();
1520
+ e.dataTransfer.dropEffect = 'move';
1521
+ const r = thumb.getBoundingClientRect();
1522
+ this._setDrop(idx(), e.clientY < r.top + r.height / 2 ? 'before' : 'after');
1523
+ });
1524
+ thumb.addEventListener('drop', (e) => {
1525
+ if (this._dragFrom == null) return;
1526
+ e.preventDefault();
1527
+ const i = idx();
1528
+ const r = thumb.getBoundingClientRect();
1529
+ let to = e.clientY >= r.top + r.height / 2 ? i + 1 : i;
1530
+ if (this._dragFrom < to) to--;
1531
+ const from = this._dragFrom;
1532
+ this._clearDrop();
1533
+ this._dragFrom = null;
1534
+ if (to !== from) this._moveSlide(from, to);
1535
+ });
1536
+
1537
+ if (this._railObserver) this._railObserver.observe(frame);
1538
+ frame.__deckThumb = entry;
1539
+ return entry;
1540
+ }
1541
+
1542
+ /** Lazily build the clone for a thumb that has scrolled into view. */
1543
+ _materialize(entry) {
1544
+ if (entry.host) return;
1545
+ const dw = this.designWidth, dh = this.designHeight;
1546
+ let clone = entry.slide.cloneNode(true);
1547
+ clone.removeAttribute('id');
1548
+ clone.removeAttribute('data-deck-active');
1549
+ clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
1550
+ // Neuter heavy media; replace <video> with its poster so the box
1551
+ // keeps a visual. <iframe>/<audio> become empty placeholders.
1552
+ clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {
1553
+ el.removeAttribute('src');
1554
+ el.removeAttribute('srcdoc');
1555
+ el.removeAttribute('data');
1556
+ el.innerHTML = '';
1557
+ });
1558
+ clone.querySelectorAll('video').forEach((el) => {
1559
+ if (!el.poster) { el.removeAttribute('src'); el.innerHTML = ''; return; }
1560
+ const img = document.createElement('img');
1561
+ img.src = el.poster;
1562
+ img.alt = '';
1563
+ img.style.cssText = el.style.cssText + ';object-fit:cover;width:100%;height:100%;';
1564
+ img.className = el.className;
1565
+ el.replaceWith(img);
1566
+ });
1567
+ // Canvas: cloneNode does not copy drawn pixels — snapshot each
1568
+ // original canvas into an <img> so ECharts / Chart.js / custom
1569
+ // drawings appear in the thumbnail instead of a blank rectangle.
1570
+ const origCanvases = entry.slide.querySelectorAll('canvas');
1571
+ const cloneCanvases = clone.querySelectorAll('canvas');
1572
+ cloneCanvases.forEach((el, ci) => {
1573
+ const orig = origCanvases[ci];
1574
+ if (!orig || !orig.width || !orig.height) return;
1575
+ try {
1576
+ const img = document.createElement('img');
1577
+ img.src = orig.toDataURL();
1578
+ img.alt = '';
1579
+ img.style.cssText = (el.getAttribute('style') || '') +
1580
+ ';width:' + orig.offsetWidth + 'px;height:' + orig.offsetHeight + 'px;';
1581
+ img.className = el.className;
1582
+ el.replaceWith(img);
1583
+ } catch (e) {}
1584
+ });
1585
+ // Images: defer decode and let the browser pick the smallest
1586
+ // srcset candidate for the ~140px thumb. Same-URL clones reuse the
1587
+ // slide's decoded bitmap (URL-keyed cache), so the remaining cost
1588
+ // is paint/composite — lazy+async keeps that off the main thread.
1589
+ clone.querySelectorAll('img').forEach((el) => {
1590
+ el.loading = 'lazy';
1591
+ el.decoding = 'async';
1592
+ if (el.srcset) el.sizes = (this._railPx || 192) + 'px';
1593
+ });
1594
+ // Custom elements inside the slide would have their
1595
+ // connectedCallback fire when the clone is appended. Replace them
1596
+ // with inert boxes so a component-heavy deck doesn't run N copies
1597
+ // of each component's mount logic in the rail. Children are
1598
+ // preserved so layout-wrapper elements (<my-column><h2>…</h2>)
1599
+ // still show their authored content; the querySelectorAll NodeList
1600
+ // is static, so nested custom elements in the moved subtree are
1601
+ // still visited on later iterations.
1602
+ const neuter = (el) => {
1603
+ const box = document.createElement('div');
1604
+ box.style.cssText = (el.getAttribute('style') || '') +
1605
+ ';background:rgba(0,0,0,0.06);border:1px dashed rgba(0,0,0,0.15);';
1606
+ box.className = el.className;
1607
+ // Preserve theming/i18n hooks so [data-*] / :lang() / [dir]
1608
+ // descendant selectors still match the neutered root.
1609
+ for (const a of el.attributes) {
1610
+ const n = a.name;
1611
+ if (n.startsWith('data-') || n.startsWith('aria-') ||
1612
+ n === 'lang' || n === 'dir' || n === 'role' || n === 'title') {
1613
+ box.setAttribute(n, a.value);
1614
+ }
1615
+ }
1616
+ while (el.firstChild) box.appendChild(el.firstChild);
1617
+ return box;
1618
+ };
1619
+ // querySelectorAll('*') returns descendants only — a custom-element
1620
+ // slide root (<my-slide>…</my-slide>) would slip through and upgrade
1621
+ // on append. Swap the root first.
1622
+ if (clone.tagName.includes('-')) clone = neuter(clone);
1623
+ clone.querySelectorAll('*').forEach((el) => {
1624
+ if (el.tagName.includes('-')) el.replaceWith(neuter(el));
1625
+ });
1626
+ clone.style.cssText += ';position:absolute;top:0;left:0;transform-origin:0 0;' +
1627
+ 'pointer-events:none;width:' + dw + 'px;height:' + dh + 'px;' +
1628
+ 'box-sizing:border-box;overflow:hidden;visibility:visible;opacity:1;';
1629
+ const host = document.createElement('div');
1630
+ host.style.cssText = 'position:absolute;inset:0;';
1631
+ this._syncThumbHostAttrs(host);
1632
+ const sr = host.attachShadow({ mode: 'open' });
1633
+ if (this._adoptedSheet) sr.adoptedStyleSheets = [this._adoptedSheet];
1634
+ else {
1635
+ const st = document.createElement('style');
1636
+ st.textContent = this._authorCss || '';
1637
+ sr.appendChild(st);
1638
+ }
1639
+ sr.appendChild(clone);
1640
+ entry.frame.appendChild(host);
1641
+ entry.host = host;
1642
+ entry.clone = clone;
1643
+ if (this._thumbScale) clone.style.transform = 'scale(' + this._thumbScale + ')';
1644
+ // Once materialized the IO callback is a no-op early-return —
1645
+ // unobserve so scroll doesn't keep firing it.
1646
+ if (this._railObserver) this._railObserver.unobserve(entry.frame);
1647
+ }
1648
+
1649
+ /** Re-clone a single thumb (live-update path). No-op if the thumb
1650
+ * hasn't been materialized yet — it'll pick up current content when
1651
+ * it scrolls into view. */
1652
+ _refreshThumb(slide) {
1653
+ const entry = (this._thumbs || []).find((t) => t.slide === slide);
1654
+ if (!entry || !entry.host) return;
1655
+ entry.host.remove();
1656
+ entry.host = entry.clone = null;
1657
+ this._materialize(entry);
1658
+ }
1659
+
1660
+ _scaleThumbs() {
1661
+ if (!this._thumbs || !this._thumbs.length) return;
1662
+ // Every frame is the same width; if it reads 0 the rail is
1663
+ // display:none (noscale / no-rail / presenting / print) — leave the
1664
+ // clones as-is and re-run when the rail is revealed.
1665
+ const fw = this._thumbs[0].frame.offsetWidth;
1666
+ if (!fw) return;
1667
+ this._thumbScale = fw / this.designWidth;
1668
+ this._thumbs.forEach(({ clone }) => {
1669
+ if (clone) clone.style.transform = 'scale(' + this._thumbScale + ')';
1670
+ });
1671
+ }
1672
+
1673
+ _setDrop(i, where) {
1674
+ // dragover fires at pointer-event rate; touch only the previous
1675
+ // and new target rather than sweeping all N thumbs.
1676
+ const t = this._thumbs && this._thumbs[i];
1677
+ if (this._dropOn && this._dropOn !== t) {
1678
+ this._dropOn.thumb.removeAttribute('data-drop');
1679
+ }
1680
+ if (t) t.thumb.setAttribute('data-drop', where);
1681
+ this._dropOn = t || null;
1682
+ }
1683
+
1684
+ _clearDrop() {
1685
+ if (this._dropOn) this._dropOn.thumb.removeAttribute('data-drop');
1686
+ this._dropOn = null;
1687
+ }
1688
+
1689
+ _syncRail(follow) {
1690
+ if (!this._thumbs) return;
1691
+ this._thumbs.forEach(({ thumb }, i) => {
1692
+ if (i === this._index) {
1693
+ thumb.setAttribute('data-current', '');
1694
+ if (follow && typeof thumb.scrollIntoView === 'function') {
1695
+ thumb.scrollIntoView({ block: 'nearest' });
1696
+ }
1697
+ } else {
1698
+ thumb.removeAttribute('data-current');
1699
+ }
1700
+ });
1701
+ }
1702
+
1703
+ _openMenu(i, x, y) {
1704
+ if (!this._menu) return;
1705
+ this._menuIndex = i;
1706
+ const slide = this._slides[i];
1707
+ const skip = slide && slide.hasAttribute('data-deck-skip');
1708
+ this._menu.querySelector('[data-act="skip"]').textContent = skip ? '取消跳过' : '跳过此页';
1709
+ this._menu.querySelector('[data-act="up"]').disabled = i <= 0;
1710
+ this._menu.querySelector('[data-act="down"]').disabled = i >= this._slides.length - 1;
1711
+ this._menu.querySelector('[data-act="delete"]').disabled = this._slides.length <= 1;
1712
+ // Place, then clamp to viewport after it's measurable.
1713
+ this._menu.style.left = x + 'px';
1714
+ this._menu.style.top = y + 'px';
1715
+ this._menu.setAttribute('data-open', '');
1716
+ const r = this._menu.getBoundingClientRect();
1717
+ const nx = Math.min(x, window.innerWidth - r.width - 4);
1718
+ const ny = Math.min(y, window.innerHeight - r.height - 4);
1719
+ this._menu.style.left = Math.max(4, nx) + 'px';
1720
+ this._menu.style.top = Math.max(4, ny) + 'px';
1721
+ }
1722
+
1723
+ _closeMenu() {
1724
+ if (this._menu) this._menu.removeAttribute('data-open');
1725
+ this._menuIndex = -1;
1726
+ }
1727
+
1728
+ _openConfirm(i) {
1729
+ if (!this._confirm) return;
1730
+ this._confirmIndex = i;
1731
+ this._confirm.querySelector('.title').textContent = '删除第 ' + (i + 1) + ' 页?';
1732
+ this._confirm.setAttribute('data-open', '');
1733
+ const btn = this._confirm.querySelector('.danger');
1734
+ if (btn && btn.focus) btn.focus();
1735
+ }
1736
+
1737
+ _closeConfirm() {
1738
+ if (this._confirm) this._confirm.removeAttribute('data-open');
1739
+ this._confirmIndex = -1;
1740
+ }
1741
+
1742
+ _emitDeckChange(detail) {
1743
+ this.dispatchEvent(new CustomEvent('deckchange', {
1744
+ detail, bubbles: true, composed: true,
1745
+ }));
1746
+ // Forward to parent so the host can persist mutations (the CustomEvent
1747
+ // cannot cross iframe boundaries). detail.slide is an Element and can't
1748
+ // survive structured-clone, so only the scalars cross.
1749
+ try {
1750
+ window.parent.postMessage({
1751
+ type: 'miaoda:deck:deck-changed',
1752
+ action: detail.action, from: detail.from, to: detail.to,
1753
+ html: this.outerHTML,
1754
+ }, '*');
1755
+ } catch (e) {}
1756
+ }
1757
+
1758
+ _deleteSlide(i) {
1759
+ const slide = this._slides[i];
1760
+ if (!slide || this._slides.length <= 1) return;
1761
+ const wasCurrent = i === this._index;
1762
+ if (i < this._index || (wasCurrent && i === this._slides.length - 1)) this._index--;
1763
+ this._squelchSlotChange = true;
1764
+ slide.remove();
1765
+ this._emitDeckChange({ action: 'delete', from: i, slide });
1766
+ this._collectSlides();
1767
+ this._applyIndex({ showOverlay: true, broadcast: true, reason: 'mutation' });
1768
+ }
1769
+
1770
+ _toggleSkip(i) {
1771
+ const slide = this._slides[i];
1772
+ if (!slide) return;
1773
+ const on = !slide.hasAttribute('data-deck-skip');
1774
+ if (on) slide.setAttribute('data-deck-skip', '');
1775
+ else slide.removeAttribute('data-deck-skip');
1776
+ if (this._thumbs && this._thumbs[i]) {
1777
+ if (on) this._thumbs[i].thumb.setAttribute('data-skip', '');
1778
+ else this._thumbs[i].thumb.removeAttribute('data-skip');
1779
+ }
1780
+ this._markLastVisible();
1781
+ this._emitDeckChange({ action: on ? 'skip' : 'unskip', from: i, slide });
1782
+ // Re-broadcast so the presenter popup's prev/next thumbnails re-pick
1783
+ // the nearest non-skipped slide without waiting for a nav event.
1784
+ try { window.parent.postMessage({ type: 'miaoda:deck:slide-changed', index: this._index, total: this._slides.length, skipped: this._skippedIndices() }, '*'); } catch (e) {}
1785
+ }
1786
+
1787
+ _skippedIndices() {
1788
+ const out = [];
1789
+ for (let i = 0; i < this._slides.length; i++) {
1790
+ if (this._slides[i].hasAttribute('data-deck-skip')) out.push(i);
1791
+ }
1792
+ return out;
1793
+ }
1794
+
1795
+ _moveSlide(i, j) {
1796
+ if (j < 0 || j >= this._slides.length || j === i) return;
1797
+ const slide = this._slides[i];
1798
+ const ref = j < i ? this._slides[j] : this._slides[j].nextSibling;
1799
+ // Track the active slide across the reorder so the same content
1800
+ // stays on screen.
1801
+ const cur = this._index;
1802
+ if (cur === i) this._index = j;
1803
+ else if (i < cur && j >= cur) this._index = cur - 1;
1804
+ else if (i > cur && j <= cur) this._index = cur + 1;
1805
+ this._squelchSlotChange = true;
1806
+ this.insertBefore(slide, ref);
1807
+ this._emitDeckChange({ action: 'move', from: i, to: j, slide });
1808
+ this._collectSlides();
1809
+ this._applyIndex({ showOverlay: false, broadcast: true, reason: 'mutation' });
1810
+ }
1811
+
1812
+ // Public API ------------------------------------------------------------
1813
+
1814
+ /** Current slide index (0-based). */
1815
+ get index() { return this._index; }
1816
+ /** Total slide count. */
1817
+ get length() { return this._slides.length; }
1818
+ /** Programmatically navigate. */
1819
+ goTo(i) { this._go(i, 'api'); }
1820
+ next() { this._advance(1, 'api'); }
1821
+ prev() { this._advance(-1, 'api'); }
1822
+ reset() { this._go(0, 'api'); }
115
1823
  }
116
1824
 
117
- customElements.define('deck-stage', DeckStage);
1825
+ if (!customElements.get('deck-stage')) {
1826
+ customElements.define('deck-stage', DeckStage);
1827
+ }
118
1828
  })();