@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.
- package/components/design-canvas.jsx +219 -26
- package/package.json +1 -1
- package/scripts/deck-stage.js +468 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
702
|
-
vp.addEventListener('pointermove',
|
|
703
|
-
vp.addEventListener('pointerup',
|
|
704
|
-
vp.addEventListener('pointercancel',
|
|
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',
|
|
712
|
-
vp.removeEventListener('pointermove',
|
|
713
|
-
vp.removeEventListener('pointerup',
|
|
714
|
-
vp.removeEventListener('pointercancel',
|
|
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=
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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:
|
|
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={{
|
|
795
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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": [
|
package/scripts/deck-stage.js
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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
|
|
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) => {
|