@lark-apaas/coding-registry-buildless 0.1.4 → 0.1.5-alpha.20260717102042

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.
@@ -28,6 +28,11 @@
28
28
  // When placing a device frame inside a DCArtboard, pass chromeless to
29
29
  // suppress the card chrome and let the artboard size to its content:
30
30
  // <DCArtboard chromeless><IOSDevice>…</IOSDevice></DCArtboard>
31
+ //
32
+ // On mobile (UA-detected) the canvas is browse-only: one-finger drag pans
33
+ // (starting on artboards too), two-finger pinch zooms, the zoom control is
34
+ // hidden, and drag-reorder / inline-rename are disabled. Tap on an artboard
35
+ // label still opens the focus overlay.
31
36
  /* END USAGE */
32
37
 
33
38
  const DC = {
@@ -40,6 +45,15 @@ const DC = {
40
45
  font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
41
46
  };
42
47
 
48
+ // ── 移动端判定:复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义
49
+ // (跨域产物 iframe 无法 import 该包,同 deck-stage 的先例)。刻意不用视口
50
+ // 断点——桌面工作台的预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
51
+ // 启动时一次性判定,不随 resize 抖动。
52
+ const DC_MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
53
+ const dcIsMobile = typeof navigator !== 'undefined' &&
54
+ (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
55
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
56
+
43
57
  // One-time CSS injection (classes are dc-prefixed so they don't collide with
44
58
  // the hosted design's own styles).
45
59
  if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
@@ -92,6 +106,39 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
92
106
  // can't be reveal-on-hover and tends to stick visible after a tap — hide it
93
107
  // entirely on mobile. The grip + label stay for per-screen context.
94
108
  '@media (hover: none){.dc-btns{display:none}}',
109
+ // Mobile (UA-detected, .dc-mobile on the viewport root) is browse-only:
110
+ // drag-reorder is disabled there, so the grip is dead weight — hide it.
111
+ '.dc-mobile .dc-grip{display:none}',
112
+ // Mobile is browse-only: kill text selection + iOS long-press callout
113
+ // inside the canvas. iOS interprets a drag over selectable TEXT as a
114
+ // selection gesture and steals the pointer (pointercancel) — touch-action
115
+ // only suppresses native pan/zoom, not selection — so text-dense
116
+ // artboards became un-pannable while image-heavy ones panned fine.
117
+ '.dc-mobile, .dc-mobile *{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}',
118
+ // Prototype content routinely carries inner scrollers (the device/window
119
+ // frames all have overflow:auto bodies). On iOS a touch starting inside a
120
+ // scrollable element is consumed by THAT scroller (native scroll attempt
121
+ // + pointercancel), so pans could never start on those artboards while
122
+ // plain-HTML cards panned fine. Mobile canvas is browse-only: kill native
123
+ // touch handling on everything inside the cards so gestures always reach
124
+ // the viewport state machine. Desktop (no .dc-mobile) keeps inner scroll.
125
+ '.dc-mobile [data-dc-slot] *{touch-action:none !important}',
126
+ // Pinch updates --dc-inv-zoom per frame (PC semantics: chrome size AND
127
+ // section spacing stay constant). The per-frame relayout that implies
128
+ // white-flashed on WKWebView when the whole world was one paint layer —
129
+ // every head resize invalidated and re-rasterized ALL content. Promote
130
+ // each card (and head) to its own compositing layer: relayout then only
131
+ // MOVES card layers (compositor, no repaint) and repaints just the tiny
132
+ // head layers. contain:paint bounds invalidation to the card box.
133
+ // contain:paint must live on .dc-card, NOT the slot: .dc-header is an
134
+ // abs-positioned slot child sitting entirely ABOVE the slot box
135
+ // (bottom:100%) — paint containment on the slot would clip it out of
136
+ // rendering AND hit-testing, killing the label tap that is mobile's only
137
+ // focus-overlay entry. will-change (the layer-promotion half of the
138
+ // white-flash fix) stays on the slot so header + card share one layer.
139
+ '.dc-mobile [data-dc-slot]{will-change:transform}',
140
+ '.dc-mobile .dc-card{contain:paint}',
141
+ '.dc-mobile .dc-sectionhead{will-change:transform}',
95
142
  '[data-dc-slot]:hover .dc-card.chromeless>*, [data-dc-slot]:hover .dc-card:not(.chromeless){box-shadow:0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)}',
96
143
  '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;',
97
144
  ' background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;',
@@ -127,7 +174,13 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
127
174
  // space gap and overlap the artboard headers at low zoom.
128
175
  '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
129
176
  ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left;will-change:transform}',
130
- '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
177
+ // Section heads counter-scale via calc()'d font metrics (see DCSection),
178
+ // NOT CSS zoom: zoom was only standardized in 2024 and legacy WebKit
179
+ // builds (older iOS WKWebViews) mis-apply it — text renders unscaled
180
+ // while layout inflates, tearing the title/subtitle apart at small
181
+ // canvas scales. calc(px * var()) lengths are interop-safe everywhere
182
+ // and keep the same in-flow layout semantics (box grows with the text,
183
+ // space below stays reserved).
131
184
  // On-canvas zoom control (bottom-right pill). Stays screen-fixed because
132
185
  // it renders outside worldRef. z-index 50 keeps it above world content but
133
186
  // below the focus overlay (z-index 100). The dropdown reuses .dc-menu and
@@ -439,7 +492,10 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
439
492
  el.style.setProperty('--dc-inv-zoom', String(1 / scale));
440
493
  // Keep the on-canvas zoom control's % readout in sync. React bails out on
441
494
  // an unchanged rounded value, so pan ticks (scale unchanged) don't render.
442
- setScalePct(Math.round(scale * 100));
495
+ // Mobile renders no zoom control — a per-frame setState during pinch
496
+ // would re-render the whole subtree every frame (visible repaint flicker
497
+ // on phones) for a readout nobody consumes, so skip it there entirely.
498
+ if (!dcIsMobile) setScalePct(Math.round(scale * 100));
443
499
  // Keep the host toolbar's % readout in sync with the canvas scale. Pan
444
500
  // ticks leave scale unchanged — skip the cross-frame post for those.
445
501
  if (lastPostedScale.current !== scale) {
@@ -490,7 +546,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
490
546
  const slots = world.querySelectorAll('[data-dc-slot]');
491
547
  if (!slots.length) return; // content not mounted yet — wait for the ready commit
492
548
  const heads = world.querySelectorAll('.dc-sectionhead');
493
- const PAD = 64;
549
+ // Fit margin from the screen edges. Mobile screens are narrow — 64px a
550
+ // side eats a third of the width, so fit flush-ish at 16px there.
551
+ const PAD = dcIsMobile ? 16 : 64;
494
552
 
495
553
  // Content bounding box in WORLD space at the currently applied transform.
496
554
  // X extent from cards only (section-head blocks are full width and would
@@ -556,8 +614,8 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
556
614
  const t = tf.current;
557
615
  const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
558
616
  const k = next / t.scale;
559
- // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
560
- // marginBottom) reflow on every scale change, vertically shifting the
617
+ // --dc-inv-zoom consumers (.dc-sectionhead's calc()'d font metrics,
618
+ // each section's marginBottom) reflow on every scale change, vertically shifting the
561
619
  // world layout — so a world point mathematically pinned under the cursor
562
620
  // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
563
621
  // Anchor the DOM element under the cursor instead: record its screen Y,
@@ -630,11 +688,18 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
630
688
  // better feel there. No-ops on other browsers. Safari also fires
631
689
  // ctrlKey wheel events during the same pinch — isGesturing makes
632
690
  // onWheel drop those entirely so they neither zoom nor pan.
691
+ //
692
+ // On mobile the pointer-based two-finger pinch below is the single zoom
693
+ // source. iOS fires BOTH pointer events and these WebKit gesture* events
694
+ // for the same two fingers — zooming from both would double-apply, so on
695
+ // mobile gesture* degrades to preventDefault only (still suppressing the
696
+ // native page zoom).
633
697
  let gsBase = 1;
634
698
  let isGesturing = false;
635
699
  const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
636
700
  const onGestureChange = (e) => {
637
701
  e.preventDefault();
702
+ if (dcIsMobile) return;
638
703
  zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
639
704
  };
640
705
  const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
@@ -664,6 +729,108 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
664
729
  vp.style.cursor = '';
665
730
  };
666
731
 
732
+ // ── Mobile touch gestures (replaces the desktop pointer mapping above):
733
+ // one-finger drag pans — starting on artboards too, since cards fill a
734
+ // phone viewport and background is scarce; two-finger pinch zooms about
735
+ // the midpoint while the midpoint's own travel pans (Figma semantics, one
736
+ // gesture frames the view). A tap/pan slop keeps label taps working: no
737
+ // capture happens within the slop, so a clean tap still clicks through to
738
+ // focus; past it the viewport captures the pointer and the click retargets
739
+ // harmlessly. No fling/inertia — release stops the canvas (spec §8).
740
+ const touches = new Map(); // pointerId -> last {x, y}
741
+ let mobMode = null; // null | 'tap' | 'pan' | 'pinch'
742
+ let tapStart = null; // down point for slop arbitration
743
+ let pinchDist = 0; // last-applied finger distance (rAF 帧粒度)
744
+ let pinchMid = null; // last-applied midpoint
745
+ let panApplied = null; // 单指 pan 最近一次已应用的触点位置
746
+ const MOB_SLOP = 9; // px of travel before a tap becomes a pan
747
+
748
+ // First two insertion-ordered touches define the pinch; a third finger is
749
+ // tracked but inert (its moves shift neither midpoint nor distance).
750
+ const pinchGeom = () => {
751
+ const [a, b] = [...touches.values()];
752
+ return { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
753
+ };
754
+
755
+ const onTouchDown = (e) => {
756
+ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
757
+ if (touches.size === 1) {
758
+ mobMode = 'tap';
759
+ tapStart = { x: e.clientX, y: e.clientY };
760
+ } else if (touches.size === 2) {
761
+ mobFlushNow(); // 单指 pan 有待应用的帧则先落账,再切 pinch 基准
762
+ mobMode = 'pinch';
763
+ const g = pinchGeom();
764
+ pinchDist = g.d;
765
+ pinchMid = { x: g.mx, y: g.my };
766
+ try { vp.setPointerCapture(e.pointerId); } catch {}
767
+ }
768
+ };
769
+ // ── rAF 合帧:move 事件只更新触点坐标,几何应用每帧至多一次。
770
+ // 高刷屏/快速捏合时 pointermove 频率可远超渲染帧率,逐事件全量跑
771
+ // zoomAt/apply(布局读写 + 样式失效 + postMessage)会以超过引擎回收
772
+ // 节奏的速度制造 WebKit 堆垃圾——真机快速捏合表现为内存持续攀升
773
+ // (vmmap 定位在 WebKit Malloc;帧率化应用后实测完全回收)。
774
+ let mobRaf = 0;
775
+ const mobFlushFrame = () => {
776
+ mobRaf = 0;
777
+ if (mobMode === 'pan') {
778
+ const [t] = [...touches.values()];
779
+ if (!t) return;
780
+ tf.current.x += t.x - panApplied.x;
781
+ tf.current.y += t.y - panApplied.y;
782
+ panApplied = { x: t.x, y: t.y };
783
+ apply();
784
+ } else if (mobMode === 'pinch' && touches.size >= 2) {
785
+ const g = pinchGeom();
786
+ tf.current.x += g.mx - pinchMid.x;
787
+ tf.current.y += g.my - pinchMid.y;
788
+ pinchMid = { x: g.mx, y: g.my };
789
+ // zoomAt applies the pan mutation above too; degenerate distances
790
+ // (fingers together) skip the zoom but still need the pan applied.
791
+ // Same full zoomAt as the desktop trackpad-pinch path (gesturechange):
792
+ // per-frame --dc-inv-zoom keeps chrome size AND section spacing
793
+ // constant throughout the gesture, with the same drift compensation.
794
+ if (pinchDist > 0 && g.d > 0) zoomAt(g.mx, g.my, g.d / pinchDist);
795
+ else apply();
796
+ pinchDist = g.d;
797
+ }
798
+ };
799
+ const mobSchedule = () => { if (!mobRaf) mobRaf = requestAnimationFrame(mobFlushFrame); };
800
+ const mobFlushNow = () => { if (mobRaf) { cancelAnimationFrame(mobRaf); mobFlushFrame(); } };
801
+
802
+ const onTouchMove = (e) => {
803
+ const t = touches.get(e.pointerId);
804
+ if (!t) return;
805
+ t.x = e.clientX; t.y = e.clientY;
806
+ if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
807
+ mobMode = 'pan';
808
+ panApplied = { x: t.x, y: t.y };
809
+ try { vp.setPointerCapture(e.pointerId); } catch {}
810
+ }
811
+ if (mobMode === 'pan' || mobMode === 'pinch') mobSchedule();
812
+ };
813
+ const onTouchUp = (e) => {
814
+ mobFlushNow(); // 抬指前把最后一个待应用帧落账,避免尾部位移丢失
815
+ if (!touches.delete(e.pointerId)) return;
816
+ try { vp.releasePointerCapture(e.pointerId); } catch {}
817
+ if (touches.size === 0) {
818
+ mobMode = null;
819
+ } else if (touches.size === 1) {
820
+ // Pinch collapsing to one finger hands off to pan with the surviving
821
+ // finger as the new baseline — no jump.
822
+ mobMode = 'pan';
823
+ const [t] = [...touches.values()];
824
+ panApplied = { x: t.x, y: t.y };
825
+ } else if (mobMode === 'pinch') {
826
+ // 3+ fingers losing one: re-baseline so the new leading pair doesn't
827
+ // read as a sudden midpoint/distance delta.
828
+ const g = pinchGeom();
829
+ pinchDist = g.d;
830
+ pinchMid = { x: g.mx, y: g.my };
831
+ }
832
+ };
833
+
667
834
  // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
668
835
  // visible midpoint stays fixed — matching the host's iframe-zoom feel.
669
836
  const onHostMsg = (e) => {
@@ -694,31 +861,38 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
694
861
  lastPostedScale.current = undefined;
695
862
  apply();
696
863
 
864
+ // Mobile swaps the desktop pointer mapping (bg-only pan) for the touch
865
+ // state machine; wheel/gesture listeners stay on both (harmless without
866
+ // the hardware, and a bluetooth mouse on Android still pans via wheel).
867
+ const pd = dcIsMobile ? onTouchDown : onPointerDown;
868
+ const pm = dcIsMobile ? onTouchMove : onPointerMove;
869
+ const pu = dcIsMobile ? onTouchUp : onPointerUp;
697
870
  vp.addEventListener('wheel', onWheel, { passive: false });
698
871
  vp.addEventListener('gesturestart', onGestureStart, { passive: false });
699
872
  vp.addEventListener('gesturechange', onGestureChange, { passive: false });
700
873
  vp.addEventListener('gestureend', onGestureEnd, { passive: false });
701
- vp.addEventListener('pointerdown', onPointerDown);
702
- vp.addEventListener('pointermove', onPointerMove);
703
- vp.addEventListener('pointerup', onPointerUp);
704
- vp.addEventListener('pointercancel', onPointerUp);
874
+ vp.addEventListener('pointerdown', pd);
875
+ vp.addEventListener('pointermove', pm);
876
+ vp.addEventListener('pointerup', pu);
877
+ vp.addEventListener('pointercancel', pu);
705
878
  return () => {
706
879
  window.removeEventListener('message', onHostMsg);
707
880
  vp.removeEventListener('wheel', onWheel);
708
881
  vp.removeEventListener('gesturestart', onGestureStart);
709
882
  vp.removeEventListener('gesturechange', onGestureChange);
710
883
  vp.removeEventListener('gestureend', onGestureEnd);
711
- vp.removeEventListener('pointerdown', onPointerDown);
712
- vp.removeEventListener('pointermove', onPointerMove);
713
- vp.removeEventListener('pointerup', onPointerUp);
714
- vp.removeEventListener('pointercancel', onPointerUp);
884
+ vp.removeEventListener('pointerdown', pd);
885
+ vp.removeEventListener('pointermove', pm);
886
+ vp.removeEventListener('pointerup', pu);
887
+ vp.removeEventListener('pointercancel', pu);
888
+ if (mobRaf) cancelAnimationFrame(mobRaf);
715
889
  };
716
890
  }, [apply, minScale, maxScale]);
717
891
 
718
892
  return (
719
893
  <div
720
894
  ref={vpRef}
721
- className="design-canvas"
895
+ className={dcIsMobile ? 'design-canvas dc-mobile' : 'design-canvas'}
722
896
  style={{
723
897
  height: '100vh', width: '100vw',
724
898
  background: DC.bg,
@@ -744,12 +918,16 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
744
918
  >
745
919
  {children}
746
920
  </div>
747
- <DCZoomControl
748
- scalePct={scalePct}
749
- minScale={minScale}
750
- maxScale={maxScale}
751
- onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
752
- />
921
+ {/* Mobile zooms by pinch — the control is not rendered at all (host
922
+ set-zoom messages still work; the protocol is independent of it). */}
923
+ {!dcIsMobile && (
924
+ <DCZoomControl
925
+ scalePct={scalePct}
926
+ minScale={minScale}
927
+ maxScale={maxScale}
928
+ onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
929
+ />
930
+ )}
753
931
  </div>
754
932
  );
755
933
  }
@@ -788,11 +966,16 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
788
966
  <div data-dc-section={sid}
789
967
  style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
790
968
  <div style={{ padding: '0 60px' }}>
791
- <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
969
+ <div className="dc-sectionhead" style={{ paddingBottom: 'calc(36px * var(--dc-inv-zoom, 1))' }}>
792
970
  <DCEditable tag="div" value={sec.title ?? title}
793
971
  onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
794
- style={{ fontSize: 20, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 2, display: 'inline-block', lineHeight: '28px' }} />
795
- {subtitle && <div style={{ fontSize: 14, color: DC.subtitle, lineHeight: '22px' }}>{subtitle}</div>}
972
+ style={{
973
+ fontSize: 'calc(20px * var(--dc-inv-zoom, 1))', fontWeight: 600, color: DC.title,
974
+ letterSpacing: 'calc(-0.4px * var(--dc-inv-zoom, 1))',
975
+ marginBottom: 'calc(2px * var(--dc-inv-zoom, 1))',
976
+ display: 'inline-block', lineHeight: 'calc(28px * var(--dc-inv-zoom, 1))',
977
+ }} />
978
+ {subtitle && <div style={{ fontSize: 'calc(14px * var(--dc-inv-zoom, 1))', color: DC.subtitle, lineHeight: 'calc(22px * var(--dc-inv-zoom, 1))' }}>{subtitle}</div>}
796
979
  </div>
797
980
  </div>
798
981
  <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
@@ -1035,14 +1218,17 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
1035
1218
 
1036
1219
  return (
1037
1220
  <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
1038
- <div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
1221
+ {/* Desktop stops pointerdown so header interactions never start a
1222
+ bg-pan; mobile pans FROM chrome too (slop arbitration keeps label
1223
+ taps working), so the event must bubble to the viewport there. */}
1224
+ <div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={dcIsMobile ? undefined : (e) => e.stopPropagation()}>
1039
1225
  <div className="dc-labelrow">
1040
- <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
1226
+ <div className="dc-grip" onPointerDown={dcIsMobile ? undefined : onGripDown} title="Drag to reorder">
1041
1227
  <svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
1042
1228
  </div>
1043
1229
  <div className="dc-labeltext" onClick={onFocus} title="Click to focus">
1044
1230
  <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
1045
- style={{ fontSize: 14, fontWeight: 300, color: DC.label, lineHeight: '22px' }} />
1231
+ style={{ fontSize: 14, fontWeight: 400, color: DC.label, lineHeight: '22px' }} />
1046
1232
  </div>
1047
1233
  </div>
1048
1234
  <div className="dc-btns">
@@ -1086,6 +1272,13 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
1086
1272
  // Inline rename — commits on blur or Enter.
1087
1273
  function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
1088
1274
  const T = tag;
1275
+ // Mobile is browse-only: render plain text — no contentEditable (a tap
1276
+ // would pop the soft keyboard), no stopPropagation (pans start on labels
1277
+ // too), and no onClick swallow (desktop uses it to keep an edit-click from
1278
+ // bubbling; on mobile the tap should bubble so a label tap opens focus).
1279
+ if (dcIsMobile) {
1280
+ return <T className="dc-editable" style={style}>{value}</T>;
1281
+ }
1089
1282
  return (
1090
1283
  <T className="dc-editable" contentEditable suppressContentEditableWarning
1091
1284
  onClick={onClick}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-registry-buildless",
3
- "version": "0.1.4",
3
+ "version": "0.1.5-alpha.20260717102042",
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": [
@@ -12,8 +12,9 @@
12
12
  * hidden while presenting / in preview / on narrow / noscale / no-rail.
13
13
  * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
14
14
  * On touch devices, tapping the left/right half of the stage goes
15
- * prev/next — taps on links, buttons and other interactive slide
16
- * content are left alone.
15
+ * prev/next (desktop / presenting mode; mobile browse mode scrolls
16
+ * instead — see (g)) — taps on links, buttons and other interactive
17
+ * slide content are left alone.
17
18
  * (c) press R to reset to slide 0.
18
19
  * (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
19
20
  * scaled with `transform: scale()` to fit the viewport, letterboxed.
@@ -34,6 +35,18 @@
34
35
  * (≤640px), and via the `no-rail` attribute. Rail mutations dispatch
35
36
  * a `deckchange`
36
37
  * CustomEvent on the element: detail = {action, from, to, slide}.
38
+ * (g) mobile — on mobile UA (mirrors the host platform's isMobile()
39
+ * semantics: UA regex + iPad maxTouchPoints, never viewport width),
40
+ * browse mode renders slides as a vertical card stream (12px margin,
41
+ * 8px gap, 8px radius) with native scrolling; a per-card page badge
42
+ * fades in while scrolling (16|20×12, ink 20% bg, fixed 8px digits)
43
+ * and fades out 2s after scrolling stops. Presenting mode
44
+ * (miaoda:deck:presenting) rotates the stage 90° (CSS pseudo-
45
+ * landscape, no orientation.lock): tap left/right third to navigate,
46
+ * tap center to toggle the controls overlay (back button +
47
+ * filmstrip; auto-hides after 3s). The back button exits presenting
48
+ * and posts miaoda:deck:presenting-dismissed to the parent.
49
+ * Desktop behaviour is unchanged.
37
50
  *
38
51
  * Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
39
52
  * `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
@@ -116,6 +129,21 @@
116
129
  // Slide-authored controls that should keep a tap instead of it navigating.
117
130
  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])';
118
131
 
132
+ // ── 移动端(复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义;
133
+ // 跨域产物 iframe 无法 import 该包)。刻意不用视口断点:桌面工作台的
134
+ // 预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
135
+ // mode=desktop 逃生阀只作用于壳层自身 location、不透传到本 iframe,不实现。
136
+ const MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
137
+ const isMobileUA = () =>
138
+ MOBILE_UA_RE.test(navigator.userAgent) ||
139
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
140
+ // 移动端视觉常量(Figma node 2965:32138 / 2965:32047 / 2965:34386 已确认)
141
+ const MOB_MARGIN = 12; // 竖排卡片页边距
142
+ const MOB_GAP = 8; // 卡片间距
143
+ const MOB_RADIUS = 8; // 卡片圆角
144
+ const MOB_BADGE_IDLE_MS = 2000; // 滚动停止 → 徽标淡出(UX 确认 2s)
145
+ const MOB_UI_HIDE_MS = 3000; // 播放控件层自动收起
146
+
119
147
  const pad2 = (n) => String(n).padStart(2, '0');
120
148
 
121
149
  // Label precedence: data-label → data-screen-label (number stripped) → first heading → "Slide".
@@ -380,6 +408,143 @@
380
408
  .rail[data-presenting] + .rail-resize,
381
409
  .rail[data-user-hidden] + .rail-resize { display: none; }
382
410
 
411
+ /* ── 移动端竖排浏览 ─────────────────────────────────────── */
412
+ :host([data-mobile-list]) .stage {
413
+ display: block;
414
+ overflow-y: auto;
415
+ -webkit-overflow-scrolling: touch;
416
+ background: #F8F9FA;
417
+ }
418
+ :host([data-mobile-list]) .canvas {
419
+ transform: none !important;
420
+ background: transparent;
421
+ will-change: auto;
422
+ }
423
+ :host([data-mobile-list][data-windowed]) .canvas { border: none; box-shadow: none; }
424
+ :host([data-mobile-list]) .rail, :host([data-mobile-list]) .rail-resize { display: none; }
425
+ :host([data-mobile-list]) ::slotted(*) {
426
+ opacity: 1;
427
+ visibility: visible;
428
+ pointer-events: auto;
429
+ transform-origin: 0 0;
430
+ box-sizing: border-box;
431
+ }
432
+ /* Skipped slides get no nth-child placement rule from _fitMobileList —
433
+ without this they'd fall back to the base inset:0 stacking rule and,
434
+ now force-visible, paint over the whole list canvas and swallow taps.
435
+ (The equivalent rule further down lives inside @media print only.) */
436
+ :host([data-mobile-list]) ::slotted([data-deck-skip]) { display: none !important; }
437
+
438
+ /* ── 移动端播放:CSS 旋转伪横屏(不依赖 orientation.lock)────
439
+ 只在设备物理竖屏时旋转;用户顺势把手机转成横屏(系统自动旋转)
440
+ 后,视口本身已是横屏,再叠 90° 会让画面侧躺——此时退化为常规
441
+ full-bleed 适配,_fitMobilePresent/_mobPresentZone 同步按
442
+ orientation 分支换轴。 */
443
+ :host([data-mobile-present]) .stage { background: #000; }
444
+ @media (orientation: portrait) {
445
+ :host([data-mobile-present]) .stage {
446
+ inset: auto;
447
+ left: 50%;
448
+ top: 50%;
449
+ width: 100vh;
450
+ height: 100vw;
451
+ transform: translate(-50%, -50%) rotate(90deg);
452
+ }
453
+ }
454
+
455
+ /* ── 移动端页码徽标(规格:Figma 2965:32047)────────────── */
456
+ .mob-badges { display: none; }
457
+ :host([data-mobile-list]) .mob-badges {
458
+ display: block;
459
+ position: absolute;
460
+ inset: 0;
461
+ pointer-events: none;
462
+ z-index: 10;
463
+ }
464
+ .mob-badge {
465
+ position: absolute;
466
+ height: 12px;
467
+ box-sizing: border-box;
468
+ padding: 0 2px;
469
+ border-radius: 3px;
470
+ background: rgba(31, 35, 41, 0.2);
471
+ color: #fff;
472
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
473
+ font-size: 8px;
474
+ line-height: 12px;
475
+ text-align: center;
476
+ opacity: 0;
477
+ transition: opacity 0.1s linear;
478
+ }
479
+ :host([data-mob-scrolling]) .mob-badge { opacity: 1; }
480
+
481
+ /* ── 移动端播放控件层(规格:Figma 2965:34386)──────────── */
482
+ .mob-ui { display: none; }
483
+ :host([data-mobile-present][data-mob-ui]) .mob-ui {
484
+ display: block;
485
+ position: absolute;
486
+ inset: 0;
487
+ pointer-events: none;
488
+ z-index: 20;
489
+ }
490
+ .mob-back {
491
+ position: absolute;
492
+ left: max(16px, env(safe-area-inset-left));
493
+ top: 12px;
494
+ pointer-events: auto;
495
+ display: flex;
496
+ align-items: center;
497
+ gap: 6px;
498
+ border: none;
499
+ border-radius: 999px;
500
+ padding: 6px 14px;
501
+ background: rgba(0, 0, 0, 0.4);
502
+ color: #fff;
503
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
504
+ font-size: 15px;
505
+ font-weight: 500;
506
+ cursor: pointer;
507
+ }
508
+ .mob-strip {
509
+ position: absolute;
510
+ left: 0;
511
+ right: 0;
512
+ bottom: 0;
513
+ pointer-events: auto;
514
+ display: flex;
515
+ gap: 8px;
516
+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
517
+ /* 透明浮在 slide 上(验收对齐设计稿)——缩略图自带白底+描边已足够区分 */
518
+ background: transparent;
519
+ overflow-x: auto;
520
+ }
521
+ .mob-strip .mthumb { flex: 0 0 132px; cursor: pointer; }
522
+ .mob-strip .mframe {
523
+ width: 132px;
524
+ height: 72px;
525
+ border-radius: 8px;
526
+ border: 0.5px solid #DEE0E3;
527
+ overflow: hidden;
528
+ position: relative;
529
+ background: #fff;
530
+ }
531
+ .mob-strip .mthumb[data-current] .mframe {
532
+ outline: 1px solid #336DF4;
533
+ outline-offset: 2px;
534
+ border-radius: 10px;
535
+ }
536
+ .mob-strip .mnum {
537
+ margin-top: 2px;
538
+ text-align: center;
539
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
540
+ font-size: 12px;
541
+ line-height: 20px;
542
+ font-weight: 500;
543
+ /* 条带透明、底衬通常是播放态黑底/slide 内容——页码用白色(对齐设计稿) */
544
+ color: rgba(255, 255, 255, 0.9);
545
+ }
546
+ .mob-strip .mthumb[data-current] .mnum { color: #336DF4; }
547
+
383
548
  /* ── Speaker-notes dock ──────────────────────────────────────────────
384
549
  Bottom panel spanning the non-rail width in edit mode: a resize grip
385
550
  (drag to set height, persisted to localStorage) + an editable textarea
@@ -538,6 +703,8 @@
538
703
  // windowed margin/shadow, see _fit. A bare load with no #N hash starts at
539
704
  // slide 1, so ?thumbnail=1 alone yields a clean first-slide frame.
540
705
  this._snthumb = /[?&](_snthumb|thumbnail)=/.test(location.search);
706
+ this._mobile = isMobileUA();
707
+ this._syncMobileMode();
541
708
  if (this._snthumb) this.setAttribute('no-rail', '');
542
709
  this._render();
543
710
  this._loadNotes();
@@ -830,6 +997,9 @@
830
997
  if (this._tweakTimer) clearTimeout(this._tweakTimer);
831
998
  if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
832
999
  if (this._notesPersistTimer) clearTimeout(this._notesPersistTimer);
1000
+ clearTimeout(this._mobUiTimer);
1001
+ clearTimeout(this._mobScrollIdleTimer);
1002
+ if (this._mobScrollRaf) { cancelAnimationFrame(this._mobScrollRaf); this._mobScrollRaf = null; }
833
1003
  if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
834
1004
  if (this._liveObserver) this._liveObserver.disconnect();
835
1005
  if (this._railObserver) this._railObserver.disconnect();
@@ -845,6 +1015,7 @@
845
1015
  if (this._rail) {
846
1016
  this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
847
1017
  }
1018
+ this._syncMobileMode();
848
1019
  this._fit();
849
1020
  this._scaleThumbs();
850
1021
  this._syncPrintPageRule();
@@ -870,6 +1041,17 @@
870
1041
  canvas.appendChild(slot);
871
1042
  stage.appendChild(canvas);
872
1043
 
1044
+ this._onMobScroll = this._onMobScroll.bind(this);
1045
+ stage.addEventListener('scroll', this._onMobScroll, { passive: true });
1046
+
1047
+ // 移动竖排的逐页定位规则(::slotted(:nth-child(i))),_fitMobileList 重建。
1048
+ this._mobStyle = document.createElement('style');
1049
+
1050
+ this._mobBadges = document.createElement('div');
1051
+ this._mobBadges.className = 'mob-badges export-hidden';
1052
+ this._mobBadges.setAttribute('data-miaoda-chrome', '');
1053
+ canvas.appendChild(this._mobBadges);
1054
+
873
1055
  // Thumbnail rail + context menu. Thumbnails are populated in
874
1056
  // _renderRail() after _collectSlides().
875
1057
  const rail = document.createElement('div');
@@ -1010,7 +1192,7 @@
1010
1192
  // the note instead of navigating slides (same trick as the thumb keydown).
1011
1193
  notesBody.addEventListener('keydown', (e) => e.stopPropagation());
1012
1194
 
1013
- this._root.append(style, rail, resize, stage, notes, menu);
1195
+ this._root.append(style, this._mobStyle, rail, resize, stage, notes, menu);
1014
1196
  this._canvas = canvas;
1015
1197
  this._stage = stage;
1016
1198
  this._slot = slot;
@@ -1285,9 +1467,156 @@
1285
1467
  }));
1286
1468
  }
1287
1469
 
1470
+ if (this.hasAttribute('data-mob-ui')) this._syncMobStrip();
1471
+
1288
1472
  this._prevIndex = curr;
1289
1473
  }
1290
1474
 
1475
+ // 移动端两种呈现互斥:竖排浏览(list)/ 旋转横屏播放(present)。
1476
+ // noscale / _snthumb(导出与演讲者缩略图捕获)必须保持桌面几何。
1477
+ _syncMobileMode() {
1478
+ const eligible = this._mobile && !this.hasAttribute('noscale') && !this._snthumb;
1479
+ this.toggleAttribute('data-mobile-list', eligible && !this._presenting);
1480
+ this.toggleAttribute('data-mobile-present', eligible && !!this._presenting);
1481
+ }
1482
+
1483
+ _fitMobileList() {
1484
+ const dw = this.designWidth, dh = this.designHeight;
1485
+ const vw = window.innerWidth;
1486
+ this._mobLastVw = vw; // _onResize 高度变化跳过的基线
1487
+ const stage = this._canvas.parentElement;
1488
+ // 清掉桌面 _fit 写过的 inset 内联值
1489
+ if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1490
+ this.removeAttribute('data-windowed');
1491
+ const cardW = vw - 2 * MOB_MARGIN;
1492
+ const s = cardW / dw;
1493
+ const cardH = dh * s;
1494
+ const kids = Array.prototype.slice.call(this.children);
1495
+ const rules = [];
1496
+ const cards = [];
1497
+ let y = MOB_MARGIN;
1498
+ this._slides.forEach((slide, i) => {
1499
+ if (slide.hasAttribute('data-deck-skip')) return;
1500
+ const nth = kids.indexOf(slide) + 1;
1501
+ if (nth <= 0) return;
1502
+ rules.push(
1503
+ ':host([data-mobile-list]) ::slotted(:nth-child(' + nth + ')) {' +
1504
+ ' inset: auto !important; top: 0 !important; left: 0 !important;' +
1505
+ ' width: ' + dw + 'px !important; height: ' + dh + 'px !important;' +
1506
+ ' transform: translate(' + MOB_MARGIN + 'px, ' + y + 'px) scale(' + s + ') !important;' +
1507
+ // 圆角/描边随 scale 反向放大,落地后视觉为 8px / 0.5px
1508
+ ' border-radius: ' + (MOB_RADIUS / s) + 'px; overflow: hidden !important;' +
1509
+ ' border: ' + (0.5 / s) + 'px solid #DEE0E3; }'
1510
+ );
1511
+ cards.push({ slide, i, y, h: cardH });
1512
+ y += cardH + MOB_GAP;
1513
+ });
1514
+ // 竖排规则只作用于屏幕媒体——print 的 ::slotted 分页规则特异性更低,
1515
+ // 否则每页会带着列表位移/缩放进入打印流。
1516
+ this._mobStyle.textContent = '@media screen {\n' + rules.join('\n') + '\n}';
1517
+ this._canvas.style.transform = 'none';
1518
+ this._canvas.style.width = vw + 'px';
1519
+ this._canvas.style.height = Math.max(y - MOB_GAP + MOB_MARGIN, 0) + 'px';
1520
+ this._mobCards = cards;
1521
+ this._mobScale = s;
1522
+
1523
+ // 徽标:每卡片左下角内 6px;宽度按位数 16/16/20。布局是确定性的,
1524
+ // 直接按 _mobCards 几何摆放,无需 IntersectionObserver。
1525
+ this._mobBadges.textContent = '';
1526
+ cards.forEach((c, ordinal) => {
1527
+ const b = document.createElement('div');
1528
+ b.className = 'mob-badge';
1529
+ const n = ordinal + 1;
1530
+ b.textContent = String(n);
1531
+ b.style.width = (String(n).length >= 3 ? 20 : 16) + 'px';
1532
+ b.style.left = (MOB_MARGIN + 6) + 'px';
1533
+ b.style.top = (c.y + c.h - 6 - 12) + 'px';
1534
+ this._mobBadges.appendChild(b);
1535
+ });
1536
+
1537
+ this._mobScrollTo(this._index, false);
1538
+ }
1539
+
1540
+ _onMobScroll() {
1541
+ if (!this.hasAttribute('data-mobile-list') || !this._mobCards || !this._mobCards.length) return;
1542
+
1543
+ // 徽标显隐:程序化滚动也算"滚动",两种来源都给可见性反馈。
1544
+ this.setAttribute('data-mob-scrolling', '');
1545
+ clearTimeout(this._mobScrollIdleTimer);
1546
+ this._mobScrollIdleTimer = setTimeout(
1547
+ () => this.removeAttribute('data-mob-scrolling'), MOB_BADGE_IDLE_MS);
1548
+
1549
+ if (this._mobScrollRaf) return;
1550
+ this._mobScrollRaf = requestAnimationFrame(() => {
1551
+ this._mobScrollRaf = null;
1552
+ const stage = this._canvas.parentElement;
1553
+ const top = stage.scrollTop;
1554
+ const moved = this._mobLastTop == null ? Infinity : Math.abs(top - this._mobLastTop);
1555
+ this._mobLastTop = top; // 抑制期间也持续跟踪位置
1556
+ if (this._mobProgUntil && performance.now() < this._mobProgUntil) {
1557
+ // 程序化滚动事件流仍在到达:续期窗口,动画多长都盖得住;
1558
+ // 事件停止 120ms 后窗口自然过期,之后才恢复用户滚动反推。
1559
+ this._mobProgUntil = Math.max(this._mobProgUntil, performance.now() + 120);
1560
+ return;
1561
+ }
1562
+ if (moved < 1) return; // 无真实位移(合成事件/终点回调)不反推
1563
+ // 纯视口中心公式在卡片高 < 半视口时到不了首/末页(卡片高 ~= 0.5625 * 宽,
1564
+ // 竖屏视口高通常远大于宽的一半),两端改用 clamp,中段维持视口中心语义。
1565
+ const max = stage.scrollHeight - stage.clientHeight;
1566
+ let cur;
1567
+ if (max <= 0 || stage.scrollTop <= 1) {
1568
+ cur = this._mobCards[0];
1569
+ } else if (stage.scrollTop >= max - 1) {
1570
+ cur = this._mobCards[this._mobCards.length - 1];
1571
+ } else {
1572
+ const center = stage.scrollTop + stage.clientHeight / 2;
1573
+ cur = this._mobCards[0];
1574
+ for (const c of this._mobCards) {
1575
+ if (center >= c.y) cur = c; else break;
1576
+ }
1577
+ }
1578
+ if (cur.i !== this._index) {
1579
+ this._index = cur.i;
1580
+ // 广播走现有内核:hash 同步 + slide-changed + slidechange 事件。
1581
+ // data-deck-active 的切换在竖排 CSS 下无视觉副作用。
1582
+ this._applyIndex({ broadcast: true, reason: 'api' });
1583
+ }
1584
+ });
1585
+ }
1586
+
1587
+ _mobScrollTo(i, smooth) {
1588
+ const card = (this._mobCards || []).find((c) => c.i === i);
1589
+ if (!card) return;
1590
+ const stage = this._canvas.parentElement;
1591
+ const top = Math.max(0, card.y - Math.max(0, (stage.clientHeight - card.h) / 2));
1592
+ // 程序化滚动期间抑制 _onMobScroll 的反推——goto/deep-link 已直接设定
1593
+ // _index,多张边缘卡片的居中目标会被 clamp 到同一个 scrollTop,
1594
+ // 反推无法区分它们对应哪一页,故导航直接落 index、反推只处理真实
1595
+ // 用户位移。
1596
+ this._mobProgUntil = performance.now() + (smooth ? 800 : 200);
1597
+ // 目标位置立为基线:即便这次 scrollTo 因目标已等于当前位置(如居中
1598
+ // 目标 clamp 到 0)而不产生真实 scroll 事件,_mobLastTop 也不会停留
1599
+ // 在 null——避免之后任意一次无位移事件被当成"首次滚动"误判有位移。
1600
+ this._mobLastTop = top;
1601
+ stage.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
1602
+ }
1603
+
1604
+ _fitMobilePresent() {
1605
+ const stage = this._canvas.parentElement;
1606
+ if (stage) { stage.style.left = ''; stage.style.right = ''; stage.style.top = ''; stage.style.bottom = ''; }
1607
+ this.removeAttribute('data-windowed');
1608
+ this._canvas.style.width = this.designWidth + 'px';
1609
+ this._canvas.style.height = this.designHeight + 'px';
1610
+ // 物理竖屏:CSS 旋转 90°,stage 逻辑尺寸 = (innerHeight × innerWidth);
1611
+ // 物理横屏(用户顺势转了手机):不旋转,按真实视口常规适配。
1612
+ // CSS 侧由 @media (orientation) 同步分支。
1613
+ const portrait = window.innerHeight >= window.innerWidth;
1614
+ const lw = portrait ? window.innerHeight : window.innerWidth;
1615
+ const lh = portrait ? window.innerWidth : window.innerHeight;
1616
+ const s = Math.min(lw / this.designWidth, lh / this.designHeight);
1617
+ this._canvas.style.transform = 'scale(' + s + ')';
1618
+ }
1619
+
1291
1620
  _railWidth() {
1292
1621
  // State-based, no offsetWidth: the first _fit() can run before the
1293
1622
  // rail has had layout on some load paths, and a 0 there paints the
@@ -1301,6 +1630,8 @@
1301
1630
 
1302
1631
  _fit() {
1303
1632
  if (!this._canvas) return;
1633
+ if (this.hasAttribute('data-mobile-list')) return this._fitMobileList();
1634
+ if (this.hasAttribute('data-mobile-present')) return this._fitMobilePresent();
1304
1635
  const stage = this._canvas.parentElement;
1305
1636
  // PPTX export sets noscale so the DOM capture sees authored-size
1306
1637
  // geometry — the scaled canvas is in shadow DOM, so the exporter's
@@ -1311,6 +1642,8 @@
1311
1642
  if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1312
1643
  return;
1313
1644
  }
1645
+ this._canvas.style.width = this.designWidth + 'px';
1646
+ this._canvas.style.height = this.designHeight + 'px';
1314
1647
  const rw = this._railWidth();
1315
1648
  const nh = this._notesHeight();
1316
1649
  // Windowed (20px margin + card shadow) in edit/preview; full-bleed while
@@ -1336,6 +1669,14 @@
1336
1669
  }
1337
1670
 
1338
1671
  _onResize() {
1672
+ // 竖排列表几何只依赖视口宽度。移动浏览器地址栏收起/展开只改高度,
1673
+ // 若照常全量 refit(重建规则/徽标 + 无条件回中当前页),用户滚动中
1674
+ // 会被可见地"拽"一下——纯高度变化直接跳过。宽度变化(旋转/分屏)
1675
+ // 照常走完整 refit。
1676
+ if (this.hasAttribute('data-mobile-list')) {
1677
+ if (this._mobLastVw === window.innerWidth) return;
1678
+ this._mobLastVw = window.innerWidth;
1679
+ }
1339
1680
  this._fit();
1340
1681
  // Crossing the narrow-viewport breakpoint reveals the rail — rerun the
1341
1682
  // thumbnail scale the same way _setRailWidth does.
@@ -1351,6 +1692,8 @@
1351
1692
  const d = e.data;
1352
1693
  if (d && d.type === 'miaoda:deck:presenting' && typeof d.on === 'boolean') {
1353
1694
  this._presenting = d.on;
1695
+ this._syncMobileMode();
1696
+ if (!d.on) this._toggleMobUi(false);
1354
1697
  this._syncRailHidden();
1355
1698
  this._syncNotesHidden();
1356
1699
  this._closeMenu();
@@ -1431,12 +1774,118 @@
1431
1774
  if (n === this._stage) break;
1432
1775
  if (n.matches && n.matches(INTERACTIVE_SEL)) return;
1433
1776
  }
1777
+ // 竖排浏览是滚动列表,tap 不翻页(spec 决策 #5)
1778
+ if (this.hasAttribute('data-mobile-list')) return;
1434
1779
  e.preventDefault();
1780
+ if (this.hasAttribute('data-mobile-present')) {
1781
+ const zone = this._mobPresentZone(e);
1782
+ if (zone === 'menu') this._toggleMobUi();
1783
+ else {
1784
+ this._advance(zone === 'prev' ? -1 : 1, 'tap');
1785
+ if (this.hasAttribute('data-mob-ui')) this._toggleMobUi(true);
1786
+ }
1787
+ return;
1788
+ }
1435
1789
  const rw = this._railWidth();
1436
1790
  const mid = rw + (window.innerWidth - rw) / 2;
1437
1791
  this._advance(e.clientX < mid ? -1 : 1, 'tap');
1438
1792
  }
1439
1793
 
1794
+ // rotate(90deg) 后视觉横屏的 x 轴对应视口 y 轴。方向以真机/仿真实测为准:
1795
+ // 若实测发现 prev/next 颠倒,翻转此处的比较符(保持单点修改)。
1796
+ _mobPresentZone(e) {
1797
+ const rect = this._stage.getBoundingClientRect();
1798
+ // 物理竖屏 = stage 被 CSS 旋转 90°,视觉横屏 x 轴对应视口 y 轴;
1799
+ // 物理横屏 = 无旋转,直接用视口 x 轴。与 _fitMobilePresent 同分支。
1800
+ const portrait = window.innerHeight >= window.innerWidth;
1801
+ const x = portrait
1802
+ ? (e.clientY - rect.top) / Math.max(1, rect.height)
1803
+ : (e.clientX - rect.left) / Math.max(1, rect.width);
1804
+ return x < 1 / 3 ? 'prev' : x > 2 / 3 ? 'next' : 'menu';
1805
+ }
1806
+
1807
+ _toggleMobUi(force) {
1808
+ const wasOpen = this.hasAttribute('data-mob-ui');
1809
+ const on = force != null ? !!force : !wasOpen;
1810
+ this.toggleAttribute('data-mob-ui', on);
1811
+ clearTimeout(this._mobUiTimer);
1812
+ if (!on) return;
1813
+ // 从隐藏态唤出才全量重建(吸收直编/重排/skip 变化——strip 是静态
1814
+ // 快照、不接 _liveObserver 刷新管线);已展开时的调用(缩略图点击/
1815
+ // 翻页重置计时)只同步高亮,避免高频路径 O(页数) 重建。
1816
+ if (!wasOpen || !this._mobUi) {
1817
+ if (this._mobUi) { this._mobUi.remove(); this._mobUi = null; }
1818
+ this._buildMobUi();
1819
+ }
1820
+ this._syncMobStrip();
1821
+ this._mobUiTimer = setTimeout(() => this._toggleMobUi(false), MOB_UI_HIDE_MS);
1822
+ }
1823
+
1824
+ _buildMobUi() {
1825
+ // 移动 viewer 语境 host 可能从不发 rail-enabled,author-CSS 快照
1826
+ // (_enableRail 才建)不存在时缩略图克隆会裸渲染——此处兜底建一次。
1827
+ // _snapshotAuthorCss 可重入且带 generation guard。
1828
+ if (!this._adoptedSheet && this._authorCss == null) this._snapshotAuthorCss();
1829
+ const ui = document.createElement('div');
1830
+ ui.className = 'mob-ui export-hidden';
1831
+ ui.setAttribute('data-miaoda-chrome', '');
1832
+ const back = document.createElement('button');
1833
+ back.className = 'mob-back';
1834
+ back.type = 'button';
1835
+ back.textContent = '‹ 演示模式';
1836
+ back.addEventListener('click', (e) => {
1837
+ e.stopPropagation();
1838
+ this._presenting = false;
1839
+ this._syncMobileMode();
1840
+ this._syncRailHidden();
1841
+ this._syncNotesHidden();
1842
+ this._fit();
1843
+ this._toggleMobUi(false);
1844
+ try { window.parent.postMessage({ type: 'miaoda:deck:presenting-dismissed' }, '*'); } catch (err) {}
1845
+ });
1846
+ const strip = document.createElement('div');
1847
+ strip.className = 'mob-strip';
1848
+ this._mobStripEntries = this._slides
1849
+ .map((slide, i) => ({ slide, i }))
1850
+ .filter((e2) => !e2.slide.hasAttribute('data-deck-skip'))
1851
+ .map((e2, ordinal) => {
1852
+ const t = document.createElement('div');
1853
+ t.className = 'mthumb';
1854
+ const frame = document.createElement('div');
1855
+ frame.className = 'mframe';
1856
+ const num = document.createElement('div');
1857
+ num.className = 'mnum';
1858
+ num.textContent = String(ordinal + 1);
1859
+ t.append(frame, num);
1860
+ t.addEventListener('click', (ev) => {
1861
+ ev.stopPropagation();
1862
+ this._go(e2.i, 'click');
1863
+ this._toggleMobUi(true); // 重置自动收起计时,保持控件层可见
1864
+ });
1865
+ strip.appendChild(t);
1866
+ // 复用 _materialize 的克隆管线(media 熄火、canvas 快照、custom
1867
+ // element 中性化):它会自建 host/shadow 并直接挂进 entry.frame,
1868
+ // 所以这里不需要再手工搭 host——只需在其后把统一走 _thumbScale
1869
+ // (rail 宽度)算出的缩放,改写成本条带 132px 专属的缩放。
1870
+ const entry = { slide: e2.slide, frame, host: null, clone: null };
1871
+ this._materialize(entry);
1872
+ if (entry.clone) entry.clone.style.transform = 'scale(' + (132 / this.designWidth) + ')';
1873
+ return { el: t, i: e2.i };
1874
+ });
1875
+ // 控件层内任何点击不冒泡成翻页 tap
1876
+ ui.addEventListener('click', (e) => e.stopPropagation());
1877
+ ui.append(back, strip);
1878
+ this._stage.appendChild(ui);
1879
+ this._mobUi = ui;
1880
+ }
1881
+
1882
+ _syncMobStrip() {
1883
+ (this._mobStripEntries || []).forEach((en) => {
1884
+ en.el.toggleAttribute('data-current', en.i === this._index);
1885
+ if (en.i === this._index) en.el.scrollIntoView({ block: 'nearest', inline: 'center' });
1886
+ });
1887
+ }
1888
+
1440
1889
  _onKey(e) {
1441
1890
  // Ignore when the user is typing.
1442
1891
  const t = e.target;
@@ -1477,6 +1926,17 @@
1477
1926
  _go(i, reason = 'api') {
1478
1927
  if (!this._slides.length) return;
1479
1928
  const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
1929
+ // 竖排浏览:导航直接落 index(与桌面语义一致),滚动只是视觉跟随;
1930
+ // 不再依赖 _onMobScroll 从滚动位置反推——多张边缘卡片的居中滚动目标
1931
+ // 会被 clamp 到同一个 scrollTop,反推无法区分它们是哪一页。
1932
+ if (this.hasAttribute('data-mobile-list')) {
1933
+ const changed = clamped !== this._index;
1934
+ this._index = clamped;
1935
+ // 同页 goto 不重复广播(与桌面分支语义一致),但仍需重新居中滚动。
1936
+ if (changed) this._applyIndex({ broadcast: true, reason });
1937
+ this._mobScrollTo(clamped, true);
1938
+ return;
1939
+ }
1480
1940
  if (clamped === this._index) return;
1481
1941
  this._index = clamped;
1482
1942
  this._applyIndex({ broadcast: true, reason });
@@ -1714,9 +2174,12 @@
1714
2174
  if (entry.host) return;
1715
2175
  const dw = this.designWidth, dh = this.designHeight;
1716
2176
  let clone = entry.slide.cloneNode(true);
1717
- clone.removeAttribute('id');
2177
+ // Keep ids: the clone lives in its own shadow root, so duplicate ids
2178
+ // can't clash with the document (getElementById doesn't pierce shadow
2179
+ // roots), while id-keyed author styles (#slide-01 { background: … })
2180
+ // MUST keep matching — stripping ids rendered such decks as blank
2181
+ // white thumbnails (styles all failed to match).
1718
2182
  clone.removeAttribute('data-deck-active');
1719
- clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
1720
2183
  // Neuter heavy media; replace <video> with its poster so the box
1721
2184
  // keeps a visual. <iframe>/<audio> become empty placeholders.
1722
2185
  clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {