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

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.
@@ -4,25 +4,29 @@
4
4
  *
5
5
  * Handles:
6
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.
7
+ * (a JSON array of per-slide note strings), shows the current slide's
8
+ * note in an editable bottom dock (edit mode only; drag its grip to
9
+ * resize, height persisted to localStorage), posts miaoda:deck:slide-changed
10
+ * to the parent on nav, and posts miaoda:deck:notes-changed (the full
11
+ * array) when a note is edited so the host can persist it. The dock is
12
+ * hidden while presenting / in preview / on narrow / noscale / no-rail.
8
13
  * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
9
14
  * On touch devices, tapping the left/right half of the stage goes
10
15
  * prev/next — taps on links, buttons and other interactive slide
11
16
  * 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)
17
+ * (c) press R to reset to slide 0.
18
+ * (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
15
19
  * scaled with `transform: scale()` to fit the viewport, letterboxed.
16
20
  * Set the `noscale` attribute to render at authored size (1:1) — the
17
21
  * 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
22
+ * (e) print — `@media print` lays every slide out as its own page at the
19
23
  * design size, so the browser's Print → Save as PDF produces a clean
20
24
  * one-page-per-slide PDF with no extra setup.
21
- * (g) thumbnail rail — resizable left-hand column of per-slide thumbnails
25
+ * (f) thumbnail rail — resizable left-hand column of per-slide thumbnails
22
26
  * (static clones). Click to navigate; ↑/↓ with a thumbnail focused to
23
27
  * 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
28
+ * Skip / Move up / Move down / Delete. Drag the rail's right edge
29
+ * to resize; width persists to
26
30
  * localStorage. Skipped slides carry `data-deck-skip`, are dimmed in
27
31
  * the rail, omitted from prev/next navigation, and hidden at print.
28
32
  * The rail is suppressed in presenting mode, in the host's Preview
@@ -83,9 +87,15 @@
83
87
  * - Do NOT set position/inset/width/height on the slide <section> elements —
84
88
  * the component absolutely positions every slotted child for you.
85
89
  * - 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} }`.
90
+ * animate *from* hidden. The thumbnail rail, print, and reduced-motion
91
+ * render each slide's static base state and never fire the animation, so
92
+ * a hidden base (opacity:0 on the rule itself) renders blank there while
93
+ * the live slide looks fine. Keep the hidden state only in the keyframes
94
+ * `from`, and gate the animation on [data-deck-active] and the motion
95
+ * query, e.g.:
96
+ * .fade-up { opacity: 1 }
97
+ * @media (prefers-reduced-motion:no-preference){ [data-deck-active] .fade-up{ animation: fadeUp .5s both } }
98
+ * @keyframes fadeUp { from{ opacity:0; transform:translateY(24px) } to{ opacity:1; transform:none } }
89
99
  * Avoid infinite decorative loops on slide content.
90
100
  */
91
101
  /* END USAGE */
@@ -94,7 +104,12 @@
94
104
  if (typeof window === 'undefined') return;
95
105
  const DESIGN_W_DEFAULT = 1920;
96
106
  const DESIGN_H_DEFAULT = 1080;
97
- const OVERLAY_HIDE_MS = 1800;
107
+ // Safe margin around the scaled slide in edit/preview (non-presenting) mode,
108
+ // giving the canvas a floating "card" look with a drop-shadow.
109
+ const DECK_MARGIN = 20;
110
+ // TEMP: presenter-notes dock is hidden for now. Flip back to false to restore
111
+ // the editable speaker-notes panel (all notes logic below stays intact).
112
+ const NOTES_DISABLED = true;
98
113
  const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
99
114
  const FINE_POINTER_MQ = matchMedia('(hover: hover) and (pointer: fine)');
100
115
  const NARROW_MQ = matchMedia('(max-width: 640px)');
@@ -123,8 +138,7 @@
123
138
  position: fixed;
124
139
  inset: 0;
125
140
  display: block;
126
- background: #fff;
127
- color: #fff;
141
+ background: #F8F9FA;
128
142
  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
129
143
  overflow: hidden;
130
144
  -webkit-tap-highlight-color: transparent;
@@ -136,7 +150,8 @@
136
150
  * Only the stage/rail hide — the black :host background stays, so the
137
151
  * iframe doesn't flash the page's default white. */
138
152
  :host([data-fonts-pending]) .stage,
139
- :host([data-fonts-pending]) .rail { opacity: 0; pointer-events: none; }
153
+ :host([data-fonts-pending]) .rail,
154
+ :host([data-fonts-pending]) .notes { opacity: 0; pointer-events: none; }
140
155
 
141
156
  .stage {
142
157
  position: absolute;
@@ -144,8 +159,9 @@
144
159
  display: flex;
145
160
  align-items: center;
146
161
  justify-content: center;
147
- background: #F8F9FA;
162
+ background: transparent;
148
163
  }
164
+ :host(:not([data-windowed])) .stage { background: #000; }
149
165
 
150
166
  .canvas {
151
167
  position: relative;
@@ -154,6 +170,13 @@
154
170
  background: #fff;
155
171
  will-change: transform;
156
172
  }
173
+ /* Floating card drop-shadow — only when the stage is windowed (20px
174
+ margin), i.e. edit/preview. Full-bleed presenting / noscale / _snthumb
175
+ carry no shadow (_fit toggles data-windowed). */
176
+ :host([data-windowed]) .canvas {
177
+ border: 0.882px solid #DEE0E3;
178
+ box-shadow: 0 0 7.052px 0 rgba(31, 35, 41, 0.06), 0 0 15.867px 0 rgba(31, 35, 41, 0.04), 0 0 22.919px 7.052px rgba(31, 35, 41, 0.04);
179
+ }
157
180
 
158
181
  /* Slides live in light DOM (via <slot>) so authored CSS still applies.
159
182
  We absolutely position each slotted child to stack them. */
@@ -174,104 +197,6 @@
174
197
  visibility: visible;
175
198
  }
176
199
 
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
200
  /* ── Thumbnail rail ──────────────────────────────────────────────────
276
201
  Fixed column on the left; each thumbnail is a static deep-clone of
277
202
  the light-DOM slide scaled into a 16:9 (or design-aspect) frame. The
@@ -287,11 +212,12 @@
287
212
  border-right: 0.5px solid #DEE0E3;
288
213
  overflow-y: auto;
289
214
  overflow-x: hidden;
290
- padding: 16px 10px;
215
+ transform: translate3d(0, 0, 0);
216
+ padding: 20px 15px 20px 10px;
291
217
  box-sizing: border-box;
292
218
  display: flex;
293
219
  flex-direction: column;
294
- gap: 12px;
220
+ gap: 18px;
295
221
  z-index: 2147482500;
296
222
  scrollbar-width: thin;
297
223
  scrollbar-color: rgba(0,0,0,0.18) transparent;
@@ -319,36 +245,30 @@
319
245
  popping. Transitions are gated on :host([data-rail-anim]) — set only
320
246
  for the 200ms around the toggle — so window-resize and rail-width
321
247
  drag (which also call _fit) don't lag behind the cursor. */
322
- .rail[data-user-hidden] { transform: translateX(-100%); }
248
+ .rail[data-user-hidden] { transform: translate3d(-100%, 0, 0); }
323
249
  :host([data-rail-anim]) .rail { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
324
250
  :host([data-rail-anim]) .stage { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
325
251
  :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
- }
252
+ :host([data-rail-anim]) .notes { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
335
253
 
336
254
  .thumb {
337
255
  position: relative;
338
256
  display: flex;
339
257
  align-items: flex-start;
340
- gap: 8px;
258
+ gap: 9px;
341
259
  cursor: pointer;
342
260
  user-select: none;
343
261
  }
262
+ .thumb[data-dragging] { cursor: grabbing; }
344
263
  .thumb .num {
345
- width: 16px;
264
+ width: 14px;
346
265
  flex-shrink: 0;
347
266
  font-size: 12px;
267
+ line-height: 1;
348
268
  font-weight: 400;
349
- text-align: right;
269
+ text-align: center;
350
270
  color: #646A73;
351
- padding-top: 2px;
271
+ padding-top: 8px;
352
272
  font-variant-numeric: tabular-nums;
353
273
  }
354
274
  .thumb .frame {
@@ -357,18 +277,32 @@
357
277
  min-width: 0;
358
278
  aspect-ratio: var(--deck-aspect);
359
279
  background: #fff;
360
- border-radius: 4px;
280
+ border-radius: 6px;
281
+ /* Default hairline as a box-shadow, not a border: the frame is the
282
+ absolutely-positioned origin for the cloned slide, so a real border
283
+ would shift the clone; box-shadow doesn't affect layout, so
284
+ _scaleThumbs' frame.offsetWidth read stays correct. */
285
+ box-shadow: 0 0 0 1px #DEE0E3;
361
286
  outline: 2px solid transparent;
362
- outline-offset: 0;
287
+ outline-offset: 2px;
363
288
  overflow: hidden;
364
- transition: outline-color 120ms ease;
289
+ transition: outline-color 120ms ease, box-shadow 120ms ease;
365
290
  }
366
- .thumb:hover .frame { outline-color: rgba(0,0,0,0.15); }
291
+ .thumb:hover .frame { outline-color: #1f23290d; }
367
292
  .thumb { outline: none; }
368
- .thumb:focus-visible .frame { outline-color: #336DF4; }
369
- .thumb[data-current] .num { color: #1F2329; }
293
+ .thumb:focus-visible .frame { outline-color: #1f23290d; }
294
+ .thumb[data-current] .num { color: #1F2329; font-weight: 500; }
370
295
  .thumb[data-current] .frame { outline-color: #1F2329; }
371
- .thumb[data-dragging] { opacity: 0.35; }
296
+ /* Elevated "picked up" look on the *stationary* source while dragging
297
+ (outline hidden, card lifted); the ::before drop-line marks the landing
298
+ position. The floating ghost under the cursor is a separate custom
299
+ drag-image built in _makeDragGhost — the browser's native snapshot of
300
+ .thumb clipped this frame's outside-offset ring and carried the number
301
+ gutter, so we no longer let it draw the ghost. */
302
+ .thumb[data-dragging] .frame {
303
+ outline-color: transparent;
304
+ box-shadow: 0 8px 24px rgba(31,35,41,0.22), 0 0 0 1px rgba(31,35,41,0.08);
305
+ }
372
306
  .thumb::before {
373
307
  content: '';
374
308
  position: absolute;
@@ -382,32 +316,30 @@
382
316
  }
383
317
  .thumb[data-drop="before"]::before { top: -8px; opacity: 1; }
384
318
  .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: '已跳过';
319
+ .thumb .skip-icon {
320
+ display: none;
388
321
  position: absolute;
389
322
  inset: 0;
390
- display: flex;
391
323
  align-items: center;
392
324
  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;
325
+ background: rgba(255,255,255,0.6);
326
+ z-index: 1;
398
327
  }
328
+ .thumb[data-skip] .skip-icon { display: flex; }
399
329
 
400
330
  .ctxmenu {
401
331
  position: fixed;
402
- min-width: 150px;
332
+ min-width: 160px;
403
333
  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);
334
+ background: #fff;
335
+ color: #1F2329;
336
+ border: 0.5px solid #D2D5D8;
337
+ border-radius: 6px;
338
+ box-shadow: 0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04);
408
339
  z-index: 2147483100;
409
340
  display: none;
410
- font-size: 12px;
341
+ font-size: 14px;
342
+ line-height: 22px;
411
343
  }
412
344
  .ctxmenu[data-open] { display: block; }
413
345
  .ctxmenu button {
@@ -416,19 +348,19 @@
416
348
  appearance: none;
417
349
  border: 0;
418
350
  background: transparent;
419
- color: #e8e8e8;
351
+ color: #1F2329;
420
352
  font: inherit;
421
353
  text-align: left;
422
- padding: 6px 10px;
354
+ padding: 6px 8px;
423
355
  border-radius: 4px;
424
356
  cursor: pointer;
425
357
  }
426
- .ctxmenu button:hover:not(:disabled) { background: rgba(255,255,255,0.08); }
358
+ .ctxmenu button:hover:not(:disabled) { background: #F2F3F5; }
427
359
  .ctxmenu button:disabled { opacity: 0.35; cursor: default; }
428
360
  .ctxmenu hr {
429
361
  border: 0;
430
- border-top: 1px solid rgba(255,255,255,0.1);
431
- margin: 4px 2px;
362
+ border-top: 0.5px solid #D2D5D8;
363
+ margin: 4px -4px;
432
364
  }
433
365
 
434
366
  .rail-resize {
@@ -448,67 +380,65 @@
448
380
  .rail[data-presenting] + .rail-resize,
449
381
  .rail[data-user-hidden] + .rail-resize { display: none; }
450
382
 
451
- /* Delete-confirm popup — matches the SPA's ConfirmDialog layout
452
- (title + message body, depressed footer with Cancel / Delete). */
453
- .confirm-backdrop {
383
+ /* ── Speaker-notes dock ──────────────────────────────────────────────
384
+ Bottom panel spanning the non-rail width in edit mode: a resize grip
385
+ (drag to set height, persisted to localStorage) + an editable textarea
386
+ bound to the current slide's note. Hidden while presenting / preview /
387
+ narrow / noscale / no-rail / print — same editing-chrome gating as the
388
+ rail. Its left edge is set to the live rail width by _fit. */
389
+ .notes {
454
390
  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);
391
+ left: var(--deck-rail-w, 192px);
392
+ right: 20px;
393
+ bottom: 0;
394
+ height: var(--deck-notes-h, 160px);
395
+ background: #fff;
396
+ border: 0.5px solid #D2D5D8;
397
+ border-bottom: none;
398
+ border-radius: 12px 12px 0 0;
399
+ box-shadow: 0 2px 4px -4px rgba(31, 35, 41, 0.02), 0 4px 8px 0 rgba(31, 35, 41, 0.02), 0 4px 16px 4px rgba(31, 35, 41, 0.03);
400
+ box-sizing: border-box;
486
401
  display: flex;
487
- justify-content: flex-end;
488
- gap: 8px;
402
+ flex-direction: column;
403
+ z-index: 2147482400;
489
404
  }
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;
405
+ :host([no-rail]) .notes,
406
+ :host([noscale]) .notes { display: none; }
407
+ .notes[data-hidden] { display: none; }
408
+ @media (max-width: 640px) { .notes { display: none; } }
409
+ .notes-handle {
410
+ flex-shrink: 0;
411
+ height: 20px;
412
+ display: flex;
413
+ align-items: center;
414
+ justify-content: center;
415
+ cursor: row-resize;
416
+ color: #8F959E;
417
+ font-size: 18px;
418
+ line-height: 20px;
419
+ letter-spacing: 2px;
420
+ user-select: none;
421
+ touch-action: none;
498
422
  }
499
- .confirm .cancel {
500
- background: transparent;
423
+ .notes-handle:hover,
424
+ .notes-handle[data-dragging] { color: #646A73; }
425
+ .notes-body {
426
+ flex: 1;
427
+ min-height: 0;
428
+ width: 100%;
429
+ box-sizing: border-box;
501
430
  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);
431
+ outline: none;
432
+ resize: none;
433
+ background: transparent;
434
+ color: #1F2329;
435
+ font: inherit;
436
+ font-size: 14px;
437
+ line-height: 1.6;
438
+ padding: 4px 24px 12px;
439
+ overflow-y: auto;
510
440
  }
511
- .confirm .danger:hover { background: #b5563a; }
441
+ .notes-body::placeholder { color: #8F959E; }
512
442
 
513
443
  /* ── Print: one page per slide, no chrome ────────────────────────────
514
444
  The screen layout stacks every slide at inset:0 inside a scaled
@@ -532,6 +462,9 @@
532
462
  height: auto !important;
533
463
  background: none;
534
464
  will-change: auto;
465
+ /* !important: :host([data-windowed]) .canvas (0,2,0) outranks a bare
466
+ .canvas rule inside this media query. */
467
+ box-shadow: none !important;
535
468
  }
536
469
  ::slotted(*) {
537
470
  position: relative !important;
@@ -557,7 +490,7 @@
557
490
  page-break-after: auto;
558
491
  }
559
492
  ::slotted([data-deck-skip]) { display: none !important; }
560
- .overlay, .rail, .rail-resize, .ctxmenu, .confirm-backdrop { display: none !important; }
493
+ .rail, .rail-resize, .ctxmenu, .notes { display: none !important; }
561
494
  }
562
495
  `;
563
496
 
@@ -570,16 +503,15 @@
570
503
  this._index = 0;
571
504
  this._slides = [];
572
505
  this._notes = [];
573
- this._hideTimer = null;
574
- this._mouseIdleTimer = null;
575
506
  this._menuIndex = -1;
507
+ this._notesPersistTimer = null;
576
508
 
577
509
  this._onKey = this._onKey.bind(this);
578
510
  this._onResize = this._onResize.bind(this);
579
511
  this._onSlotChange = this._onSlotChange.bind(this);
580
- this._onMouseMove = this._onMouseMove.bind(this);
581
512
  this._onTap = this._onTap.bind(this);
582
513
  this._onMessage = this._onMessage.bind(this);
514
+ this._onHashChange = this._onHashChange.bind(this);
583
515
  // Capture-phase close so a click anywhere dismisses the menu, but
584
516
  // ignore clicks that land inside the menu itself — otherwise the
585
517
  // capture handler runs before the menu's own (bubble) handler and
@@ -598,17 +530,22 @@
598
530
  }
599
531
 
600
532
  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', '');
533
+ // Capture modes that render a clean full-bleed single slide, both routed
534
+ // through the same no-rail path: the host's pre-screenshot ?thumbnail=1
535
+ // (product thumbnail — slide 1) and the presenter-view popup's
536
+ // ?_snthumb=...#N (prev/cur/next thumbnails). Both drop the rail (wrong
537
+ // scale, offsets the stage into a gutter) and render full-bleed — no
538
+ // windowed margin/shadow, see _fit. A bare load with no #N hash starts at
539
+ // slide 1, so ?thumbnail=1 alone yields a clean first-slide frame.
540
+ this._snthumb = /[?&](_snthumb|thumbnail)=/.test(location.search);
541
+ if (this._snthumb) this.setAttribute('no-rail', '');
605
542
  this._render();
606
543
  this._loadNotes();
607
544
  this._syncPrintPageRule();
608
545
  window.addEventListener('keydown', this._onKey);
609
546
  window.addEventListener('resize', this._onResize);
610
- window.addEventListener('mousemove', this._onMouseMove, { passive: true });
611
547
  window.addEventListener('message', this._onMessage);
548
+ window.addEventListener('hashchange', this._onHashChange);
612
549
  window.addEventListener('click', this._onDocClick, true);
613
550
  this.addEventListener('click', this._onTap);
614
551
  // Print lays every slide out as its own page, so [data-deck-active]-
@@ -625,7 +562,7 @@
625
562
  this._slides.forEach((s) => s.setAttribute('data-deck-active', ''));
626
563
  };
627
564
  this._onAfterPrint = () => {
628
- this._applyIndex({ showOverlay: false, broadcast: false });
565
+ this._applyIndex({ broadcast: false });
629
566
  if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
630
567
  };
631
568
  window.addEventListener('beforeprint', this._onBeforePrint);
@@ -648,6 +585,8 @@
648
585
  new Promise((r) => setTimeout(r, 2000)),
649
586
  ]).then(reveal, reveal);
650
587
  });
588
+ // Announce to the host that this product IS a deck scene.
589
+ try { window.parent.postMessage({ type: 'miaoda:deck:available' }, '*'); } catch (e) {}
651
590
  }
652
591
 
653
592
  _enableRail() {
@@ -724,26 +663,86 @@
724
663
  this._syncRailHidden();
725
664
  this._renderRail();
726
665
  this._fit();
666
+ // The notes dock is editing chrome too — show it and load the current
667
+ // slide's note now that the rail feature is enabled.
668
+ this._syncNotesHidden();
669
+ this._syncNotesBody();
727
670
  }
728
671
 
729
672
  /** Snapshot document stylesheets into a constructable sheet that each
730
673
  * thumbnail's nested shadow root adopts — so author CSS styles the
731
674
  * 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. */
675
+ * Re-callable: the constructable sheet is reused via replaceSync so
676
+ * every already-adopted shadow root picks up the fresh CSS.
677
+ *
678
+ * Cross-origin sheets (a CDN styles.css) throw SecurityError on
679
+ * .cssRules — the rules style the light DOM but CSSOM won't expose them,
680
+ * so the snapshot comes back empty and thumbnail clones render unstyled.
681
+ * Re-fetch those sheets' source and re-apply when it lands; the shared
682
+ * adopted sheet means one replaceSync restyles every clone (no re-clone).
683
+ * The fetch is CORS-gated — a CDN without Access-Control-Allow-Origin
684
+ * fails it and we degrade to an unstyled clone, no worse than before. */
735
685
  _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) => {
686
+ // Generation guard: a stale fetch (from a snapshot a newer call has
687
+ // superseded) must not clobber the current adopted CSS.
688
+ const gen = (this._cssGen = (this._cssGen || 0) + 1);
689
+ if (!this._xoCssCache) this._xoCssCache = new Map(); // href → fetched text ('' = failed)
690
+ const pending = [];
691
+ this._applyAuthorCss(this._collectAuthorCss(pending));
692
+ if (!pending.length) return;
693
+ Promise.all(pending.map((href) =>
694
+ fetch(href).then((r) => (r.ok ? r.text() : '')).catch(() => '')
695
+ .then((text) => { this._xoCssCache.set(href, text); })
696
+ )).then(() => {
697
+ if (this._cssGen !== gen) return;
698
+ this._applyAuthorCss(this._collectAuthorCss(null));
699
+ if (this._adoptedSheet) {
700
+ // Shared sheet already restyled every clone — just re-mirror vars.
701
+ const cs = getComputedStyle(this);
702
+ (this._thumbs || []).forEach((t) => { if (t.host) this._syncThumbHostAttrs(t.host, cs); });
703
+ } else {
704
+ // Fallback (no constructable sheets): each clone baked its own
705
+ // <style> at materialize, so re-clone to pick up the fetched CSS.
706
+ (this._thumbs || []).forEach((t) => { if (t.host) this._refreshThumb(t.slide); });
707
+ }
708
+ });
709
+ }
710
+
711
+ /** Concatenate every document stylesheet's rules in document order. A
712
+ * cross-origin sheet whose .cssRules throws is unreadable via CSSOM:
713
+ * substitute its re-fetched source if cached, else push its href to
714
+ * pendingOut (when provided) and leave a placeholder until the fetch lands. */
715
+ _collectAuthorCss(pendingOut) {
716
+ return Array.from(document.styleSheets).map((sh) => {
743
717
  try {
744
718
  return Array.from(sh.cssRules).map((r) => r.cssText).join('\n');
745
- } catch (e) { return ''; }
746
- }).join('\n')
719
+ } catch (e) {
720
+ const href = sh.href;
721
+ if (!href) return '';
722
+ if (this._xoCssCache.has(href)) return this._xoCssCache.get(href);
723
+ if (pendingOut) pendingOut.push(href);
724
+ return '';
725
+ }
726
+ }).join('\n');
727
+ }
728
+
729
+ /** Rewrite raw author CSS so its document-scoped selectors match inside a
730
+ * thumbnail's shadow root, then (re)load it into the shared adopted sheet.
731
+ * :root in an adopted sheet inside a shadow root matches nothing (only the
732
+ * document root qualifies), so author rules like
733
+ * `:root[data-voice="modern"] .serif` never reach the clones. Rewrite
734
+ * :root → :host and mirror <html>'s data-*, class and lang onto each thumb
735
+ * host (see _syncThumbHostAttrs) so the same selectors match inside it.
736
+ * The <deck-stage> type selector gets the same treatment: from a clone's
737
+ * point of view inside an isolated thumb shadow root there is no real
738
+ * <deck-stage> ancestor, so a rule like `deck-stage section { background }`
739
+ * (the common per-slide default-background idiom) would never match and
740
+ * the clone renders with a transparent background — through which the
741
+ * page-level html/body background (rewritten to :host above) leaks. The
742
+ * thumb's own host IS the deck-stage from the clone's perspective, so
743
+ * alias deck-stage → :host to restore the match. */
744
+ _applyAuthorCss(rawCss) {
745
+ const authorCss = rawCss
747
746
  // The shadow host is featureless outside the functional :host(...)
748
747
  // form, so any compound on :root — [attr], .class, #id, :pseudo —
749
748
  // must become :host(<compound>) not :host<compound>. Same for the
@@ -753,7 +752,12 @@
753
752
  .replace(/:root((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)/g, ':host($1)')
754
753
  .replace(/:root\b/g, ':host')
755
754
  .replace(/(^|[\s,>~+(}])html((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
756
- .replace(/(^|[\s,>~+(}])html(?![-\w])/g, '$1:host');
755
+ .replace(/(^|[\s,>~+(}])html(?![-\w])/g, '$1:host')
756
+ // Same rewrite for the deck-stage type selector (see the doc comment).
757
+ // The negative-lookahead (?![-\w]) keeps `.deck-stage-title` and the
758
+ // `--deck-stage-*` custom-property names untouched.
759
+ .replace(/(^|[\s,>~+(}])deck-stage((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
760
+ .replace(/(^|[\s,>~+(}])deck-stage(?![-\w])/g, '$1:host');
757
761
  // Every custom property the author references. _syncThumbHostAttrs
758
762
  // mirrors each one's *computed* value at <deck-stage> onto the
759
763
  // thumb host so the live value wins over the :host default above
@@ -814,18 +818,18 @@
814
818
  disconnectedCallback() {
815
819
  window.removeEventListener('keydown', this._onKey);
816
820
  window.removeEventListener('resize', this._onResize);
817
- window.removeEventListener('mousemove', this._onMouseMove);
818
821
  window.removeEventListener('message', this._onMessage);
822
+ window.removeEventListener('hashchange', this._onHashChange);
819
823
  window.removeEventListener('click', this._onDocClick, true);
820
824
  window.removeEventListener('beforeprint', this._onBeforePrint);
821
825
  window.removeEventListener('afterprint', this._onAfterPrint);
822
826
  if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
827
+ if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
823
828
  this.removeEventListener('click', this._onTap);
824
- if (this._hideTimer) clearTimeout(this._hideTimer);
825
- if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
826
829
  if (this._liveTimer) clearTimeout(this._liveTimer);
827
830
  if (this._tweakTimer) clearTimeout(this._tweakTimer);
828
831
  if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
832
+ if (this._notesPersistTimer) clearTimeout(this._notesPersistTimer);
829
833
  if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
830
834
  if (this._liveObserver) this._liveObserver.disconnect();
831
835
  if (this._railObserver) this._railObserver.disconnect();
@@ -866,28 +870,6 @@
866
870
  canvas.appendChild(slot);
867
871
  stage.appendChild(canvas);
868
872
 
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
873
  // Thumbnail rail + context menu. Thumbnails are populated in
892
874
  // _renderRail() after _collectSlides().
893
875
  const rail = document.createElement('div');
@@ -900,23 +882,51 @@
900
882
  // (ramped by edge proximity) is enough — no rAF loop needed.
901
883
  rail.addEventListener('dragover', (e) => {
902
884
  if (this._dragFrom == null) return;
885
+ // preventDefault makes the whole rail — including the gaps between
886
+ // thumbs, where the drop-line actually renders — a valid drop target.
887
+ // Without it a release in a gap is rejected and the slide snaps back
888
+ // even though the indicator was showing.
889
+ e.preventDefault();
890
+ e.dataTransfer.dropEffect = 'move';
903
891
  const r = rail.getBoundingClientRect();
904
892
  const EDGE = 40;
905
893
  const dt = e.clientY - r.top;
906
894
  const db = r.bottom - e.clientY;
907
895
  if (dt < EDGE) rail.scrollTop -= Math.ceil((EDGE - dt) / 3);
908
896
  else if (db < EDGE) rail.scrollTop += Math.ceil((EDGE - db) / 3);
897
+ // Over a thumb its own dragover set the indicator (O(1)); over a gap
898
+ // no thumb handler fires, so resolve the nearest slot here.
899
+ const overThumb = e.target && e.target.closest && e.target.closest('.thumb');
900
+ if (!overThumb) {
901
+ const tgt = this._railDropTarget(e.clientY);
902
+ if (tgt) this._setDrop(tgt.i, tgt.pos);
903
+ }
904
+ });
905
+ rail.addEventListener('drop', (e) => {
906
+ // A drop over a thumb is handled by the thumb's own handler, which
907
+ // nulls _dragFrom before the event bubbles here — so this branch only
908
+ // runs for releases that land in a gap / the rail padding. It commits
909
+ // to whatever slot the indicator is currently showing.
910
+ if (this._dragFrom == null) return;
911
+ e.preventDefault();
912
+ const on = this._dropOn;
913
+ const from = this._dragFrom;
914
+ this._dragFrom = null;
915
+ let to = on ? (this._dropWhere === 'after' ? on.i + 1 : on.i) : from;
916
+ if (from < to) to--;
917
+ this._clearDrop();
918
+ if (on && to !== from) this._moveSlide(from, to);
909
919
  });
910
920
 
911
921
  const menu = document.createElement('div');
912
922
  menu.className = 'ctxmenu export-hidden';
913
923
  menu.setAttribute('data-miaoda-chrome', '');
914
924
  menu.innerHTML = `
915
- <button type="button" data-act="skip">跳过此页</button>
925
+ <button type="button" data-act="skip">隐藏此页</button>
916
926
  <button type="button" data-act="up">上移</button>
917
927
  <button type="button" data-act="down">下移</button>
918
928
  <hr>
919
- <button type="button" data-act="delete">删除此页</button>
929
+ <button type="button" data-act="delete">删除</button>
920
930
  `;
921
931
  menu.addEventListener('click', (e) => {
922
932
  const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
@@ -926,7 +936,7 @@
926
936
  if (act === 'skip') this._toggleSkip(i);
927
937
  else if (act === 'up') this._moveSlide(i, i - 1);
928
938
  else if (act === 'down') this._moveSlide(i, i + 1);
929
- else if (act === 'delete') this._openConfirm(i);
939
+ else if (act === 'delete') this._deleteSlide(i);
930
940
  });
931
941
  menu.addEventListener('contextmenu', (e) => e.preventDefault());
932
942
 
@@ -952,43 +962,64 @@
952
962
  resize.addEventListener('pointercancel', up);
953
963
  });
954
964
 
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();
965
+ // Speaker-notes dock — a resize grip + an editable textarea bound to the
966
+ // current slide's note. Chrome (export-hidden / data-miaoda-chrome) like
967
+ // the rail/overlay so it's excluded from PPTX/screenshot capture.
968
+ const notes = document.createElement('div');
969
+ notes.className = 'notes export-hidden';
970
+ notes.setAttribute('data-miaoda-chrome', '');
971
+ const notesHandle = document.createElement('div');
972
+ notesHandle.className = 'notes-handle';
973
+ notesHandle.innerHTML = '<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.3008 11.4434C13.3008 10.822 13.8045 10.3184 14.4258 10.3184C15.047 10.3185 15.5508 10.8221 15.5508 11.4434C15.5508 12.0646 15.047 12.5682 14.4258 12.5684C13.8045 12.5684 13.3008 12.0647 13.3008 11.4434ZM13.3008 6.55664C13.3008 5.93532 13.8045 5.43164 14.4258 5.43164C15.047 5.43179 15.5508 5.93541 15.5508 6.55664C15.5508 7.17787 15.047 7.68149 14.4258 7.68164C13.8045 7.68164 13.3008 7.17796 13.3008 6.55664ZM7.875 11.4434C7.875 10.822 8.37868 10.3184 9 10.3184C9.62132 10.3184 10.125 10.822 10.125 11.4434C10.125 12.0647 9.62132 12.5684 9 12.5684C8.37868 12.5684 7.875 12.0647 7.875 11.4434ZM7.875 6.55664C7.875 5.93532 8.37868 5.43164 9 5.43164C9.62132 5.43164 10.125 5.93532 10.125 6.55664C10.125 7.17796 9.62132 7.68164 9 7.68164C8.37868 7.68164 7.875 7.17796 7.875 6.55664ZM2.44922 11.4434C2.44922 10.822 2.9529 10.3184 3.57422 10.3184C4.19554 10.3184 4.69922 10.822 4.69922 11.4434C4.69922 12.0647 4.19554 12.5684 3.57422 12.5684C2.9529 12.5684 2.44922 12.0647 2.44922 11.4434ZM2.44922 6.55664C2.44922 5.93532 2.9529 5.43164 3.57422 5.43164C4.19554 5.43164 4.69922 5.93532 4.69922 6.55664C4.69922 7.17796 4.19554 7.68164 3.57422 7.68164C2.9529 7.68164 2.44922 7.17796 2.44922 6.55664Z" fill="currentColor"/></svg>';
974
+ notesHandle.setAttribute('title', '拖动调整高度');
975
+ const notesBody = document.createElement('textarea');
976
+ notesBody.className = 'notes-body';
977
+ notesBody.setAttribute('placeholder', '点击添加演示者备注');
978
+ notesBody.setAttribute('aria-label', '演示者备注');
979
+ notesBody.setAttribute('spellcheck', 'false');
980
+ notes.append(notesHandle, notesBody);
981
+
982
+ // Drag the grip to resize the dock height (mirror the rail-resize
983
+ // pattern); persist to localStorage so it survives reloads.
984
+ notesHandle.addEventListener('pointerdown', (e) => {
985
+ e.preventDefault();
986
+ notesHandle.setPointerCapture(e.pointerId);
987
+ notesHandle.setAttribute('data-dragging', '');
988
+ const move = (ev) => this._setNotesHeight(window.innerHeight - ev.clientY);
989
+ const up = () => {
990
+ notesHandle.removeEventListener('pointermove', move);
991
+ notesHandle.removeEventListener('pointerup', up);
992
+ notesHandle.removeEventListener('pointercancel', up);
993
+ notesHandle.removeAttribute('data-dragging');
994
+ try { localStorage.setItem('deck-stage.notesHeight', String(this._notesPx)); } catch (err) {}
995
+ };
996
+ notesHandle.addEventListener('pointermove', move);
997
+ notesHandle.addEventListener('pointerup', up);
998
+ notesHandle.addEventListener('pointercancel', up);
973
999
  });
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);
1000
+
1001
+ // Edit → write the current slide's note + debounced persist.
1002
+ notesBody.addEventListener('input', () => {
1003
+ if (!Array.isArray(this._notes)) this._notes = [];
1004
+ this._notes[this._index] = notesBody.value;
1005
+ this._scheduleNotesPersist();
979
1006
  });
1007
+ // At window scope the keydown target is retargeted to the host
1008
+ // <deck-stage> (not this textarea), so _onKey's INPUT/TEXTAREA guard
1009
+ // never matches — stop propagation here so typing (space/arrows/…) edits
1010
+ // the note instead of navigating slides (same trick as the thumb keydown).
1011
+ notesBody.addEventListener('keydown', (e) => e.stopPropagation());
980
1012
 
981
- this._root.append(style, rail, resize, stage, overlay, menu, confirm);
1013
+ this._root.append(style, rail, resize, stage, notes, menu);
982
1014
  this._canvas = canvas;
983
1015
  this._stage = stage;
984
1016
  this._slot = slot;
985
- this._overlay = overlay;
986
1017
  this._rail = rail;
987
1018
  this._resize = resize;
988
1019
  this._menu = menu;
989
- this._confirm = confirm;
990
- this._countEl = overlay.querySelector('.current');
991
- this._totalEl = overlay.querySelector('.total');
1020
+ this._notesPanel = notes;
1021
+ this._notesHandle = notesHandle;
1022
+ this._notesBody = notesBody;
992
1023
 
993
1024
  // Restore persisted rail width.
994
1025
  let rw = 192;
@@ -998,6 +1029,15 @@
998
1029
  } catch (err) {}
999
1030
  this._setRailWidth(rw);
1000
1031
  this._syncRailHidden();
1032
+
1033
+ // Restore persisted notes-dock height.
1034
+ let nh = 160;
1035
+ try {
1036
+ const s = localStorage.getItem('deck-stage.notesHeight');
1037
+ if (s) nh = parseInt(s, 10) || nh;
1038
+ } catch (err) {}
1039
+ this._setNotesHeight(nh);
1040
+ this._syncNotesHidden();
1001
1041
  }
1002
1042
 
1003
1043
  _setRailWidth(px) {
@@ -1016,6 +1056,74 @@
1016
1056
  }
1017
1057
  }
1018
1058
 
1059
+ // ── Speaker-notes dock ────────────────────────────────────────────────
1060
+
1061
+ _setNotesHeight(px) {
1062
+ const max = Math.max(120, Math.round(window.innerHeight * 0.5));
1063
+ const h = Math.max(58, Math.min(max, Math.round(px)));
1064
+ this._notesPx = h;
1065
+ this.style.setProperty('--deck-notes-h', h + 'px');
1066
+ this._fit();
1067
+ }
1068
+
1069
+ _notesVisible() {
1070
+ if (NOTES_DISABLED) return false;
1071
+ return !!this._hasNotesTag && !!this._railEnabled
1072
+ && !this._presenting && !this._previewMode
1073
+ && !this.hasAttribute('noscale') && !this.hasAttribute('no-rail')
1074
+ && !NARROW_MQ.matches;
1075
+ }
1076
+
1077
+ /** Height _fit subtracts from the stage region. Clamped so a tall dock on
1078
+ * a short window still leaves ~160px of canvas (no negative/NaN scale). */
1079
+ _notesHeight() {
1080
+ if (!this._notesVisible()) return 0;
1081
+ return Math.min(this._notesPx || 0, Math.max(0, window.innerHeight - 160));
1082
+ }
1083
+
1084
+ _syncNotesHidden() {
1085
+ if (!this._notesPanel) return;
1086
+ // Static conditions (no-rail/noscale/narrow/print) live in CSS; toggle
1087
+ // only the runtime-dynamic hide (presenting / preview / rail disabled).
1088
+ const hide = NOTES_DISABLED || !this._hasNotesTag || !this._railEnabled || this._presenting || this._previewMode;
1089
+ this._notesPanel.toggleAttribute('data-hidden', hide);
1090
+ this._notesPanel.inert = !this._notesVisible(); // keep textarea out of tab order when hidden
1091
+ }
1092
+
1093
+ /** Load the current slide's note into the body (no-op if unchanged so it
1094
+ * doesn't disturb an in-progress edit). */
1095
+ _syncNotesBody() {
1096
+ if (!this._notesBody) return;
1097
+ const val = (Array.isArray(this._notes) && this._notes[this._index]) || '';
1098
+ if (this._notesBody.value !== val) this._notesBody.value = val;
1099
+ }
1100
+
1101
+ /** this._notes with holes → '' and length preserved (safe to JSON). */
1102
+ _notesForEmit() {
1103
+ const src = Array.isArray(this._notes) ? this._notes : [];
1104
+ return Array.from(src, (v) => (typeof v === 'string' ? v : (v == null ? '' : String(v))));
1105
+ }
1106
+
1107
+ _scheduleNotesPersist() {
1108
+ clearTimeout(this._notesPersistTimer);
1109
+ this._notesPersistTimer = setTimeout(() => this._emitNotesChange(), 300);
1110
+ }
1111
+
1112
+ /** Persist a pure text edit: mirror the in-DOM #speaker-notes tag (if any)
1113
+ * for in-iframe consumers, and post the full array to the parent so the
1114
+ * host writes it to disk — the tag lives OUTSIDE <deck-stage>, so the
1115
+ * deck-changed outerHTML path can't carry notes. */
1116
+ _emitNotesChange() {
1117
+ const notes = this._notesForEmit();
1118
+ this._notes = notes;
1119
+ const tag = document.getElementById('speaker-notes');
1120
+ if (tag) tag.textContent = JSON.stringify(notes);
1121
+ this.dispatchEvent(new CustomEvent('noteschange', {
1122
+ detail: { notes, index: this._index }, bubbles: true, composed: true,
1123
+ }));
1124
+ try { window.parent.postMessage({ type: 'miaoda:deck:notes-changed', index: this._index, notes }, '*'); } catch (e) {}
1125
+ }
1126
+
1019
1127
  /** @page must live in the document stylesheet — it's a no-op inside
1020
1128
  * shadow DOM. Inject/update a single <head> style tag so the print
1021
1129
  * sheet matches the design size and Save-as-PDF yields one slide per
@@ -1047,7 +1155,7 @@
1047
1155
  if (this._squelchSlotChange) { this._squelchSlotChange = false; return; }
1048
1156
  this._collectSlides();
1049
1157
  this._restoreIndex();
1050
- this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
1158
+ this._applyIndex({ broadcast: true, reason: 'init' });
1051
1159
  this._fit();
1052
1160
  }
1053
1161
 
@@ -1072,7 +1180,6 @@
1072
1180
  slide.setAttribute('data-deck-slide', String(i));
1073
1181
  });
1074
1182
 
1075
- if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
1076
1183
  if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
1077
1184
  this._markLastVisible();
1078
1185
  this._renderRail();
@@ -1092,6 +1199,7 @@
1092
1199
 
1093
1200
  _loadNotes() {
1094
1201
  const tag = document.getElementById('speaker-notes');
1202
+ this._hasNotesTag = !!tag;
1095
1203
  if (!tag) { this._notes = []; return; }
1096
1204
  try {
1097
1205
  const parsed = JSON.parse(tag.textContent || '[]');
@@ -1102,18 +1210,38 @@
1102
1210
  }
1103
1211
  }
1104
1212
 
1213
+ /** Parse a `#<int>` fragment (1-indexed) into a 0-based slide index, or
1214
+ * -1 when the hash is absent/malformed or points outside the deck.
1215
+ * Shared by _restoreIndex (load time) and _onHashChange (runtime). */
1216
+ _slideFromHash() {
1217
+ const h = (location.hash || '').match(/^#(\d+)$/);
1218
+ if (!h) return -1;
1219
+ const n = parseInt(h[1], 10) - 1;
1220
+ return n >= 0 && n < this._slides.length ? n : -1;
1221
+ }
1222
+
1105
1223
  _restoreIndex() {
1106
1224
  // The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
1107
1225
  // the iframe src. No hash → slide 1; the deck itself keeps no position
1108
1226
  // 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
- }
1227
+ const n = this._slideFromHash();
1228
+ if (n >= 0) this._index = n;
1114
1229
  }
1115
1230
 
1116
- _applyIndex({ showOverlay = true, broadcast = true, reason = 'init' } = {}) {
1231
+ _onHashChange() {
1232
+ // Runtime counterpart to _restoreIndex, which only reads the fragment
1233
+ // once (at connectedCallback). A same-document fragment change — a deep
1234
+ // link edited in the address bar, or an automation/screenshot tool that
1235
+ // navigates to #N without a full reload — fires hashchange but never
1236
+ // re-runs connectedCallback, so honor it here too. The deck's own
1237
+ // navigation writes the fragment via history.replaceState, which does
1238
+ // NOT fire hashchange, so this can't feed back into a loop; the index
1239
+ // guard also makes a redundant fragment a no-op.
1240
+ const n = this._slideFromHash();
1241
+ if (n >= 0 && n !== this._index) this._go(n, 'api');
1242
+ }
1243
+
1244
+ _applyIndex({ broadcast = true, reason = 'init' } = {}) {
1117
1245
  if (!this._slides.length) return;
1118
1246
  const prev = this._prevIndex == null ? -1 : this._prevIndex;
1119
1247
  const curr = this._index;
@@ -1125,13 +1253,13 @@
1125
1253
  if (i === curr) s.setAttribute('data-deck-active', '');
1126
1254
  else s.removeAttribute('data-deck-active');
1127
1255
  });
1128
- if (this._countEl) this._countEl.textContent = String(curr + 1);
1129
1256
  // Follow-scroll on every navigation (init deep-link, keyboard, click,
1130
1257
  // tap, external goTo) — the only time we *don't* want the rail to
1131
1258
  // track current is after a rail-internal mutation, where _renderRail
1132
1259
  // has already restored the user's scroll position and yanking back to
1133
1260
  // current would undo it.
1134
1261
  this._syncRail(reason !== 'mutation');
1262
+ this._syncNotesBody();
1135
1263
 
1136
1264
  if (broadcast) {
1137
1265
  // (1) Parent-window postMessage for speaker-notes renderers.
@@ -1158,19 +1286,6 @@
1158
1286
  }
1159
1287
 
1160
1288
  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
1289
  }
1175
1290
 
1176
1291
  _railWidth() {
@@ -1192,18 +1307,30 @@
1192
1307
  // resetTransformSelector can't reach .canvas.style.transform directly.
1193
1308
  if (this.hasAttribute('noscale')) {
1194
1309
  this._canvas.style.transform = 'none';
1195
- if (stage) stage.style.left = '0';
1196
- if (this._overlay) this._overlay.style.marginLeft = '0';
1310
+ this.removeAttribute('data-windowed');
1311
+ if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1197
1312
  return;
1198
1313
  }
1199
1314
  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;
1315
+ const nh = this._notesHeight();
1316
+ // Windowed (20px margin + card shadow) in edit/preview; full-bleed while
1317
+ // presenting or inside presenter-thumbnail iframes (_snthumb).
1318
+ const windowed = !this._presenting && !this._snthumb;
1319
+ const m = windowed ? DECK_MARGIN : 0;
1320
+ this.toggleAttribute('data-windowed', windowed);
1321
+ // Explicit four-side inset shrinks the flex box to the safe region; the
1322
+ // design-size canvas centres in it, then scale() shrinks it around the
1323
+ // centre — so it stays inside the inset with equal margins on all sides.
1324
+ if (stage) {
1325
+ stage.style.left = (rw + m) + 'px';
1326
+ stage.style.right = m + 'px';
1327
+ stage.style.top = m + 'px';
1328
+ stage.style.bottom = (m + nh) + 'px';
1329
+ }
1330
+ // The notes dock spans the non-rail width; its left edge tracks the rail.
1331
+ if (this._notesPanel) this._notesPanel.style.left = (rw + DECK_MARGIN) + 'px';
1332
+ const vw = window.innerWidth - rw - 2 * m;
1333
+ const vh = window.innerHeight - nh - 2 * m;
1207
1334
  const s = Math.min(vw / this.designWidth, vh / this.designHeight);
1208
1335
  this._canvas.style.transform = `scale(${s})`;
1209
1336
  }
@@ -1220,22 +1347,13 @@
1220
1347
  }
1221
1348
  }
1222
1349
 
1223
- _onMouseMove() {
1224
- // Keep overlay visible while mouse moves; hide after idle.
1225
- this._flashOverlay();
1226
- }
1227
-
1228
1350
  _onMessage(e) {
1229
1351
  const d = e.data;
1230
1352
  if (d && d.type === 'miaoda:deck:presenting' && typeof d.on === 'boolean') {
1231
1353
  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
1354
  this._syncRailHidden();
1355
+ this._syncNotesHidden();
1237
1356
  this._closeMenu();
1238
- this._closeConfirm();
1239
1357
  this._fit();
1240
1358
  this._scaleThumbs();
1241
1359
  }
@@ -1248,8 +1366,8 @@
1248
1366
  if (d.on === this._previewMode) return;
1249
1367
  this._previewMode = d.on;
1250
1368
  this._syncRailHidden();
1369
+ this._syncNotesHidden();
1251
1370
  this._closeMenu();
1252
- this._closeConfirm();
1253
1371
  this._fit();
1254
1372
  this._scaleThumbs();
1255
1373
  }
@@ -1295,8 +1413,11 @@
1295
1413
  }
1296
1414
 
1297
1415
  _onTap(e) {
1298
- // Touch-only — keyboard + the overlay toolbar cover nav on desktop.
1299
- if (FINE_POINTER_MQ.matches) return;
1416
+ // Touch-only for normal browse/edit — on desktop there, keyboard + the
1417
+ // overlay toolbar cover nav. But in presenting mode the overlay is hidden
1418
+ // and click-to-advance is the expected gesture (Keynote/PPT), so allow it
1419
+ // on fine pointers too.
1420
+ if (FINE_POINTER_MQ.matches && !this._presenting) return;
1300
1421
  // Only taps that land on the stage (slide content or letterbox); the
1301
1422
  // overlay / rail / menus are siblings with their own click handlers.
1302
1423
  const path = e.composedPath();
@@ -1320,13 +1441,6 @@
1320
1441
  // Ignore when the user is typing.
1321
1442
  const t = e.target;
1322
1443
  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
1444
  if (e.key === 'Escape' && this._menu && this._menu.hasAttribute('data-open')) {
1331
1445
  this._closeMenu();
1332
1446
  e.preventDefault();
@@ -1337,9 +1451,9 @@
1337
1451
  const key = e.key;
1338
1452
  let handled = true;
1339
1453
 
1340
- if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
1454
+ if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
1341
1455
  this._advance(1, 'keyboard');
1342
- } else if (key === 'ArrowLeft' || key === 'PageUp') {
1456
+ } else if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'PageUp') {
1343
1457
  this._advance(-1, 'keyboard');
1344
1458
  } else if (key === 'Home') {
1345
1459
  this._go(0, 'keyboard');
@@ -1357,31 +1471,26 @@
1357
1471
 
1358
1472
  if (handled) {
1359
1473
  e.preventDefault();
1360
- this._flashOverlay();
1361
1474
  }
1362
1475
  }
1363
1476
 
1364
1477
  _go(i, reason = 'api') {
1365
1478
  if (!this._slides.length) return;
1366
1479
  const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
1367
- if (clamped === this._index) {
1368
- this._flashOverlay();
1369
- return;
1370
- }
1480
+ if (clamped === this._index) return;
1371
1481
  this._index = clamped;
1372
- this._applyIndex({ showOverlay: true, broadcast: true, reason });
1482
+ this._applyIndex({ broadcast: true, reason });
1373
1483
  }
1374
1484
 
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. */
1485
+ /** Step forward/back skipping any slide marked data-deck-skip. Clamps at
1486
+ * the ends (no-op past the first/last non-skipped slide). */
1378
1487
  _advance(dir, reason) {
1379
1488
  if (!this._slides.length) return;
1380
1489
  let i = this._index + dir;
1381
1490
  while (i >= 0 && i < this._slides.length && this._slides[i].hasAttribute('data-deck-skip')) {
1382
1491
  i += dir;
1383
1492
  }
1384
- if (i < 0 || i >= this._slides.length) { this._flashOverlay(); return; }
1493
+ if (i < 0 || i >= this._slides.length) return;
1385
1494
  this._go(i, reason);
1386
1495
  }
1387
1496
 
@@ -1476,6 +1585,10 @@
1476
1585
  num.className = 'num';
1477
1586
  const frame = document.createElement('div');
1478
1587
  frame.className = 'frame';
1588
+ const skipIcon = document.createElement('div');
1589
+ skipIcon.className = 'skip-icon';
1590
+ skipIcon.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.8447 1.82978C2.16723 1.50736 2.68943 1.51112 3.0119 1.8336L18.3385 17.1606C18.6638 17.4859 18.6637 18.0136 18.3384 18.3389C18.0131 18.6641 17.4855 18.6643 17.1602 18.339L1.83317 3.01234C1.51069 2.68986 1.50692 2.16767 1.82935 1.84514C1.83447 1.84002 1.83958 1.8349 1.8447 1.82978ZM3.33085 6.27753L6.68668 9.63337C6.63272 10.1199 6.68658 10.6123 6.84443 11.0757C7.00228 11.5391 7.26025 11.962 7.60001 12.3144C7.93976 12.6668 8.35296 12.94 8.81024 13.1147C9.26753 13.2894 9.75767 13.3612 10.2458 13.325L10.3667 13.3134L13.3529 16.2996C12.2833 16.8225 11.1604 17.0838 9.98626 17.0838C6.65746 17.0838 3.75395 14.9833 1.27539 10.7819C0.991215 10.3003 0.987158 9.70269 1.26629 9.21806C1.91897 8.08486 2.60738 7.10484 3.33085 6.27753ZM13.3333 10.0005C13.3334 9.54197 13.2389 9.08841 13.0556 8.66813C12.8724 8.24784 12.6045 7.86987 12.2686 7.55784C11.9327 7.24581 11.536 7.00644 11.1033 6.85468C10.6707 6.70293 10.2114 6.64205 9.75418 6.67587L9.63293 6.68712L6.6246 3.67878C7.67308 3.17422 8.82227 2.91381 9.98585 2.91712C13.3783 2.91712 16.2919 5.01573 18.7276 9.21295C19.0101 9.6999 19.0068 10.3018 18.7193 10.7858C18.0552 11.9035 17.3618 12.8724 16.6388 13.693L13.3133 10.3675C13.3267 10.2467 13.3333 10.1246 13.3333 10.0005Z" fill="#646A73"/></svg>';
1591
+ frame.append(skipIcon);
1479
1592
  thumb.append(num, frame);
1480
1593
 
1481
1594
  const entry = { thumb, num, frame, slide, clone: null, host: null, i: -1 };
@@ -1508,11 +1621,23 @@
1508
1621
  thumb.setAttribute('data-dragging', '');
1509
1622
  e.dataTransfer.effectAllowed = 'move';
1510
1623
  try { e.dataTransfer.setData('text/plain', String(this._dragFrom)); } catch (err) {}
1624
+ // Hand the browser a clean elevated card instead of its raw .thumb
1625
+ // bitmap; anchor it under the cursor at the grab point (no jump).
1626
+ try {
1627
+ const g = this._makeDragGhost(entry);
1628
+ if (g) {
1629
+ const r = frame.getBoundingClientRect();
1630
+ const gx = g.pad + Math.max(0, Math.min(g.w, e.clientX - r.left));
1631
+ const gy = g.pad + Math.max(0, Math.min(g.h, e.clientY - r.top));
1632
+ e.dataTransfer.setDragImage(g.el, gx, gy);
1633
+ }
1634
+ } catch (err) {}
1511
1635
  });
1512
1636
  thumb.addEventListener('dragend', () => {
1513
1637
  thumb.removeAttribute('data-dragging');
1514
1638
  this._clearDrop();
1515
1639
  this._dragFrom = null;
1640
+ if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
1516
1641
  });
1517
1642
  thumb.addEventListener('dragover', (e) => {
1518
1643
  if (this._dragFrom == null) return;
@@ -1539,6 +1664,51 @@
1539
1664
  return entry;
1540
1665
  }
1541
1666
 
1667
+ /** Build the floating drag-image for a rail thumb.
1668
+ * The browser's default drag-image is a raw bitmap of the whole .thumb:
1669
+ * it includes the number gutter and clips the current-slide's dark ring
1670
+ * (which sits 2px outside the frame via outline-offset) at the bitmap
1671
+ * edge. Instead we hand setDragImage a purpose-built card — just the
1672
+ * slide frame, rounded, with a soft drop-shadow, transparent around it.
1673
+ * The card lives inside a transparent padded wrapper because the bitmap
1674
+ * crops to the element's border-box, so the shadow needs room inside the
1675
+ * box or it clips too. Returns { el, pad, w, h } for the anchor math, or
1676
+ * null if the frame has no size yet. */
1677
+ _makeDragGhost(entry) {
1678
+ if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
1679
+ this._materialize(entry);
1680
+ const w = entry.frame.offsetWidth, h = entry.frame.offsetHeight;
1681
+ if (!w || !h) return null;
1682
+ const pad = 36; // ≥ the shadow's blur + offset so it stays in the bitmap
1683
+ const wrap = document.createElement('div');
1684
+ wrap.style.cssText =
1685
+ 'position:fixed;top:0;left:-100000px;pointer-events:none;' +
1686
+ 'background:transparent;box-sizing:content-box;padding:' + pad + 'px;';
1687
+ const card = document.createElement('div');
1688
+ // Drop-shadow only — no hairline ring (the 0 0 0 1px shadow reads as a
1689
+ // border, which we don't want on the floating card).
1690
+ card.style.cssText =
1691
+ 'position:relative;width:' + w + 'px;height:' + h + 'px;background:#fff;' +
1692
+ 'border-radius:6px;overflow:hidden;' +
1693
+ 'box-shadow:0 6px 10px 0 rgba(31, 35, 41, 0.20);cursor:grabbing;';
1694
+ const host = document.createElement('div');
1695
+ host.style.cssText = 'position:absolute;inset:0;';
1696
+ this._syncThumbHostAttrs(host);
1697
+ const sr = host.attachShadow({ mode: 'open' });
1698
+ if (this._adoptedSheet) sr.adoptedStyleSheets = [this._adoptedSheet];
1699
+ else {
1700
+ const st = document.createElement('style');
1701
+ st.textContent = this._authorCss || '';
1702
+ sr.appendChild(st);
1703
+ }
1704
+ if (entry.clone) sr.appendChild(entry.clone.cloneNode(true));
1705
+ card.appendChild(host);
1706
+ wrap.appendChild(card);
1707
+ (this.shadowRoot || document.body).appendChild(wrap);
1708
+ this._dragGhost = wrap;
1709
+ return { el: wrap, pad, w, h };
1710
+ }
1711
+
1542
1712
  /** Lazily build the clone for a thumb that has scrolled into view. */
1543
1713
  _materialize(entry) {
1544
1714
  if (entry.host) return;
@@ -1670,6 +1840,25 @@
1670
1840
  });
1671
1841
  }
1672
1842
 
1843
+ /** Nearest reorder slot for a Y coordinate anywhere in the rail — used when
1844
+ * the pointer is over a gap, where no thumb dragover fires. Returns
1845
+ * { i, pos } matching _setDrop's contract, or null. Mirrors the thumb
1846
+ * handler's midpoint test so the gap indicator lines up with the thumbs. */
1847
+ _railDropTarget(clientY) {
1848
+ const ts = this._thumbs;
1849
+ if (!ts || !ts.length) return null;
1850
+ for (let k = 0; k < ts.length; k++) {
1851
+ const r = ts[k].thumb.getBoundingClientRect();
1852
+ if (clientY < r.top + r.height / 2) return { i: ts[k].i, pos: 'before' };
1853
+ const next = ts[k + 1];
1854
+ if (!next) return { i: ts[k].i, pos: 'after' };
1855
+ const nr = next.thumb.getBoundingClientRect();
1856
+ if (clientY < nr.top + nr.height / 2) return { i: ts[k].i, pos: 'after' };
1857
+ }
1858
+ const last = ts[ts.length - 1];
1859
+ return { i: last.i, pos: 'after' };
1860
+ }
1861
+
1673
1862
  _setDrop(i, where) {
1674
1863
  // dragover fires at pointer-event rate; touch only the previous
1675
1864
  // and new target rather than sweeping all N thumbs.
@@ -1679,11 +1868,15 @@
1679
1868
  }
1680
1869
  if (t) t.thumb.setAttribute('data-drop', where);
1681
1870
  this._dropOn = t || null;
1871
+ // Remembered so a drop that lands in a gap (rail handler) can commit to
1872
+ // the slot the indicator is showing without re-deriving before/after.
1873
+ this._dropWhere = t ? where : null;
1682
1874
  }
1683
1875
 
1684
1876
  _clearDrop() {
1685
1877
  if (this._dropOn) this._dropOn.thumb.removeAttribute('data-drop');
1686
1878
  this._dropOn = null;
1879
+ this._dropWhere = null;
1687
1880
  }
1688
1881
 
1689
1882
  _syncRail(follow) {
@@ -1705,7 +1898,7 @@
1705
1898
  this._menuIndex = i;
1706
1899
  const slide = this._slides[i];
1707
1900
  const skip = slide && slide.hasAttribute('data-deck-skip');
1708
- this._menu.querySelector('[data-act="skip"]').textContent = skip ? '取消跳过' : '跳过此页';
1901
+ this._menu.querySelector('[data-act="skip"]').textContent = skip ? '取消隐藏' : '隐藏此页';
1709
1902
  this._menu.querySelector('[data-act="up"]').disabled = i <= 0;
1710
1903
  this._menu.querySelector('[data-act="down"]').disabled = i >= this._slides.length - 1;
1711
1904
  this._menu.querySelector('[data-act="delete"]').disabled = this._slides.length <= 1;
@@ -1725,21 +1918,12 @@
1725
1918
  this._menuIndex = -1;
1726
1919
  }
1727
1920
 
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
1921
  _emitDeckChange(detail) {
1922
+ // A structural edit carries the (possibly reindexed) notes array so the
1923
+ // host writes deck-stage + speaker-notes in ONE file write. Cancel any
1924
+ // pending text-edit notes-changed so the two never race two writes at
1925
+ // the same file (etag conflict would drop the second).
1926
+ clearTimeout(this._notesPersistTimer);
1743
1927
  this.dispatchEvent(new CustomEvent('deckchange', {
1744
1928
  detail, bubbles: true, composed: true,
1745
1929
  }));
@@ -1751,6 +1935,7 @@
1751
1935
  type: 'miaoda:deck:deck-changed',
1752
1936
  action: detail.action, from: detail.from, to: detail.to,
1753
1937
  html: this.outerHTML,
1938
+ notes: this._notesForEmit(),
1754
1939
  }, '*');
1755
1940
  } catch (e) {}
1756
1941
  }
@@ -1762,9 +1947,11 @@
1762
1947
  if (i < this._index || (wasCurrent && i === this._slides.length - 1)) this._index--;
1763
1948
  this._squelchSlotChange = true;
1764
1949
  slide.remove();
1950
+ // Keep the per-slide-indexed notes array aligned with the deck.
1951
+ if (Array.isArray(this._notes)) this._notes.splice(i, 1);
1765
1952
  this._emitDeckChange({ action: 'delete', from: i, slide });
1766
1953
  this._collectSlides();
1767
- this._applyIndex({ showOverlay: true, broadcast: true, reason: 'mutation' });
1954
+ this._applyIndex({ broadcast: true, reason: 'mutation' });
1768
1955
  }
1769
1956
 
1770
1957
  _toggleSkip(i) {
@@ -1804,9 +1991,14 @@
1804
1991
  else if (i > cur && j <= cur) this._index = cur + 1;
1805
1992
  this._squelchSlotChange = true;
1806
1993
  this.insertBefore(slide, ref);
1994
+ // Move the note entry in lockstep so notes stay aligned to slides.
1995
+ if (Array.isArray(this._notes)) {
1996
+ const [n] = this._notes.splice(i, 1);
1997
+ this._notes.splice(j, 0, n === undefined ? '' : n);
1998
+ }
1807
1999
  this._emitDeckChange({ action: 'move', from: i, to: j, slide });
1808
2000
  this._collectSlides();
1809
- this._applyIndex({ showOverlay: false, broadcast: true, reason: 'mutation' });
2001
+ this._applyIndex({ broadcast: true, reason: 'mutation' });
1810
2002
  }
1811
2003
 
1812
2004
  // Public API ------------------------------------------------------------