@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.
- package/components/animations.jsx +173 -6
- package/components/design-canvas.jsx +255 -9
- package/package.json +1 -1
- package/scripts/deck-stage.js +167 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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.
|
|
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": [
|
package/scripts/deck-stage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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() {
|