@lark-apaas/coding-registry-buildless 0.1.25-alpha.20260922075718 → 0.1.26

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.
@@ -31,9 +31,9 @@
31
31
  // it). Inlined per script rather than shipped as its own registry file:
32
32
  // call sites use the global bare, so inlining is what guarantees
33
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
34
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
35
+ // identical (find them by this block's heading) — same convention as the
36
+ // mobile-UA regex. Two halves of
37
37
  // the 妙享 (Magic Share) mirror protocol:
38
38
  // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
39
39
  // message as a broadcastable UI-state snapshot;
@@ -101,9 +101,9 @@ function dcFitTransformToBounds(bounds, vp, minScale, maxScale) {
101
101
  // it). Inlined per script rather than shipped as its own registry file:
102
102
  // call sites use the global bare, so inlining is what guarantees
103
103
  // definition-before-use — a separate copy-in file would add a whole "SDK
104
- // copied but <script> tag missing" failure mode. Keep this block identical
105
- // across deck-stage.js / design-canvas.jsx / animations.jsx — same
106
- // convention as the mobile-UA regex. Two halves of
104
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
105
+ // identical (find them by this block's heading) — same convention as the
106
+ // mobile-UA regex. Two halves of
107
107
  // the 妙享 (Magic Share) mirror protocol:
108
108
  // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
109
109
  // message as a broadcastable UI-state snapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-registry-buildless",
3
- "version": "0.1.25-alpha.20260922075718",
3
+ "version": "0.1.26",
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": [
@@ -80,6 +80,76 @@
80
80
  /* END USAGE */
81
81
 
82
82
  (() => {
83
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
84
+ // Shared by every buildless script on the page (first one to load defines
85
+ // it). Inlined per script rather than shipped as its own registry file:
86
+ // call sites use the global bare, so inlining is what guarantees
87
+ // definition-before-use — a separate copy-in file would add a whole "SDK
88
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
89
+ // identical (find them by this block's heading) — same convention as the
90
+ // mobile-UA regex. Two halves of
91
+ // the 妙享 (Magic Share) mirror protocol:
92
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
93
+ // message as a broadcastable UI-state snapshot;
94
+ // unstamped messages are never mirrored. opts.key is
95
+ // the sync channel (maps to the share SDK's
96
+ // actionType): late joiners receive only the LATEST
97
+ // snapshot per key, so states that overwrite each
98
+ // other must share one key. opts.throttle is the
99
+ // suggested host-side coalescing interval in ms —
100
+ // snapshots make dropped intermediate frames
101
+ // lossless, so hint it on any continuous gesture.
102
+ // onApply(type, fn) — replay entry for a message type this script itself
103
+ // emits. The host sends {type:'miaoda:mirror:apply',
104
+ // uuid, message, latencyMs} and needs
105
+ // {type:'miaoda:mirror:apply-result', uuid, result}
106
+ // back to drive the SDK's retry: result.type
107
+ // 'failed' means retry (use it for transient
108
+ // not-ready states), 'ignored' is terminal (not
109
+ // applicable / bad payload), 'applied' is success.
110
+ // Unregistered types stay silent on purpose —
111
+ // another script on the page may own them, and the
112
+ // host's timeout is the backstop.
113
+ if (!window.__miaodaMirror) {
114
+ const appliers = new Map(); // message.type -> Set<fn>
115
+ window.__miaodaMirror = {
116
+ emit(msg, opts) {
117
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
118
+ },
119
+ onApply(type, fn) {
120
+ if (!appliers.has(type)) appliers.set(type, new Set());
121
+ appliers.get(type).add(fn);
122
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
123
+ },
124
+ };
125
+ window.addEventListener('message', async (e) => {
126
+ // Replays are accepted from the host frame only — generated pages embed
127
+ // arbitrary third-party iframes, any of which could otherwise forge an
128
+ // apply and drive state (plus its persisted side effects).
129
+ if (e.source !== window.parent) return;
130
+ const d = e.data;
131
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
132
+ const fns = appliers.get(d.message.type);
133
+ if (!fns || !fns.size) return;
134
+ // Merge across handlers (multi-instance case): one success is enough.
135
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
136
+ // slotted second deck returning 'failed') must not poison an applied
137
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
138
+ // nobody applied. Every handler still runs (no short-circuit) so each
139
+ // sibling instance gets the state.
140
+ let result = { type: 'ignored', reason: 'no applier result' };
141
+ for (const fn of Array.from(fns)) {
142
+ let r;
143
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
144
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
145
+ if (!r) continue;
146
+ if (r.type === 'applied') result = r;
147
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
148
+ }
149
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
150
+ });
151
+ }
152
+
83
153
  /** Shorthands for the common frames; anything else is computed by parseRatio — this is not an allowlist */
84
154
  const RATIOS = {
85
155
  '3:4': [1080, 1440],
@@ -414,13 +484,20 @@
414
484
  (paging / zoom / copy / download).
415
485
  overflow:auto is what lets you scroll a card that has been zoomed past the viewport,
416
486
  and the holder centres with margin:auto — flex centring clips the top once the content
417
- outgrows its container. */
487
+ outgrows its container.
488
+ The enlarged card must stay commentable while everything around it must not: the host
489
+ exempts a whole event path once any ancestor carries the chrome mark (csChrome), so
490
+ the mark cannot sit on .cs-focus. The dark ground is a separate scrim sibling carrying the mark,
491
+ and the holder is exactly the card's size — every empty pixel then belongs to the
492
+ scrim. pointer-events cannot carve that out instead: comment mode forces
493
+ pointer-events:auto on every element. */
418
494
  .cs-focus {
419
495
  position: fixed; inset: 0; z-index: 100;
420
- background: rgba(16,17,20,.92);
421
496
  display: grid; overflow: auto;
497
+ padding: 56px 24px 104px; box-sizing: border-box;
422
498
  }
423
- .cs-focus .cs-holder { margin: auto; padding: 56px 24px 104px; }
499
+ .cs-focus-scrim { position: fixed; inset: 0; background: rgba(16,17,20,.92); }
500
+ .cs-focus .cs-holder { position: relative; margin: auto; }
424
501
  .cs-focus .cs-holder > section { outline: none !important; box-shadow: none !important; }
425
502
  .cs-focus-close {
426
503
  position: fixed; top: 20px; right: 24px; z-index: 2;
@@ -1239,6 +1316,9 @@
1239
1316
  connectedCallback() {
1240
1317
  if (this.dataset.ready) return;
1241
1318
  this.dataset.ready = '1';
1319
+ // Before init, not after: a late joiner's replay can land while init still awaits the
1320
+ // sidecar, and only a registered applier can answer 'failed' to make the SDK retry
1321
+ this.wireMirror();
1242
1322
  // The script is usually loaded synchronously from <head>, at which point the child
1243
1323
  // <section>s are not parsed yet — wait for the document before initialising
1244
1324
  if (document.readyState === 'loading') {
@@ -1364,10 +1444,15 @@
1364
1444
  // themselves; the "fit" button and the 0 key do not count as framing, since following
1365
1445
  // the viewport is exactly what they ask for.
1366
1446
  addEventListener('resize', () => {
1447
+ if (this._remoteBounds) { this.fitBounds(this._remoteBounds); return; }
1367
1448
  if (this.userFramed) { this.placeHeads(); return; }
1368
1449
  this.fit();
1369
1450
  });
1370
1451
  }
1452
+ this._ready = true;
1453
+ // Baseline for late joiners (the viewport baseline already went out through fit)
1454
+ this.emitStructure();
1455
+ this.emitFocus();
1371
1456
  ready();
1372
1457
  void this.reportOverflow();
1373
1458
  }
@@ -1572,6 +1657,7 @@
1572
1657
  section.dataset.label = label.textContent.trim();
1573
1658
  this._labelOverrides[section.dataset.cardKey] = section.dataset.label;
1574
1659
  this.saveState();
1660
+ this.emitStructure();
1575
1661
  });
1576
1662
  label.addEventListener('keydown', (e) => {
1577
1663
  if (e.key === 'Enter') { e.preventDefault(); label.blur(); }
@@ -1679,20 +1765,27 @@
1679
1765
  deleteCard(n) {
1680
1766
  const section = this.cardAt(n);
1681
1767
  if (!section) return;
1682
- if (this.focusIndex === n) this.closeFocus();
1683
1768
  const key = section.dataset.cardKey;
1769
+ this.dropCard(section);
1770
+ if (key && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
1771
+ this.saveState();
1772
+ this.emitStructure();
1773
+ this.toast('已删除');
1774
+ }
1775
+
1776
+ /** Takes a card off the page — shared by a local delete and a replayed one, which must not persist */
1777
+ dropCard(section) {
1778
+ if (this.focusIndex === Number(section.dataset.cardIndex)) this.closeFocus();
1684
1779
  const head = this.headFor && this.headFor.get(section);
1685
1780
  if (head) head.remove();
1686
1781
  if (this.headFor) this.headFor.delete(section);
1687
1782
  section.remove();
1688
- if (key && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
1689
- this.saveState();
1690
1783
 
1691
1784
  const cards = this.cards();
1692
1785
  cards.forEach((s, i) => { s.dataset.cardIndex = String(i + 1); });
1693
- this.heads = cards.map((s) => this.headFor.get(s));
1786
+ if (this.headFor) this.heads = cards.map((s) => this.headFor.get(s));
1694
1787
  this.placeHeads();
1695
- this.toast('已删除');
1788
+ if (csIsMobile) this.goToPage(this.page, false);
1696
1789
  }
1697
1790
 
1698
1791
  /**
@@ -1811,10 +1904,18 @@
1811
1904
 
1812
1905
  // A click on a card opens focus mode (but not when the click lands on a link, button or
1813
1906
  // the label)
1907
+ // Releasing a text-selection drag still dispatches a click on the card. Opening focus
1908
+ // there would bury the selection under the overlay before the host's selection-comment
1909
+ // toolbar can use it, so a press that moved or left a selection is not a click.
1910
+ let downX = 0, downY = 0;
1911
+ this.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
1814
1912
  this.addEventListener('click', (e) => {
1815
1913
  const section = e.target.closest('card-stage > section');
1816
1914
  if (!section) return;
1817
1915
  if (e.target.closest('a, button, input, textarea, [contenteditable]')) return;
1916
+ if (Math.hypot(e.clientX - downX, e.clientY - downY) > 4) return;
1917
+ const sel = getSelection();
1918
+ if (sel && !sel.isCollapsed && section.contains(sel.anchorNode)) return;
1818
1919
  this.openFocus(Number(section.dataset.cardIndex));
1819
1920
  });
1820
1921
 
@@ -1849,6 +1950,9 @@
1849
1950
  this.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
1850
1951
  this.syncZoomBar();
1851
1952
  this.placeHeads();
1953
+ // Any local framing takes the view back from the presenter, resize included
1954
+ if (!this._applyingMirror) this._remoteBounds = null;
1955
+ this.emitViewport();
1852
1956
  }
1853
1957
 
1854
1958
  zoomAt(cx, cy, factor) {
@@ -1938,6 +2042,7 @@
1938
2042
  this._slideTimer = setTimeout(() => { this.style.transition = 'none'; }, 300);
1939
2043
  }
1940
2044
  this.syncDots();
2045
+ this.emitFocus();
1941
2046
  }
1942
2047
 
1943
2048
  wireSwipe() {
@@ -2250,6 +2355,9 @@
2250
2355
  this.focusZoom = this.focusFit;
2251
2356
  this._focusFrame = frame;
2252
2357
  this._focusClone = clone;
2358
+ // Mirrored by key, not index: indices shift under deletes, and the two ends may have
2359
+ // applied a different set of them when a snapshot crosses
2360
+ this._focusKey = src.dataset.cardKey;
2253
2361
 
2254
2362
  const close = document.createElement('button');
2255
2363
  close.className = 'cs-focus-close';
@@ -2299,9 +2407,20 @@
2299
2407
  };
2300
2408
  bar.append(prev, counter, next, sep(), zoomOut, this._focusPct, zoomIn, sep(), copy, download);
2301
2409
 
2302
- overlay.append(holder, close, bar);
2410
+ const scrim = document.createElement('div');
2411
+ scrim.className = 'cs-focus-scrim';
2412
+ // The scrim is fixed, so its containing block is the viewport and a wheel over it would
2413
+ // chain to the document rather than the overlay — scrolling a zoomed-in card from the
2414
+ // dark margin is forwarded by hand. Ctrl/⌘ is left to bubble to the zoom handler below.
2415
+ scrim.addEventListener('wheel', (e) => {
2416
+ if (e.ctrlKey || e.metaKey) return;
2417
+ e.preventDefault();
2418
+ overlay.scrollBy(e.deltaX, e.deltaY);
2419
+ }, { passive: false });
2420
+
2421
+ overlay.append(csChrome(scrim), holder, csChrome(close), csChrome(bar));
2303
2422
  overlay.addEventListener('click', (e) => {
2304
- if (e.target === overlay || e.target === holder) this.closeFocus();
2423
+ if (e.target === overlay || e.target === scrim) this.closeFocus();
2305
2424
  });
2306
2425
  // Focus mode's right-click shares the canvas menu
2307
2426
  clone.addEventListener('contextmenu', (e) => {
@@ -2315,7 +2434,9 @@
2315
2434
  this.setFocusZoom(this.focusZoom * Math.exp(-e.deltaY / 300));
2316
2435
  }, { passive: false });
2317
2436
 
2318
- document.body.appendChild(csChrome(overlay));
2437
+ overlay.addEventListener('scroll', () => this.emitFocus(), { passive: true });
2438
+
2439
+ document.body.appendChild(overlay);
2319
2440
  this.overlay = overlay;
2320
2441
  this.applyFocusZoom();
2321
2442
  }
@@ -2331,16 +2452,188 @@
2331
2452
  this._focusFrame.style.height = this.H * this.focusZoom + 'px';
2332
2453
  this._focusClone.style.transform = `scale(${this.focusZoom})`;
2333
2454
  if (this._focusPct) this._focusPct.textContent = Math.round(this.focusZoom * 100) + '%';
2455
+ this.emitFocus();
2334
2456
  }
2335
2457
 
2336
2458
  closeFocus() {
2337
2459
  this.closeContextMenu();
2460
+ const wasOpen = !!this.overlay;
2338
2461
  if (this.overlay) this.overlay.remove();
2339
2462
  this.overlay = null;
2340
2463
  this.focusIndex = null;
2464
+ this._focusKey = null;
2341
2465
  this._focusFrame = null;
2342
2466
  this._focusClone = null;
2343
2467
  this._focusPct = null;
2468
+ if (wasOpen) this.emitFocus();
2469
+ }
2470
+
2471
+ // —— Magic Share (妙享) ——
2472
+ // Three keys, because a late joiner receives only the latest snapshot per key: canvas
2473
+ // framing, the focused card, and deletes / renames never overwrite one another.
2474
+ // Every applier answers 'failed' until init has laid the cards out — that is transient and
2475
+ // only 'failed' makes the SDK retry.
2476
+ wireMirror() {
2477
+ // Capture iframes (?thumbnail= product screenshots, ?export=1) sit out in both directions
2478
+ // — same rule as design-canvas's dcCaptureMode
2479
+ if (/[?&](_snthumb|thumbnail)=|[?&]export=1(&|$)/.test(location.search || '')) return;
2480
+ this._mirrorOn = true;
2481
+ // The applier registry is page-global and outlives this element: once it is taken off the
2482
+ // page it must not answer for (or draw overlays on behalf of) the stage that replaced it
2483
+ const on = (type, fn) => window.__miaodaMirror.onApply(type, (m) =>
2484
+ this.isConnected ? fn(m) : { type: 'ignored', reason: 'stage detached' });
2485
+ on('miaoda:cards:viewport-changed', (m) => this.applyMirrorViewport(m));
2486
+ on('miaoda:cards:focus-changed', (m) => this.applyMirrorFocus(m));
2487
+ on('miaoda:cards:structure-changed', (m) => this.applyMirrorStructure(m));
2488
+ }
2489
+
2490
+ /** Runs a replay so that the emits it triggers are not echoed back as this end's own */
2491
+ asMirror(fn) {
2492
+ this._applyingMirror = true;
2493
+ try { fn(); } finally { this._applyingMirror = false; }
2494
+ }
2495
+
2496
+ // The transform is local screen geometry and cannot be replayed on another window size, so
2497
+ // the world rectangle in view is mirrored instead and each end fits it into its own window
2498
+ // — same scheme as design-canvas's viewport v2.
2499
+ emitViewport() {
2500
+ if (!this._mirrorOn || this._applyingMirror || csIsMobile || this._viewportT) return;
2501
+ // Trailing 120ms: applyTransform runs at pointer rate; reading at fire time loses nothing
2502
+ this._viewportT = setTimeout(() => {
2503
+ this._viewportT = 0;
2504
+ if (!this.isConnected) return;
2505
+ const z = this.zoom;
2506
+ window.__miaodaMirror.emit(
2507
+ { type: 'miaoda:cards:viewport-changed', bounds: { x: -this.panX / z, y: -this.panY / z, width: innerWidth / z, height: innerHeight / z } },
2508
+ { key: 'cards:viewport', throttle: 60 },
2509
+ );
2510
+ }, 120);
2511
+ }
2512
+
2513
+ applyMirrorViewport(m) {
2514
+ // Mobile pages card by card; it follows cards:focus instead
2515
+ if (csIsMobile) return { type: 'ignored', reason: 'mobile follows the focused card' };
2516
+ if (!this._ready) return { type: 'failed', reason: 'not laid out yet' };
2517
+ const b = m.bounds;
2518
+ if (!b || ![b.x, b.y, b.width, b.height].every(Number.isFinite) || !(b.width > 0) || !(b.height > 0)) {
2519
+ return { type: 'ignored', reason: 'bad bounds' };
2520
+ }
2521
+ // A timer armed by a local gesture would fire with the presenter's view and echo it back
2522
+ clearTimeout(this._viewportT);
2523
+ this._viewportT = 0;
2524
+ this.fitBounds(b);
2525
+ return { type: 'applied' };
2526
+ }
2527
+
2528
+ fitBounds(b) {
2529
+ const z = Math.min(MAX_PCT / 100, Math.max(MIN_PCT / 100, Math.min(innerWidth / b.width, innerHeight / b.height)));
2530
+ this.asMirror(() => {
2531
+ this.zoom = z;
2532
+ this.panX = innerWidth / 2 - (b.x + b.width / 2) * z;
2533
+ // Top-anchored: a contain fit's spare height would otherwise become a per-device offset
2534
+ this.panY = -b.y * z;
2535
+ this.applyTransform();
2536
+ });
2537
+ // Kept so a resize on this end refits the presenter's view rather than auto-fitting
2538
+ this._remoteBounds = b;
2539
+ }
2540
+
2541
+ focusSnapshot() {
2542
+ const r3 = (v) => Math.round(v * 1000) / 1000;
2543
+ if (csIsMobile) {
2544
+ const s = this.cardAt(this.page);
2545
+ return { key: s ? s.dataset.cardKey : null, zoom: null, sx: 0, sy: 0 };
2546
+ }
2547
+ const ov = this.overlay;
2548
+ if (!ov || !this._focusKey) return { key: null, zoom: null, sx: 0, sy: 0 };
2549
+ const frac = (pos, range) => (range > 0 ? r3(pos / range) : 0);
2550
+ return {
2551
+ key: this._focusKey,
2552
+ // Relative to the entry fit, not the absolute %: the fit follows each end's window, and
2553
+ // what has to match is how much of the card is in view
2554
+ zoom: r3(this.focusZoom / this.focusFit),
2555
+ sx: frac(ov.scrollLeft, ov.scrollWidth - ov.clientWidth),
2556
+ sy: frac(ov.scrollTop, ov.scrollHeight - ov.clientHeight),
2557
+ };
2558
+ }
2559
+
2560
+ emitFocus() {
2561
+ if (!this._mirrorOn || this._applyingMirror || this._focusT) return;
2562
+ this._focusT = setTimeout(() => {
2563
+ this._focusT = 0;
2564
+ if (!this.isConnected) return;
2565
+ const snap = this.focusSnapshot();
2566
+ // Scroll events from a replay arrive after the replay returns, so the flag cannot catch
2567
+ // them; an unchanged snapshot is that echo
2568
+ const sig = JSON.stringify(snap);
2569
+ if (sig === this._focusSig) return;
2570
+ this._focusSig = sig;
2571
+ window.__miaodaMirror.emit({ type: 'miaoda:cards:focus-changed', ...snap }, { key: 'cards:focus', throttle: 60 });
2572
+ }, 120);
2573
+ }
2574
+
2575
+ applyMirrorFocus(m) {
2576
+ if (!this._ready) return { type: 'failed', reason: 'not laid out yet' };
2577
+ if (m.key !== null && typeof m.key !== 'string') return { type: 'ignored', reason: 'bad key' };
2578
+ const section = m.key === null ? null : this.cards().find((s) => s.dataset.cardKey === m.key);
2579
+ if (m.key !== null && !section) return { type: 'ignored', reason: 'card not present' };
2580
+ clearTimeout(this._focusT);
2581
+ this._focusT = 0;
2582
+ this.asMirror(() => {
2583
+ const n = section ? Number(section.dataset.cardIndex) : null;
2584
+ if (csIsMobile) {
2585
+ if (n) this.goToPage(n);
2586
+ return;
2587
+ }
2588
+ if (!n) { this.closeFocus(); return; }
2589
+ if (this.focusIndex !== n) this.openFocus(n);
2590
+ if (Number.isFinite(m.zoom) && m.zoom > 0) this.setFocusZoom(this.focusFit * m.zoom);
2591
+ const ov = this.overlay;
2592
+ if (Number.isFinite(m.sx)) ov.scrollLeft = m.sx * (ov.scrollWidth - ov.clientWidth);
2593
+ if (Number.isFinite(m.sy)) ov.scrollTop = m.sy * (ov.scrollHeight - ov.clientHeight);
2594
+ });
2595
+ this._focusSig = JSON.stringify(this.focusSnapshot());
2596
+ return { type: 'applied' };
2597
+ }
2598
+
2599
+ emitStructure() {
2600
+ if (!this._mirrorOn || this._applyingMirror) return;
2601
+ window.__miaodaMirror.emit(
2602
+ { type: 'miaoda:cards:structure-changed', srcKey: this.srcKey, hidden: this._hiddenKeys.slice(), labels: { ...this._labelOverrides } },
2603
+ { key: 'cards:structure' },
2604
+ );
2605
+ }
2606
+
2607
+ applyMirrorStructure(m) {
2608
+ if (!this._ready) return { type: 'failed', reason: 'not laid out yet' };
2609
+ if (typeof m.srcKey !== 'string' || !Array.isArray(m.hidden) || !m.labels || typeof m.labels !== 'object') {
2610
+ return { type: 'ignored', reason: 'malformed structure' };
2611
+ }
2612
+ // Built from another version of the source (an agent edit one end has not reloaded): card
2613
+ // keys no longer line up, same rule the sidecar follows
2614
+ if (m.srcKey !== this.srcKey) return { type: 'ignored', reason: 'source mismatch' };
2615
+ // Replayed state is the presenter's, never persisted through this end's write bridge — but
2616
+ // it is folded into this end's state, or this end's next emit (say, a rename) would carry
2617
+ // the pre-replay hidden list and overwrite the latest snapshot late joiners receive.
2618
+ // Hidden is a union: a dropped section cannot come back.
2619
+ for (const key of m.hidden) {
2620
+ if (typeof key === 'string' && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
2621
+ }
2622
+ for (const [key, label] of Object.entries(m.labels)) {
2623
+ if (typeof label === 'string') this._labelOverrides[key] = label;
2624
+ }
2625
+ this.asMirror(() => {
2626
+ for (const section of this.cards()) {
2627
+ const key = section.dataset.cardKey;
2628
+ if (this._hiddenKeys.includes(key)) { this.dropCard(section); continue; }
2629
+ const label = m.labels[key];
2630
+ if (typeof label !== 'string' || label === section.dataset.label) continue;
2631
+ section.dataset.label = label;
2632
+ const head = this.headFor && this.headFor.get(section);
2633
+ if (head) head.querySelector('.cs-label').textContent = label;
2634
+ }
2635
+ });
2636
+ return { type: 'applied' };
2344
2637
  }
2345
2638
 
2346
2639
  }
@@ -14,6 +14,12 @@
14
14
  * On touch devices, tapping the left/right half of the stage goes
15
15
  * prev/next (not in mobile browse mode — see (g)) — taps on links,
16
16
  * buttons and other interactive slide content are left alone.
17
+ * In mobile presenting mode a swipe pages too, in all four directions:
18
+ * swipe left or up for the NEXT slide, right or down for the previous
19
+ * one — the deck moves with the finger, so the gesture is the mirror of
20
+ * the tap zones (tap left = previous), exactly as in Keynote/PPT.
21
+ * Swipes that start on interactive or scrollable slide content are left
22
+ * alone, and browse mode has no swipe — there it is the list's scroll.
17
23
  * (c) press R to reset to slide 0.
18
24
  * (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
19
25
  * scaled with `transform: scale()` to fit the viewport, letterboxed.
@@ -65,7 +71,7 @@
65
71
  * e.detail.total // total slide count
66
72
  * e.detail.slide // the new active slide element
67
73
  * e.detail.previousSlide // the prior slide element, or null on init
68
- * e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
74
+ * e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'swipe' | 'api'
69
75
  * });
70
76
  *
71
77
  * Persistence: none at the deck level. The host app keeps the current slide
@@ -133,9 +139,9 @@
133
139
  // it). Inlined per script rather than shipped as its own registry file:
134
140
  // call sites use the global bare, so inlining is what guarantees
135
141
  // definition-before-use — a separate copy-in file would add a whole "SDK
136
- // copied but <script> tag missing" failure mode. Keep this block identical
137
- // across deck-stage.js / design-canvas.jsx / animations.jsx — same
138
- // convention as the mobile-UA regex. Two halves of
142
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
143
+ // identical (find them by this block's heading) — same convention as the
144
+ // mobile-UA regex. Two halves of
139
145
  // the 妙享 (Magic Share) mirror protocol:
140
146
  // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
141
147
  // message as a broadcastable UI-state snapshot;
@@ -211,6 +217,24 @@
211
217
  const NARROW_MQ = matchMedia('(max-width: 640px)');
212
218
  // Slide-authored controls that should keep a tap instead of it navigating.
213
219
  const INTERACTIVE_SEL = 'a[href], button, input, select, textarea, summary, label, video[controls], audio[controls], [role="button"], [onclick], [tabindex]:not([tabindex^="-"]), [contenteditable]:not([contenteditable="false" i])';
220
+ // Swipe paging thresholds. The travel floor is what separates a swipe from a
221
+ // sloppy tap; the axis ratio makes a diagonal drag do nothing rather than
222
+ // have the deck guess which way the user meant.
223
+ const SWIPE_MIN_PX = 40;
224
+ const SWIPE_AXIS_RATIO = 1.5;
225
+ // A slide-authored scroller owns any gesture that starts inside it — same
226
+ // yield-to-the-slide rule as INTERACTIVE_SEL, one step further out. Slide
227
+ // sections themselves are overflow:hidden, so this only ever matches
228
+ // something the author deliberately made scrollable.
229
+ const isScrollable = (el) => {
230
+ if (!el || el.nodeType !== 1) return false;
231
+ const cs = getComputedStyle(el);
232
+ const scrolls = (v) => v === 'auto' || v === 'scroll';
233
+ return (
234
+ (scrolls(cs.overflowY) && el.scrollHeight > el.clientHeight) ||
235
+ (scrolls(cs.overflowX) && el.scrollWidth > el.clientWidth)
236
+ );
237
+ };
214
238
 
215
239
  /* Custom element families whose thumbnail clones keep their tag instead of
216
240
  * being neutered (see _materialize).
@@ -869,6 +893,8 @@
869
893
  this._onResize = this._onResize.bind(this);
870
894
  this._onSlotChange = this._onSlotChange.bind(this);
871
895
  this._onTap = this._onTap.bind(this);
896
+ this._onTouchStart = this._onTouchStart.bind(this);
897
+ this._onTouchEnd = this._onTouchEnd.bind(this);
872
898
  this._onMessage = this._onMessage.bind(this);
873
899
  this._onHashChange = this._onHashChange.bind(this);
874
900
  // Capture-phase close so a click anywhere dismisses the menu, but
@@ -937,6 +963,10 @@
937
963
  window.addEventListener('hashchange', this._onHashChange);
938
964
  window.addEventListener('click', this._onDocClick, true);
939
965
  this.addEventListener('click', this._onTap);
966
+ // touchend stays non-passive: preventDefault there is what stops the
967
+ // trailing compatibility click from paging a second time.
968
+ this.addEventListener('touchstart', this._onTouchStart, { passive: true });
969
+ this.addEventListener('touchend', this._onTouchEnd, { passive: false });
940
970
  // Print lays every slide out as its own page, so [data-deck-active]-
941
971
  // gated entrance styles need the attribute on every slide (not just
942
972
  // the current one) or their content prints at the hidden base style.
@@ -1369,6 +1399,8 @@
1369
1399
  if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
1370
1400
  if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
1371
1401
  this.removeEventListener('click', this._onTap);
1402
+ this.removeEventListener('touchstart', this._onTouchStart);
1403
+ this.removeEventListener('touchend', this._onTouchEnd);
1372
1404
  if (this._liveTimer) clearTimeout(this._liveTimer);
1373
1405
  if (this._authorCssTimer) clearTimeout(this._authorCssTimer);
1374
1406
  if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
@@ -1897,7 +1929,7 @@
1897
1929
  total: this._slides.length,
1898
1930
  slide: this._slides[curr] || null,
1899
1931
  previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
1900
- reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
1932
+ reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'swipe' | 'api'
1901
1933
  };
1902
1934
  this.dispatchEvent(new CustomEvent('slidechange', {
1903
1935
  detail,
@@ -2241,6 +2273,9 @@
2241
2273
  }
2242
2274
 
2243
2275
  _onTap(e) {
2276
+ // A swipe already paged; the click the browser synthesises after it must
2277
+ // not page again. Set in _onTouchEnd, cleared on the next touchstart.
2278
+ if (this._swipeNav) return;
2244
2279
  // Touch-only for normal browse/edit — on desktop there, keyboard + the
2245
2280
  // overlay toolbar cover nav. But in presenting mode the overlay is hidden
2246
2281
  // and click-to-advance is the expected gesture (Keynote/PPT), so allow it
@@ -2293,6 +2328,62 @@
2293
2328
  return x < 1 / 3 ? 'prev' : x > 2 / 3 ? 'next' : 'menu';
2294
2329
  }
2295
2330
 
2331
+ // Swipe paging, mobile presenting only. The deck travels with the finger:
2332
+ // dragging left/up pulls the next slide in, right/down the previous one.
2333
+ // That is deliberately the mirror of the tap zones (tap left = previous,
2334
+ // a positional target rather than a drag) — the same pair every deck app
2335
+ // ships, so neither half should be "fixed" to match the other.
2336
+ // Browse mode is excluded on purpose — there the vertical gesture is the
2337
+ // card list's own scroll, and a horizontal one has no counterpart.
2338
+ _onTouchStart(e) {
2339
+ // Cleared at the start of every touch rather than after the suppressed
2340
+ // click: a WebView that swallows that click outright would otherwise
2341
+ // leave the flag set and eat the next genuine tap. Resetting here is
2342
+ // also what makes a second finger (pinch) abandon the gesture.
2343
+ this._swipeNav = false;
2344
+ this._swipeFrom = null;
2345
+ if (e.touches.length !== 1) return;
2346
+ if (!this.hasAttribute('data-mobile-present')) return;
2347
+ const path = e.composedPath();
2348
+ if (!this._stage || !path.includes(this._stage)) return;
2349
+ for (const n of path) {
2350
+ if (n === this._stage) break;
2351
+ if (n.matches && n.matches(INTERACTIVE_SEL)) return;
2352
+ if (isScrollable(n)) return;
2353
+ }
2354
+ const t = e.touches[0];
2355
+ this._swipeFrom = { x: t.clientX, y: t.clientY };
2356
+ }
2357
+
2358
+ _onTouchEnd(e) {
2359
+ const from = this._swipeFrom;
2360
+ this._swipeFrom = null;
2361
+ // Another finger still down: this is the tail of a multi-touch gesture,
2362
+ // not a swipe that ended.
2363
+ if (!from || e.touches.length) return;
2364
+ const t = e.changedTouches && e.changedTouches[0];
2365
+ if (!t) return;
2366
+ const dx = t.clientX - from.x;
2367
+ const dy = t.clientY - from.y;
2368
+ // Rotated stage = the visual axes come off the viewport swapped. Reached
2369
+ // through the same predicate as the tap zones, and derived the same way:
2370
+ // _mobPresentZone reads visual x off viewport y, which fixes visual y as
2371
+ // the negated viewport x.
2372
+ const rotated = this._presentRotated();
2373
+ const vx = rotated ? dy : dx;
2374
+ const vy = rotated ? -dx : dy;
2375
+ const major = Math.abs(vx) >= Math.abs(vy) ? vx : vy;
2376
+ const minor = Math.abs(vx) >= Math.abs(vy) ? vy : vx;
2377
+ if (Math.abs(major) < SWIPE_MIN_PX) return;
2378
+ if (Math.abs(major) < Math.abs(minor) * SWIPE_AXIS_RATIO) return;
2379
+ this._swipeNav = true;
2380
+ e.preventDefault();
2381
+ this._advance(major < 0 ? 1 : -1, 'swipe');
2382
+ // Parity with tap: a gesture while the control layer is up restarts its
2383
+ // auto-hide instead of letting it disappear mid-navigation.
2384
+ if (this.hasAttribute('data-mob-ui')) this._toggleMobUi(true);
2385
+ }
2386
+
2296
2387
  _toggleMobUi(force) {
2297
2388
  const wasOpen = this.hasAttribute('data-mob-ui');
2298
2389
  const on = force != null ? !!force : !wasOpen;