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

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.
@@ -20,9 +20,82 @@
20
20
  // `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
21
21
  // for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
22
22
  // Build YOUR scenes by composing Sprites inside a Stage.
23
+ //
24
+ // window.__miaodaMirror (defined below) is Magic-Share sync plumbing between
25
+ // this component and the miaoda host — internal; do not call or remove it.
23
26
  /* END USAGE */
24
27
  // ─────────────────────────────────────────────────────────────────────────────
25
28
 
29
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
30
+ // Shared by every buildless script on the page (first one to load defines
31
+ // it). Inlined per script rather than shipped as its own registry file:
32
+ // call sites use the global bare, so inlining is what guarantees
33
+ // definition-before-use — a separate copy-in file would add a whole "SDK
34
+ // copied but <script> tag missing" failure mode. Keep this block identical
35
+ // across deck-stage.js / design-canvas.jsx / animations.jsx — same
36
+ // convention as the mobile-UA regex. Two halves of
37
+ // the 妙享 (Magic Share) mirror protocol:
38
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
39
+ // message as a broadcastable UI-state snapshot;
40
+ // unstamped messages are never mirrored. opts.key is
41
+ // the sync channel (maps to the share SDK's
42
+ // actionType): late joiners receive only the LATEST
43
+ // snapshot per key, so states that overwrite each
44
+ // other must share one key. opts.throttle is the
45
+ // suggested host-side coalescing interval in ms —
46
+ // snapshots make dropped intermediate frames
47
+ // lossless, so hint it on any continuous gesture.
48
+ // onApply(type, fn) — replay entry for a message type this script itself
49
+ // emits. The host sends {type:'miaoda:mirror:apply',
50
+ // uuid, message, latencyMs} and needs
51
+ // {type:'miaoda:mirror:apply-result', uuid, result}
52
+ // back to drive the SDK's retry: result.type
53
+ // 'failed' means retry (use it for transient
54
+ // not-ready states), 'ignored' is terminal (not
55
+ // applicable / bad payload), 'applied' is success.
56
+ // Unregistered types stay silent on purpose —
57
+ // another script on the page may own them, and the
58
+ // host's timeout is the backstop.
59
+ if (!window.__miaodaMirror) {
60
+ const appliers = new Map(); // message.type -> Set<fn>
61
+ window.__miaodaMirror = {
62
+ emit(msg, opts) {
63
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
64
+ },
65
+ onApply(type, fn) {
66
+ if (!appliers.has(type)) appliers.set(type, new Set());
67
+ appliers.get(type).add(fn);
68
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
69
+ },
70
+ };
71
+ window.addEventListener('message', async (e) => {
72
+ // Replays are accepted from the host frame only — generated pages embed
73
+ // arbitrary third-party iframes, any of which could otherwise forge an
74
+ // apply and drive state (plus its persisted side effects).
75
+ if (e.source !== window.parent) return;
76
+ const d = e.data;
77
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
78
+ const fns = appliers.get(d.message.type);
79
+ if (!fns || !fns.size) return;
80
+ // Merge across handlers (multi-instance case): one success is enough.
81
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
82
+ // slotted second deck returning 'failed') must not poison an applied
83
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
84
+ // nobody applied. Every handler still runs (no short-circuit) so each
85
+ // sibling instance gets the state.
86
+ let result = { type: 'ignored', reason: 'no applier result' };
87
+ for (const fn of Array.from(fns)) {
88
+ let r;
89
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
90
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
91
+ if (!r) continue;
92
+ if (r.type === 'applied') result = r;
93
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
94
+ }
95
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
96
+ });
97
+ }
98
+
26
99
  // ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
27
100
  // All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
28
101
  const Easing = {
@@ -346,6 +419,13 @@ function Stage({
346
419
  // ~1s as a fallback — paused, with the playback bar hidden, so the product
347
420
  // thumbnail is a deterministic frame of the first composed scene.
348
421
  const captureMode = typeof location !== 'undefined' && /[?&]thumbnail=/.test(location.search || '');
422
+ // Mirror silence additionally covers _snthumb frames (deck's presenter
423
+ // popup pins LIVE product iframes per slide — a stage embedded in such a
424
+ // slide is on the presenter's side, so the role gate can't drop its
425
+ // stamped emits). Kept separate from captureMode on purpose: the poster
426
+ // freeze stays keyed to ?thumbnail= only, so _snthumb thumbnails still
427
+ // look like the live slide.
428
+ const captureFrame = captureMode || (typeof location !== 'undefined' && /[?&]_snthumb=/.test(location.search || ''));
349
429
 
350
430
  const [time, setTime] = React.useState(() => {
351
431
  if (captureMode) return clamp(poster == null ? 1 : poster, 0, duration);
@@ -362,7 +442,11 @@ function Stage({
362
442
  const rafRef = React.useRef(null);
363
443
  const lastTsRef = React.useRef(null);
364
444
 
365
- // Persist playhead
445
+ // Persist playhead. A Magic-Share-replayed time persists too, on purpose:
446
+ // unlike the rail/viewport preferences this IS the stage's current
447
+ // position, and while playing the next local rAF tick would re-persist it
448
+ // anyway (a guard would only reach the paused case, whose landing spot is
449
+ // equally this stage's real position).
366
450
  React.useEffect(() => {
367
451
  try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
368
452
  }, [time, persistKey]);
@@ -402,7 +486,51 @@ function Stage({
402
486
  // The playback bar is a *sibling* of this node, so it never lands in a capture
403
487
  // and needs no hiding.
404
488
  const bridgeStateRef = React.useRef(null);
405
- bridgeStateRef.current = { time, duration, fps, width, height, setTime, setPlaying };
489
+ bridgeStateRef.current = { time, duration, fps, width, height, playing, loop, setTime, setPlaying };
490
+
491
+ // Magic Share: playhead sync fires only from explicit interactions (playbar,
492
+ // keyboard) plus the coarse keepalive below — never from the rAF tick, which
493
+ // would broadcast at frame rate for nothing (each message is already a full
494
+ // snapshot). Scrub-drags still call this per mousemove, so the actual post
495
+ // is trailing-throttled locally (same rule as design-canvas's viewport:
496
+ // no per-event cross-frame posts). Capture iframes are frozen stills and
497
+ // the host export drives the bridge programmatically, so captureMode
498
+ // stays silent. ts rides along for the HOST, which derives from it the
499
+ // latencyMs it hands back to replays.
500
+ const emitTRef = React.useRef(0);
501
+ React.useEffect(() => () => clearTimeout(emitTRef.current), []);
502
+ const emitPlayhead = () => {
503
+ if (captureFrame || emitTRef.current) return;
504
+ emitTRef.current = setTimeout(() => {
505
+ emitTRef.current = 0;
506
+ // Read at fire time, not call time — by 80ms the interaction's state
507
+ // has committed and bridgeStateRef is fresh (same live-ref pattern as
508
+ // design-canvas's tf trailing timer).
509
+ const cur = bridgeStateRef.current;
510
+ window.__miaodaMirror.emit(
511
+ { type: 'miaoda:anim:playhead-changed', t: cur.time, playing: cur.playing, ts: Date.now() },
512
+ { key: 'anim:playhead', throttle: 60 },
513
+ );
514
+ }, 80);
515
+ };
516
+
517
+ // Coarse drift correction: followers free-run their own rAF between
518
+ // messages, so while playing re-anchor them every few seconds. 3s is
519
+ // coarse enough to be free and tight enough that free-run drift stays
520
+ // imperceptible. Quiesced while replays are flowing in: a replayed
521
+ // playing:true arms this effect on followers too, and their competing
522
+ // snapshots are otherwise only stopped by the host's presenter role gate —
523
+ // don't lean on a single layer across presenter-handoff windows.
524
+ const lastApplyTsRef = React.useRef(0);
525
+ React.useEffect(() => {
526
+ if (!playing || captureFrame) return;
527
+ const iv = setInterval(() => {
528
+ if (Date.now() - lastApplyTsRef.current < 3000) return;
529
+ emitPlayhead();
530
+ }, 3000);
531
+ return () => clearInterval(iv);
532
+ }, [playing]);
533
+
406
534
  React.useEffect(() => {
407
535
  const s = bridgeStateRef;
408
536
  const bridge = {
@@ -420,7 +548,42 @@ function Stage({
420
548
  // Announce to the host that this product IS an animation scene, so it can
421
549
  // reveal the export entry (mirrors deck-stage's miaoda:deck:available).
422
550
  try { window.parent.postMessage({ type: 'miaoda:anim:available' }, '*'); } catch (e) {}
423
- return () => { if (window.__animStage === bridge) delete window.__animStage; };
551
+ // Magic Share replay — a thin adapter over the same setTime/setPlaying the
552
+ // export bridge exposes. The setters themselves never emit, but
553
+ // setPlaying(true) does arm the keepalive effect, whose re-emit — like
554
+ // every follower emit — is dropped host-side by the presenter role gate.
555
+ const offApply = window.__miaodaMirror.onApply('miaoda:anim:playhead-changed', (m, ctx) => {
556
+ // Capture iframes are frozen stills — a forwarded replay's playing:true
557
+ // would unfreeze the poster mid-screenshot. The emit side is already
558
+ // silent under captureMode; keep the apply side symmetric.
559
+ if (captureFrame) return { type: 'ignored', reason: 'capture iframe' };
560
+ // isFinite, not typeof: NaN would sail through clamp() (Math.min/max
561
+ // propagate it) into setTime and persisted state.
562
+ if (!Number.isFinite(m.t)) return { type: 'ignored', reason: 'no t' };
563
+ // Kill any pending local-emit timer — it reads state at fire time and
564
+ // would re-broadcast the just-replayed values as this end's own (same
565
+ // rule as design-canvas's viewport applier).
566
+ clearTimeout(emitTRef.current);
567
+ emitTRef.current = 0;
568
+ const st = s.current;
569
+ // While playing, advance the replayed time by the transport delay so
570
+ // both playheads aim at the same wall-clock frame. A looping stage
571
+ // wraps rather than clamps — a snapshot sent near the seam would
572
+ // otherwise pin the follower on the last frame until its own rAF
573
+ // wraps.
574
+ const lat = m.playing && ctx && Number.isFinite(ctx.latencyMs) ? ctx.latencyMs / 1000 : 0;
575
+ const nt = m.t + lat;
576
+ // Double-mod wrap: JS % keeps the sign, and a (forged/skewed) negative
577
+ // t would otherwise render a negative playhead.
578
+ st.setTime(m.playing && st.loop && st.duration > 0 ? ((nt % st.duration) + st.duration) % st.duration : clamp(nt, 0, st.duration));
579
+ st.setPlaying(!!m.playing);
580
+ lastApplyTsRef.current = Date.now();
581
+ return { type: 'applied' };
582
+ });
583
+ return () => {
584
+ offApply();
585
+ if (window.__animStage === bridge) delete window.__animStage;
586
+ };
424
587
  }, []);
425
588
 
426
589
  // Animation loop
@@ -457,12 +620,16 @@ function Stage({
457
620
  if (e.code === 'Space') {
458
621
  e.preventDefault();
459
622
  setPlaying(p => !p);
623
+ emitPlayhead();
460
624
  } else if (e.code === 'ArrowLeft') {
461
625
  setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
626
+ emitPlayhead();
462
627
  } else if (e.code === 'ArrowRight') {
463
628
  setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
629
+ emitPlayhead();
464
630
  } else if (e.key === '0' || e.code === 'Home') {
465
631
  setTime(0);
632
+ emitPlayhead();
466
633
  }
467
634
  };
468
635
  window.addEventListener('keydown', onKey);
@@ -519,9 +686,9 @@ function Stage({
519
686
  time={time}
520
687
  duration={duration}
521
688
  playing={playing}
522
- onPlayPause={() => setPlaying(p => !p)}
523
- onReset={() => { setTime(0); }}
524
- onSeek={(t) => setTime(t)}
689
+ onPlayPause={() => { setPlaying(p => !p); emitPlayhead(); }}
690
+ onReset={() => { setTime(0); emitPlayhead(); }}
691
+ onSeek={(t) => { setTime(t); emitPlayhead(); }}
525
692
  />
526
693
  )}
527
694
  </div>
@@ -32,6 +32,9 @@
32
32
  // On mobile devices the canvas automatically becomes a browse-only surface
33
33
  // (touch pan/zoom, editing disabled). This needs NO authoring changes:
34
34
  // never add custom touch handling or mobile-specific layouts.
35
+ //
36
+ // window.__miaodaMirror (defined below) is Magic-Share sync plumbing between
37
+ // this component and the miaoda host — internal; do not call or remove it.
35
38
  /* END USAGE */
36
39
 
37
40
  const DC = {
@@ -54,6 +57,82 @@ const dcIsMobile = typeof navigator !== 'undefined' &&
54
57
  (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
55
58
  (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
56
59
 
60
+ // Capture iframes (?thumbnail= product screenshots) sit out Magic Share in
61
+ // BOTH directions — same rule as deck-stage's _snthumb and animations'
62
+ // captureMode: their emits would carry a throwaway instance's state, and a
63
+ // (mis)forwarded replay could reframe the canvas mid-shot.
64
+ const dcCaptureMode = typeof location !== 'undefined' && /[?&](_snthumb|thumbnail)=/.test(location.search || '');
65
+
66
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
67
+ // Shared by every buildless script on the page (first one to load defines
68
+ // it). Inlined per script rather than shipped as its own registry file:
69
+ // call sites use the global bare, so inlining is what guarantees
70
+ // definition-before-use — a separate copy-in file would add a whole "SDK
71
+ // copied but <script> tag missing" failure mode. Keep this block identical
72
+ // across deck-stage.js / design-canvas.jsx / animations.jsx — same
73
+ // convention as the mobile-UA regex. Two halves of
74
+ // the 妙享 (Magic Share) mirror protocol:
75
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
76
+ // message as a broadcastable UI-state snapshot;
77
+ // unstamped messages are never mirrored. opts.key is
78
+ // the sync channel (maps to the share SDK's
79
+ // actionType): late joiners receive only the LATEST
80
+ // snapshot per key, so states that overwrite each
81
+ // other must share one key. opts.throttle is the
82
+ // suggested host-side coalescing interval in ms —
83
+ // snapshots make dropped intermediate frames
84
+ // lossless, so hint it on any continuous gesture.
85
+ // onApply(type, fn) — replay entry for a message type this script itself
86
+ // emits. The host sends {type:'miaoda:mirror:apply',
87
+ // uuid, message, latencyMs} and needs
88
+ // {type:'miaoda:mirror:apply-result', uuid, result}
89
+ // back to drive the SDK's retry: result.type
90
+ // 'failed' means retry (use it for transient
91
+ // not-ready states), 'ignored' is terminal (not
92
+ // applicable / bad payload), 'applied' is success.
93
+ // Unregistered types stay silent on purpose —
94
+ // another script on the page may own them, and the
95
+ // host's timeout is the backstop.
96
+ if (!window.__miaodaMirror) {
97
+ const appliers = new Map(); // message.type -> Set<fn>
98
+ window.__miaodaMirror = {
99
+ emit(msg, opts) {
100
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
101
+ },
102
+ onApply(type, fn) {
103
+ if (!appliers.has(type)) appliers.set(type, new Set());
104
+ appliers.get(type).add(fn);
105
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
106
+ },
107
+ };
108
+ window.addEventListener('message', async (e) => {
109
+ // Replays are accepted from the host frame only — generated pages embed
110
+ // arbitrary third-party iframes, any of which could otherwise forge an
111
+ // apply and drive state (plus its persisted side effects).
112
+ if (e.source !== window.parent) return;
113
+ const d = e.data;
114
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
115
+ const fns = appliers.get(d.message.type);
116
+ if (!fns || !fns.size) return;
117
+ // Merge across handlers (multi-instance case): one success is enough.
118
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
119
+ // slotted second deck returning 'failed') must not poison an applied
120
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
121
+ // nobody applied. Every handler still runs (no short-circuit) so each
122
+ // sibling instance gets the state.
123
+ let result = { type: 'ignored', reason: 'no applier result' };
124
+ for (const fn of Array.from(fns)) {
125
+ let r;
126
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
127
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
128
+ if (!r) continue;
129
+ if (r.type === 'applied') result = r;
130
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
131
+ }
132
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
133
+ });
134
+ }
135
+
57
136
  // One-time CSS injection (classes are dc-prefixed so they don't collide with
58
137
  // the hosted design's own styles).
59
138
  if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
@@ -228,10 +307,23 @@ function dcFlatten(children) {
228
307
  // ─────────────────────────────────────────────────────────────
229
308
  const DC_STATE_FILE = '.design-canvas.state.json';
230
309
 
231
- // Persist a sidecar file back to the host.
232
- function miaodaWriteFile(path, content) {
310
+ // Persist a sidecar file back to the host. writeContext is an OWNERSHIP
311
+ // declaration for the host's release pipeline (not a sync receipt — the
312
+ // product can't know whether a Magic Share sync actually reached anyone):
313
+ // {kind:'mirror-managed', key} says this file's full content is the same
314
+ // state a mirror channel carries, so the host may skip reloading followers
315
+ // in FOLLOW mode for a release made purely of such writes — they already
316
+ // have the state via mirror, and channel guarantees (retry / per-key latest
317
+ // on rejoin / re-seed after reload / the file itself on next cold load)
318
+ // cover every miss. Persistence and deploy proceed unchanged — this is NOT
319
+ // a notify:false. Only declare it where emit and write are fed by the very
320
+ // same snapshot — an untagged write reloads as before, which is the
321
+ // fail-safe for states that are NOT mirrored.
322
+ function miaodaWriteFile(path, content, writeContext) {
233
323
  try {
234
- window.parent.postMessage({ type: 'miaoda:bridge:write-file', path: path, content: content }, '*');
324
+ const msg = { type: 'miaoda:bridge:write-file', path: path, content: content };
325
+ if (writeContext) msg.writeContext = writeContext;
326
+ window.parent.postMessage(msg, '*');
235
327
  } catch (e) {
236
328
  /* no parent — ignore */
237
329
  }
@@ -248,13 +340,30 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
248
340
  const [ready, setReady] = React.useState(false);
249
341
  const didRead = React.useRef(false);
250
342
  const skipNextWrite = React.useRef(false);
343
+ // Magic Share echo guards, one per mirrored state: a remote apply must land
344
+ // locally without re-broadcasting as if the user did it here.
345
+ const skipNextMirrorEmit = React.useRef(false);
346
+ const skipNextFocusEmit = React.useRef(false);
347
+ // Current focus, readable synchronously by the applier — an apply carrying
348
+ // the value we already hold must NOT set the skip flag: same-value setState
349
+ // re-renders but the [state.focus] effect bails out (primitive dep), so the
350
+ // flag would strand and swallow the next real focus broadcast.
351
+ const focusRef = React.useRef(state.focus);
352
+ focusRef.current = state.focus;
353
+
354
+ // Set once a Magic Share structure replay has landed. A late joiner's
355
+ // replay races the sidecar fetch below — the replay is the fresher truth
356
+ // (it IS the presenter's current arrangement, and it was already answered
357
+ // with 'applied' so the SDK won't resend), so a slower fetch must not
358
+ // clobber it with this end's stale sidecar.
359
+ const mirrorSeeded = React.useRef(false);
251
360
 
252
361
  React.useEffect(() => {
253
362
  let off = false;
254
363
  fetch('./' + DC_STATE_FILE)
255
364
  .then((r) => (r.ok ? r.json() : null))
256
365
  .then((saved) => {
257
- if (off || !saved || !saved.sections) return;
366
+ if (off || mirrorSeeded.current || !saved || !saved.sections) return;
258
367
  skipNextWrite.current = true;
259
368
  setState((s) => ({ ...s, sections: saved.sections }));
260
369
  })
@@ -266,13 +375,78 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
266
375
 
267
376
  React.useEffect(() => {
268
377
  if (!didRead.current) return;
378
+ // Magic Share: snapshot the arrangement whenever it settles — including
379
+ // the sidecar hydration (the skipNextWrite path), which is exactly the
380
+ // baseline a late-joining follower needs. Only a remote apply must not
381
+ // re-broadcast.
382
+ if (skipNextMirrorEmit.current) skipNextMirrorEmit.current = false;
383
+ else if (!dcCaptureMode) window.__miaodaMirror.emit({ type: 'miaoda:canvas:structure-changed', sections: state.sections }, { key: 'canvas:structure' });
269
384
  if (skipNextWrite.current) { skipNextWrite.current = false; return; }
270
385
  const t = setTimeout(() => {
271
- miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
386
+ // mirror-managed precondition: this write persists the same snapshot
387
+ // the emit above carried on canvas:structure — keep both fed by the
388
+ // same state if either moves.
389
+ miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections }),
390
+ { kind: 'mirror-managed', key: 'canvas:structure' }).catch(() => {});
272
391
  }, 250);
273
392
  return () => clearTimeout(t);
274
393
  }, [state.sections]);
275
394
 
395
+ // Magic Share: focus is ephemeral view state — mirror it as its own key so
396
+ // a follower enters/exits the focus overlay in step with the presenter.
397
+ React.useEffect(() => {
398
+ if (dcCaptureMode) return;
399
+ if (skipNextFocusEmit.current) { skipNextFocusEmit.current = false; return; }
400
+ window.__miaodaMirror.emit({ type: 'miaoda:canvas:focus-changed', focus: state.focus }, { key: 'canvas:focus' });
401
+ }, [state.focus]);
402
+
403
+ // Magic Share replay entries — consume the same message types this
404
+ // component emits, so the emit↔apply contract stays in this one file.
405
+ React.useEffect(() => {
406
+ if (dcCaptureMode) return;
407
+ const offs = [
408
+ window.__miaodaMirror.onApply('miaoda:canvas:structure-changed', (m) => {
409
+ // Per-field shape check, not just typeof: a malformed snapshot (e.g.
410
+ // order that isn't an array) would pass setState here, be answered
411
+ // 'applied', and only then blow up the whole React tree in render —
412
+ // an unrecoverable failure the retry protocol can't see.
413
+ if (!m.sections || typeof m.sections !== 'object') return { type: 'ignored', reason: 'no sections' };
414
+ for (const sec of Object.values(m.sections)) {
415
+ if (!sec || typeof sec !== 'object' ||
416
+ (sec.order !== undefined && !Array.isArray(sec.order)) ||
417
+ (sec.hidden !== undefined && !Array.isArray(sec.hidden)) ||
418
+ (sec.labels !== undefined && (typeof sec.labels !== 'object' || sec.labels === null))) {
419
+ return { type: 'ignored', reason: 'malformed sections' };
420
+ }
421
+ }
422
+ skipNextMirrorEmit.current = true;
423
+ // A replayed arrangement is the presenter's state, not a local edit —
424
+ // never persist it back through this end's write bridge.
425
+ skipNextWrite.current = true;
426
+ // Marking didRead lets the sections effect run NOW and consume both
427
+ // flags — before hydration settles they would otherwise strand on the
428
+ // effect's !didRead early-return and swallow a later real emit/write.
429
+ mirrorSeeded.current = true;
430
+ didRead.current = true;
431
+ setState((s) => ({ ...s, sections: m.sections }));
432
+ return { type: 'applied' };
433
+ }),
434
+ window.__miaodaMirror.onApply('miaoda:canvas:focus-changed', (m) => {
435
+ const next = typeof m.focus === 'string' ? m.focus : null;
436
+ // Same value → nothing will change and the [state.focus] effect won't
437
+ // re-run; setting the skip flag would strand it (see focusRef note).
438
+ if (next === focusRef.current) return { type: 'applied' };
439
+ // Sync the ref now, not at next render — a second apply landing in
440
+ // the same tick would otherwise compare against the stale value.
441
+ focusRef.current = next;
442
+ skipNextFocusEmit.current = true;
443
+ setState((s) => ({ ...s, focus: next }));
444
+ return { type: 'applied' };
445
+ }),
446
+ ];
447
+ return () => offs.forEach((off) => off());
448
+ }, []);
449
+
276
450
  // Build registries synchronously from children so FocusOverlay can read
277
451
  // them in the same render. Fragments are flattened; wrapping in other
278
452
  // elements still opts out of focus/reorder.
@@ -466,6 +640,16 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
466
640
  const saveT = React.useRef(0);
467
641
 
468
642
  const lastPostedScale = React.useRef();
643
+ // Magic Share: trailing-throttle timer for the viewport snapshot, plus a
644
+ // guard so a replayed viewport doesn't schedule its own re-broadcast.
645
+ const mirrorEmitT = React.useRef(0);
646
+ const applyingRemoteView = React.useRef(false);
647
+ // True while tf holds a REMOTE framing (last write came from a replay,
648
+ // no local gesture since). Gates every localStorage persist path — the
649
+ // 200ms saveT and the pagehide/unmount flush both read tf at fire time,
650
+ // so guarding only the scheduling call would still leak the presenter's
651
+ // framing into this viewer's saved viewport.
652
+ const viewIsRemote = React.useRef(false);
469
653
  // Imperative handle for the on-canvas zoom control to zoom around the
470
654
  // viewport centre (same path as the host's set-zoom message). Assigned in
471
655
  // the pointer/wheel effect below where zoomAt is in scope.
@@ -502,10 +686,34 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
502
686
  lastPostedScale.current = scale;
503
687
  window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
504
688
  }
505
- clearTimeout(saveT.current);
506
- saveT.current = setTimeout(() => {
507
- try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
508
- }, 200);
689
+ // A replayed viewport is the presenter's framing, not this viewer's — it
690
+ // must not overwrite the viewport this end will restore when opening the
691
+ // canvas on its own later. Any local gesture re-owns the view (and its
692
+ // persistence); apply() is the single fan-out for both, so this branch
693
+ // is the one place that distinction needs to live.
694
+ if (applyingRemoteView.current) {
695
+ viewIsRemote.current = true;
696
+ } else {
697
+ viewIsRemote.current = false;
698
+ clearTimeout(saveT.current);
699
+ saveT.current = setTimeout(() => {
700
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
701
+ }, 200);
702
+ }
703
+ // Magic Share: mirror the full viewport (scale AND pan) as a snapshot.
704
+ // Trailing-throttled at 120ms rather than per-apply — apply() runs at
705
+ // pointer rate, and this file deliberately avoids per-frame cross-frame
706
+ // posts (see the zoom guard above); 120ms keeps a follower tracking a
707
+ // continuous pan without that cost.
708
+ if (!dcCaptureMode && !applyingRemoteView.current && !mirrorEmitT.current) {
709
+ mirrorEmitT.current = setTimeout(() => {
710
+ mirrorEmitT.current = 0;
711
+ window.__miaodaMirror.emit(
712
+ { type: 'miaoda:canvas:viewport-changed', x: tf.current.x, y: tf.current.y, scale: tf.current.scale },
713
+ { key: 'canvas:viewport', throttle: 60 },
714
+ );
715
+ }, 120);
716
+ }
509
717
  }, [tfKey]);
510
718
 
511
719
  // Whether the mount-time restore found a saved viewport — when it did, the
@@ -516,6 +724,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
516
724
  React.useLayoutEffect(() => {
517
725
  const flush = () => {
518
726
  clearTimeout(saveT.current);
727
+ // Unload/unmount with a replayed framing on screen: persist nothing —
728
+ // the saved viewport must stay this viewer's own.
729
+ if (viewIsRemote.current) return;
519
730
  try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
520
731
  };
521
732
  try {
@@ -852,10 +1063,42 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
852
1063
  // after the mount-time announce) drops prior state, so re-declare here.
853
1064
  window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*');
854
1065
  lastPostedScale.current = undefined;
1066
+ // The re-announce apply() must not change who owns the view: a
1067
+ // viewport seed can land BEFORE the iframe's native 'load' fires this
1068
+ // probe, and a plain local apply() would flip viewIsRemote and
1069
+ // persist the presenter's framing — the exact leak viewIsRemote
1070
+ // exists to stop. The zoom % re-post is ungated either way.
1071
+ applyingRemoteView.current = viewIsRemote.current;
855
1072
  apply();
1073
+ applyingRemoteView.current = false;
856
1074
  }
857
1075
  };
858
1076
  window.addEventListener('message', onHostMsg);
1077
+ // Magic Share replay entry for the viewport — reuses the local apply()
1078
+ // pipeline (DOM transform, --dc-inv-zoom, host % readout); the
1079
+ // applyingRemoteView guard keeps the replay from re-broadcasting or
1080
+ // persisting the presenter's framing.
1081
+ const offViewportApply = dcCaptureMode ? () => {} : window.__miaodaMirror.onApply('miaoda:canvas:viewport-changed', (m) => {
1082
+ if (![m.x, m.y, m.scale].every(Number.isFinite)) return { type: 'ignored', reason: 'bad snapshot' };
1083
+ // Kill any pending local-emit timer: it was armed before this replay
1084
+ // and would otherwise fire 120ms later with tf already holding the
1085
+ // REMOTE state — echoing the presenter's viewport back as our own.
1086
+ clearTimeout(mirrorEmitT.current);
1087
+ mirrorEmitT.current = 0;
1088
+ // Same staleness for the persist timer: scheduled by a local gesture,
1089
+ // but it reads tf at fire time and would save the remote framing.
1090
+ clearTimeout(saveT.current);
1091
+ // A replayed framing IS this canvas's framing now — mark the first-visit
1092
+ // fit as satisfied, or its layout effect (armed whenever a fresh viewer
1093
+ // has no saved viewport) would re-measure and override the presenter's
1094
+ // viewport as soon as content mounts.
1095
+ didFit.current = true;
1096
+ applyingRemoteView.current = true;
1097
+ tf.current = { x: m.x, y: m.y, scale: Math.min(maxScale, Math.max(minScale, m.scale)) };
1098
+ apply();
1099
+ applyingRemoteView.current = false;
1100
+ return { type: 'applied' };
1101
+ });
859
1102
  // Announce canvas mode so the host toolbar proxies its % control here
860
1103
  // instead of scaling the iframe element (which would just shrink the
861
1104
  // viewport window of an infinite canvas). The apply() that follows emits
@@ -889,6 +1132,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
889
1132
  vp.addEventListener('pointercancel', pu);
890
1133
  return () => {
891
1134
  window.removeEventListener('message', onHostMsg);
1135
+ offViewportApply();
1136
+ clearTimeout(mirrorEmitT.current);
1137
+ mirrorEmitT.current = 0;
892
1138
  vp.removeEventListener('wheel', onWheel);
893
1139
  vp.removeEventListener('gesturestart', onGestureStart);
894
1140
  vp.removeEventListener('gesturechange', onGestureChange);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-registry-buildless",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Miaoda registry (buildless runtime) — shadcn-style copy-in SDKs (globals + CDN-pinned, browser Babel) for buildless static HTML stacks",
5
5
  "type": "module",
6
6
  "files": [
@@ -111,11 +111,86 @@
111
111
  * @media (prefers-reduced-motion:no-preference){ [data-deck-active] .fade-up{ animation: fadeUp .5s both } }
112
112
  * @keyframes fadeUp { from{ opacity:0; transform:translateY(24px) } to{ opacity:1; transform:none } }
113
113
  * Avoid infinite decorative loops on slide content.
114
+ *
115
+ * window.__miaodaMirror (defined below) is Magic-Share sync plumbing between
116
+ * this component and the miaoda host — internal; do not call it from slide
117
+ * code or remove it.
114
118
  */
115
119
  /* END USAGE */
116
120
 
117
121
  (() => {
118
122
  if (typeof window === 'undefined') return;
123
+
124
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
125
+ // Shared by every buildless script on the page (first one to load defines
126
+ // it). Inlined per script rather than shipped as its own registry file:
127
+ // call sites use the global bare, so inlining is what guarantees
128
+ // definition-before-use — a separate copy-in file would add a whole "SDK
129
+ // copied but <script> tag missing" failure mode. Keep this block identical
130
+ // across deck-stage.js / design-canvas.jsx / animations.jsx — same
131
+ // convention as the mobile-UA regex. Two halves of
132
+ // the 妙享 (Magic Share) mirror protocol:
133
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
134
+ // message as a broadcastable UI-state snapshot;
135
+ // unstamped messages are never mirrored. opts.key is
136
+ // the sync channel (maps to the share SDK's
137
+ // actionType): late joiners receive only the LATEST
138
+ // snapshot per key, so states that overwrite each
139
+ // other must share one key. opts.throttle is the
140
+ // suggested host-side coalescing interval in ms —
141
+ // snapshots make dropped intermediate frames
142
+ // lossless, so hint it on any continuous gesture.
143
+ // onApply(type, fn) — replay entry for a message type this script itself
144
+ // emits. The host sends {type:'miaoda:mirror:apply',
145
+ // uuid, message, latencyMs} and needs
146
+ // {type:'miaoda:mirror:apply-result', uuid, result}
147
+ // back to drive the SDK's retry: result.type
148
+ // 'failed' means retry (use it for transient
149
+ // not-ready states), 'ignored' is terminal (not
150
+ // applicable / bad payload), 'applied' is success.
151
+ // Unregistered types stay silent on purpose —
152
+ // another script on the page may own them, and the
153
+ // host's timeout is the backstop.
154
+ if (!window.__miaodaMirror) {
155
+ const appliers = new Map(); // message.type -> Set<fn>
156
+ window.__miaodaMirror = {
157
+ emit(msg, opts) {
158
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
159
+ },
160
+ onApply(type, fn) {
161
+ if (!appliers.has(type)) appliers.set(type, new Set());
162
+ appliers.get(type).add(fn);
163
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
164
+ },
165
+ };
166
+ window.addEventListener('message', async (e) => {
167
+ // Replays are accepted from the host frame only — generated pages embed
168
+ // arbitrary third-party iframes, any of which could otherwise forge an
169
+ // apply and drive state (plus its persisted side effects).
170
+ if (e.source !== window.parent) return;
171
+ const d = e.data;
172
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
173
+ const fns = appliers.get(d.message.type);
174
+ if (!fns || !fns.size) return;
175
+ // Merge across handlers (multi-instance case): one success is enough.
176
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
177
+ // slotted second deck returning 'failed') must not poison an applied
178
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
179
+ // nobody applied. Every handler still runs (no short-circuit) so each
180
+ // sibling instance gets the state.
181
+ let result = { type: 'ignored', reason: 'no applier result' };
182
+ for (const fn of Array.from(fns)) {
183
+ let r;
184
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
185
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
186
+ if (!r) continue;
187
+ if (r.type === 'applied') result = r;
188
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
189
+ }
190
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
191
+ });
192
+ }
193
+
119
194
  const DESIGN_W_DEFAULT = 1920;
120
195
  const DESIGN_H_DEFAULT = 1080;
121
196
  // Safe margin around the scaled slide in edit/preview (non-presenting) mode,
@@ -880,6 +955,57 @@
880
955
  });
881
956
  // Announce to the host that this product IS a deck scene.
882
957
  try { window.parent.postMessage({ type: 'miaoda:deck:available' }, '*'); } catch (e) {}
958
+ // Magic Share replay entries — each consumes the very message type this
959
+ // component emits, so the emit↔apply contract lives in one file and is
960
+ // version-consistent on both ends of a share by construction. Replays
961
+ // route through the same internal paths as local interaction (_go, the
962
+ // miaoda:deck:* inbound handlers); the re-emit those paths produce is
963
+ // the host role gate's to drop (the protocol's first anti-echo layer).
964
+ // Synthetic replays through _onMessage run under _applyingMirror so the
965
+ // handlers can tell "presenter state to render" from "this viewer's own
966
+ // choice to persist" (see the rail localStorage guard).
967
+ const applyViaOnMessage = (data) => {
968
+ this._applyingMirror = true;
969
+ try { this._onMessage({ data }); } finally { this._applyingMirror = false; }
970
+ };
971
+ // Capture iframes navigate on their own — the emit side stays unstamped
972
+ // there for exactly that reason, and the replay entries stay
973
+ // unregistered symmetrically so a (mis)forwarded apply can't steer a
974
+ // pinned thumbnail either.
975
+ this._mirrorOff = this._snthumb ? [] : [
976
+ window.__miaodaMirror.onApply('miaoda:deck:slide-changed', (m) => {
977
+ // isFinite, not typeof: NaN|0 would silently land on slide 1.
978
+ if (!Number.isFinite(m.index) || m.index < 0) return { type: 'ignored', reason: 'bad index' };
979
+ // Slides are collected by slotchange AFTER connectedCallback, so a
980
+ // late joiner's replay can beat them — that's transient, and only
981
+ // 'failed' makes the SDK retry ('ignored' would strand the joiner
982
+ // on slide 1).
983
+ if (!this._slides.length) return { type: 'failed', reason: 'no slides yet' };
984
+ const requested = m.index | 0;
985
+ const clamped = Math.max(0, Math.min(this._slides.length - 1, requested));
986
+ if (clamped !== requested) {
987
+ // Fewer slides than the presenter (reload lag after an agent
988
+ // edit): show the nearest page as best effort, but answer
989
+ // 'failed' so the SDK keeps retrying until the slide exists —
990
+ // 'applied' here would strand this end on the clamped page.
991
+ if (clamped !== this._index) this._go(clamped, 'api');
992
+ return { type: 'failed', reason: 'requested slide not present yet' };
993
+ }
994
+ if (clamped === this._index) return { type: 'applied' };
995
+ this._go(clamped, 'api');
996
+ return this._index === clamped ? { type: 'applied' } : { type: 'failed', reason: 'goto did not land' };
997
+ }),
998
+ window.__miaodaMirror.onApply('miaoda:deck:presenting-changed', (m) => {
999
+ if (typeof m.on !== 'boolean') return { type: 'ignored', reason: 'no on' };
1000
+ applyViaOnMessage({ type: 'miaoda:deck:presenting', on: m.on });
1001
+ return { type: 'applied' };
1002
+ }),
1003
+ window.__miaodaMirror.onApply('miaoda:deck:rail-visible-changed', (m) => {
1004
+ if (typeof m.on !== 'boolean') return { type: 'ignored', reason: 'no on' };
1005
+ applyViaOnMessage({ type: 'miaoda:deck:rail-visible', on: m.on });
1006
+ return { type: 'applied' };
1007
+ }),
1008
+ ];
883
1009
  }
884
1010
 
885
1011
  _enableRail() {
@@ -1235,6 +1361,8 @@
1235
1361
  if (this._headObserver) this._headObserver.disconnect();
1236
1362
  (this._thumbs || []).forEach((t) => clearTimeout(t._canvasRecapTimer));
1237
1363
  if (this._onTweakChange) window.removeEventListener('tweakchange', this._onTweakChange);
1364
+ (this._mirrorOff || []).forEach((off) => off());
1365
+ this._mirrorOff = null;
1238
1366
  }
1239
1367
 
1240
1368
  attributeChangedCallback() {
@@ -1689,6 +1817,16 @@
1689
1817
  if (n >= 0 && n !== this._index) this._go(n, 'api');
1690
1818
  }
1691
1819
 
1820
+ // Single home for the capture-iframe rule: presenter-popup thumbnails
1821
+ // (_snthumb) navigate on their own, so nothing they post may carry the
1822
+ // Magic Share stamp — a stamped emit from a pinned N±1 thumbnail would
1823
+ // broadcast conflicting state. Route every stamp through here so a new
1824
+ // emit site can't forget the rule.
1825
+ _stampMirror(msg, key) {
1826
+ if (!this._snthumb) msg.__mirror = { v: 1, key: key };
1827
+ return msg;
1828
+ }
1829
+
1692
1830
  _applyIndex({ broadcast = true, reason = 'init' } = {}) {
1693
1831
  if (!this._slides.length) return;
1694
1832
  const prev = this._prevIndex == null ? -1 : this._prevIndex;
@@ -1718,8 +1856,14 @@
1718
1856
  this._syncNotesBody();
1719
1857
 
1720
1858
  if (broadcast) {
1721
- // (1) Parent-window postMessage for speaker-notes renderers.
1722
- try { window.parent.postMessage({ type: 'miaoda:deck:slide-changed', index: curr, total: this._slides.length, skipped: this._skippedIndices() }, '*'); } catch (e) {}
1859
+ // (1) Parent-window postMessage for speaker-notes renderers, stamped
1860
+ // __mirror for Magic Share. Capture iframes (presenter-popup
1861
+ // thumbnails pinned to N±1) navigate on their own — stamping them
1862
+ // would broadcast conflicting indices, so they stay unstamped.
1863
+ try {
1864
+ const msg = { type: 'miaoda:deck:slide-changed', index: curr, total: this._slides.length, skipped: this._skippedIndices() };
1865
+ window.parent.postMessage(this._stampMirror(msg, 'deck:slide'), '*');
1866
+ } catch (e) {}
1723
1867
 
1724
1868
  // (2) In-page CustomEvent on the <deck-stage> element itself.
1725
1869
  // Bubbles and composes out of shadow DOM so slide code can listen:
@@ -1977,6 +2121,10 @@
1977
2121
  _onMessage(e) {
1978
2122
  const d = e.data;
1979
2123
  if (d && d.type === 'miaoda:deck:presenting' && typeof d.on === 'boolean') {
2124
+ // Same-value messages (incl. mirror replays routed back through here)
2125
+ // must not re-run the layout work or re-emit. _presenting starts
2126
+ // undefined, so the first message always lands.
2127
+ if (d.on === this._presenting) return;
1980
2128
  this._presenting = d.on;
1981
2129
  this._syncMobileMode();
1982
2130
  if (!d.on) this._toggleMobUi(false);
@@ -1985,6 +2133,9 @@
1985
2133
  this._closeMenu();
1986
2134
  this._fit();
1987
2135
  this._scaleThumbs();
2136
+ // Presenting is host-driven, so the inbound handler is the one place
2137
+ // the state settles — snapshot it out for Magic Share from here.
2138
+ try { window.parent.postMessage(this._stampMirror({ type: 'miaoda:deck:presenting-changed', on: d.on }, 'deck:presenting'), '*'); } catch (e) {}
1988
2139
  }
1989
2140
  // Host's Preview segment (ViewerMode='none'): the rail's drag-reorder /
1990
2141
  // right-click skip-delete affordances are editing chrome, so hide it
@@ -2007,9 +2158,14 @@
2007
2158
  if (d && d.type === 'miaoda:deck:rail-visible' && typeof d.on === 'boolean') {
2008
2159
  if (d.on === this._railVisible) return;
2009
2160
  this._railVisible = d.on;
2010
- try { localStorage.setItem('deck-stage.railVisible', d.on ? '1' : '0'); } catch (e) {}
2161
+ // Rail visibility is a per-viewer preference — a mirror replay drives
2162
+ // the visual state but must not overwrite what this viewer chose for
2163
+ // themselves after the share ends.
2164
+ if (!this._applyingMirror) {
2165
+ try { localStorage.setItem('deck-stage.railVisible', d.on ? '1' : '0'); } catch (e) {}
2166
+ }
2011
2167
  // Notify the parent so its toolbar state stays in sync.
2012
- try { window.parent.postMessage({ type: 'miaoda:deck:rail-visible-changed', on: d.on }, '*'); } catch (e) {}
2168
+ try { window.parent.postMessage(this._stampMirror({ type: 'miaoda:deck:rail-visible-changed', on: d.on }, 'deck:rail-visible'), '*'); } catch (e) {}
2013
2169
  // Arm the transition, commit it, then flip state — otherwise the
2014
2170
  // browser coalesces both writes and nothing animates on show.
2015
2171
  this.setAttribute('data-rail-anim', '');
@@ -2132,6 +2288,9 @@
2132
2288
  this._fit();
2133
2289
  this._toggleMobUi(false);
2134
2290
  try { window.parent.postMessage({ type: 'miaoda:deck:presenting-dismissed' }, '*'); } catch (err) {}
2291
+ // The mobile back button flips presenting locally (no host round-trip),
2292
+ // so mirror the settled state from here too.
2293
+ try { window.parent.postMessage(this._stampMirror({ type: 'miaoda:deck:presenting-changed', on: false }, 'deck:presenting'), '*'); } catch (e) {}
2135
2294
  });
2136
2295
  const strip = document.createElement('div');
2137
2296
  strip.className = 'mob-strip';
@@ -2742,7 +2901,10 @@
2742
2901
  this._emitDeckChange({ action: on ? 'skip' : 'unskip', from: i, slide });
2743
2902
  // Re-broadcast so the presenter popup's prev/next thumbnails re-pick
2744
2903
  // the nearest non-skipped slide without waiting for a nav event.
2745
- try { window.parent.postMessage({ type: 'miaoda:deck:slide-changed', index: this._index, total: this._slides.length, skipped: this._skippedIndices() }, '*'); } catch (e) {}
2904
+ try {
2905
+ const msg = { type: 'miaoda:deck:slide-changed', index: this._index, total: this._slides.length, skipped: this._skippedIndices() };
2906
+ window.parent.postMessage(this._stampMirror(msg, 'deck:slide'), '*');
2907
+ } catch (e) {}
2746
2908
  }
2747
2909
 
2748
2910
  _skippedIndices() {