@lark-apaas/coding-registry-buildless 0.1.4 → 0.1.5-alpha.20260717111512
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 +221 -26
- package/package.json +1 -1
- package/scripts/deck-stage.js +473 -5
|
@@ -28,6 +28,10 @@
|
|
|
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 devices the canvas automatically becomes a browse-only surface
|
|
33
|
+
// (touch pan/zoom, editing disabled). This needs NO authoring changes:
|
|
34
|
+
// never add custom touch handling or mobile-specific layouts.
|
|
31
35
|
/* END USAGE */
|
|
32
36
|
|
|
33
37
|
const DC = {
|
|
@@ -40,6 +44,16 @@ const DC = {
|
|
|
40
44
|
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
|
41
45
|
};
|
|
42
46
|
|
|
47
|
+
// Mirrors the host platform's isMobile() (UA regex + iPad maxTouchPoints);
|
|
48
|
+
// the product iframe is cross-origin, so the shared helper can't be imported.
|
|
49
|
+
// Deliberately NOT viewport-based: the desktop workbench shows canvases in a
|
|
50
|
+
// narrow preview iframe that a width breakpoint would misread as mobile.
|
|
51
|
+
// Evaluated once at load. Keep the regex in sync with deck-stage.js's copy.
|
|
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 a drag
|
|
116
|
+
// starting on selectable text never reaches the pan.
|
|
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 a pan could
|
|
122
|
+
// never start there. 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,110 @@ 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; // finger distance as of the last applied frame
|
|
744
|
+
let pinchMid = null; // last-applied midpoint
|
|
745
|
+
let panApplied = null; // pointer position as of the last applied pan frame
|
|
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(); // apply any pending pan frame before rebasing as a 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-coalesced application: move handlers only record pointer
|
|
770
|
+
// positions; geometry is applied at most once per frame. pointermove can
|
|
771
|
+
// far outrun the render rate (120Hz touch sampling, fast pinches), and
|
|
772
|
+
// running the full zoomAt/apply per event creates engine-heap garbage
|
|
773
|
+
// faster than mobile WebKit reclaims it — which reads as unbounded
|
|
774
|
+
// memory growth on device. Only the last position per frame can ever
|
|
775
|
+
// be painted anyway.
|
|
776
|
+
let mobRaf = 0;
|
|
777
|
+
const mobFlushFrame = () => {
|
|
778
|
+
mobRaf = 0;
|
|
779
|
+
if (mobMode === 'pan') {
|
|
780
|
+
const [t] = [...touches.values()];
|
|
781
|
+
if (!t) return;
|
|
782
|
+
tf.current.x += t.x - panApplied.x;
|
|
783
|
+
tf.current.y += t.y - panApplied.y;
|
|
784
|
+
panApplied = { x: t.x, y: t.y };
|
|
785
|
+
apply();
|
|
786
|
+
} else if (mobMode === 'pinch' && touches.size >= 2) {
|
|
787
|
+
const g = pinchGeom();
|
|
788
|
+
tf.current.x += g.mx - pinchMid.x;
|
|
789
|
+
tf.current.y += g.my - pinchMid.y;
|
|
790
|
+
pinchMid = { x: g.mx, y: g.my };
|
|
791
|
+
// zoomAt applies the pan mutation above too; degenerate distances
|
|
792
|
+
// (fingers together) skip the zoom but still need the pan applied.
|
|
793
|
+
// Same full zoomAt as the desktop trackpad-pinch path (gesturechange):
|
|
794
|
+
// per-frame --dc-inv-zoom keeps chrome size AND section spacing
|
|
795
|
+
// constant throughout the gesture, with the same drift compensation.
|
|
796
|
+
if (pinchDist > 0 && g.d > 0) zoomAt(g.mx, g.my, g.d / pinchDist);
|
|
797
|
+
else apply();
|
|
798
|
+
pinchDist = g.d;
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
const mobSchedule = () => { if (!mobRaf) mobRaf = requestAnimationFrame(mobFlushFrame); };
|
|
802
|
+
const mobFlushNow = () => { if (mobRaf) { cancelAnimationFrame(mobRaf); mobFlushFrame(); } };
|
|
803
|
+
|
|
804
|
+
const onTouchMove = (e) => {
|
|
805
|
+
const t = touches.get(e.pointerId);
|
|
806
|
+
if (!t) return;
|
|
807
|
+
t.x = e.clientX; t.y = e.clientY;
|
|
808
|
+
if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
|
|
809
|
+
mobMode = 'pan';
|
|
810
|
+
panApplied = { x: t.x, y: t.y };
|
|
811
|
+
try { vp.setPointerCapture(e.pointerId); } catch {}
|
|
812
|
+
}
|
|
813
|
+
if (mobMode === 'pan' || mobMode === 'pinch') mobSchedule();
|
|
814
|
+
};
|
|
815
|
+
const onTouchUp = (e) => {
|
|
816
|
+
mobFlushNow(); // apply the pending frame first so the gesture tail isn't lost
|
|
817
|
+
if (!touches.delete(e.pointerId)) return;
|
|
818
|
+
try { vp.releasePointerCapture(e.pointerId); } catch {}
|
|
819
|
+
if (touches.size === 0) {
|
|
820
|
+
mobMode = null;
|
|
821
|
+
} else if (touches.size === 1) {
|
|
822
|
+
// Pinch collapsing to one finger hands off to pan with the surviving
|
|
823
|
+
// finger as the new baseline — no jump.
|
|
824
|
+
mobMode = 'pan';
|
|
825
|
+
const [t] = [...touches.values()];
|
|
826
|
+
panApplied = { x: t.x, y: t.y };
|
|
827
|
+
} else if (mobMode === 'pinch') {
|
|
828
|
+
// 3+ fingers losing one: re-baseline so the new leading pair doesn't
|
|
829
|
+
// read as a sudden midpoint/distance delta.
|
|
830
|
+
const g = pinchGeom();
|
|
831
|
+
pinchDist = g.d;
|
|
832
|
+
pinchMid = { x: g.mx, y: g.my };
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
667
836
|
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
|
668
837
|
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
|
669
838
|
const onHostMsg = (e) => {
|
|
@@ -694,31 +863,38 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
|
|
694
863
|
lastPostedScale.current = undefined;
|
|
695
864
|
apply();
|
|
696
865
|
|
|
866
|
+
// Mobile swaps the desktop pointer mapping (bg-only pan) for the touch
|
|
867
|
+
// state machine; wheel/gesture listeners stay on both (harmless without
|
|
868
|
+
// the hardware, and a bluetooth mouse on Android still pans via wheel).
|
|
869
|
+
const pd = dcIsMobile ? onTouchDown : onPointerDown;
|
|
870
|
+
const pm = dcIsMobile ? onTouchMove : onPointerMove;
|
|
871
|
+
const pu = dcIsMobile ? onTouchUp : onPointerUp;
|
|
697
872
|
vp.addEventListener('wheel', onWheel, { passive: false });
|
|
698
873
|
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
|
699
874
|
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
|
700
875
|
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
|
701
|
-
vp.addEventListener('pointerdown',
|
|
702
|
-
vp.addEventListener('pointermove',
|
|
703
|
-
vp.addEventListener('pointerup',
|
|
704
|
-
vp.addEventListener('pointercancel',
|
|
876
|
+
vp.addEventListener('pointerdown', pd);
|
|
877
|
+
vp.addEventListener('pointermove', pm);
|
|
878
|
+
vp.addEventListener('pointerup', pu);
|
|
879
|
+
vp.addEventListener('pointercancel', pu);
|
|
705
880
|
return () => {
|
|
706
881
|
window.removeEventListener('message', onHostMsg);
|
|
707
882
|
vp.removeEventListener('wheel', onWheel);
|
|
708
883
|
vp.removeEventListener('gesturestart', onGestureStart);
|
|
709
884
|
vp.removeEventListener('gesturechange', onGestureChange);
|
|
710
885
|
vp.removeEventListener('gestureend', onGestureEnd);
|
|
711
|
-
vp.removeEventListener('pointerdown',
|
|
712
|
-
vp.removeEventListener('pointermove',
|
|
713
|
-
vp.removeEventListener('pointerup',
|
|
714
|
-
vp.removeEventListener('pointercancel',
|
|
886
|
+
vp.removeEventListener('pointerdown', pd);
|
|
887
|
+
vp.removeEventListener('pointermove', pm);
|
|
888
|
+
vp.removeEventListener('pointerup', pu);
|
|
889
|
+
vp.removeEventListener('pointercancel', pu);
|
|
890
|
+
if (mobRaf) cancelAnimationFrame(mobRaf);
|
|
715
891
|
};
|
|
716
892
|
}, [apply, minScale, maxScale]);
|
|
717
893
|
|
|
718
894
|
return (
|
|
719
895
|
<div
|
|
720
896
|
ref={vpRef}
|
|
721
|
-
className=
|
|
897
|
+
className={dcIsMobile ? 'design-canvas dc-mobile' : 'design-canvas'}
|
|
722
898
|
style={{
|
|
723
899
|
height: '100vh', width: '100vw',
|
|
724
900
|
background: DC.bg,
|
|
@@ -744,12 +920,16 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
|
|
744
920
|
>
|
|
745
921
|
{children}
|
|
746
922
|
</div>
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
923
|
+
{/* Mobile zooms by pinch — the control is not rendered at all (host
|
|
924
|
+
set-zoom messages still work; the protocol is independent of it). */}
|
|
925
|
+
{!dcIsMobile && (
|
|
926
|
+
<DCZoomControl
|
|
927
|
+
scalePct={scalePct}
|
|
928
|
+
minScale={minScale}
|
|
929
|
+
maxScale={maxScale}
|
|
930
|
+
onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
|
|
931
|
+
/>
|
|
932
|
+
)}
|
|
753
933
|
</div>
|
|
754
934
|
);
|
|
755
935
|
}
|
|
@@ -788,11 +968,16 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
|
|
788
968
|
<div data-dc-section={sid}
|
|
789
969
|
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
|
790
970
|
<div style={{ padding: '0 60px' }}>
|
|
791
|
-
<div className="dc-sectionhead" style={{ paddingBottom:
|
|
971
|
+
<div className="dc-sectionhead" style={{ paddingBottom: 'calc(36px * var(--dc-inv-zoom, 1))' }}>
|
|
792
972
|
<DCEditable tag="div" value={sec.title ?? title}
|
|
793
973
|
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
|
794
|
-
style={{
|
|
795
|
-
|
|
974
|
+
style={{
|
|
975
|
+
fontSize: 'calc(20px * var(--dc-inv-zoom, 1))', fontWeight: 600, color: DC.title,
|
|
976
|
+
letterSpacing: 'calc(-0.4px * var(--dc-inv-zoom, 1))',
|
|
977
|
+
marginBottom: 'calc(2px * var(--dc-inv-zoom, 1))',
|
|
978
|
+
display: 'inline-block', lineHeight: 'calc(28px * var(--dc-inv-zoom, 1))',
|
|
979
|
+
}} />
|
|
980
|
+
{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
981
|
</div>
|
|
797
982
|
</div>
|
|
798
983
|
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
|
@@ -1035,14 +1220,17 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
|
|
|
1035
1220
|
|
|
1036
1221
|
return (
|
|
1037
1222
|
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
|
1038
|
-
|
|
1223
|
+
{/* Desktop stops pointerdown so header interactions never start a
|
|
1224
|
+
bg-pan; mobile pans FROM chrome too (slop arbitration keeps label
|
|
1225
|
+
taps working), so the event must bubble to the viewport there. */}
|
|
1226
|
+
<div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={dcIsMobile ? undefined : (e) => e.stopPropagation()}>
|
|
1039
1227
|
<div className="dc-labelrow">
|
|
1040
|
-
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
|
1228
|
+
<div className="dc-grip" onPointerDown={dcIsMobile ? undefined : onGripDown} title="Drag to reorder">
|
|
1041
1229
|
<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
1230
|
</div>
|
|
1043
1231
|
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
|
1044
1232
|
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
|
1045
|
-
style={{ fontSize: 14, fontWeight:
|
|
1233
|
+
style={{ fontSize: 14, fontWeight: 400, color: DC.label, lineHeight: '22px' }} />
|
|
1046
1234
|
</div>
|
|
1047
1235
|
</div>
|
|
1048
1236
|
<div className="dc-btns">
|
|
@@ -1086,6 +1274,13 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
|
|
|
1086
1274
|
// Inline rename — commits on blur or Enter.
|
|
1087
1275
|
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
|
1088
1276
|
const T = tag;
|
|
1277
|
+
// Mobile is browse-only: render plain text — no contentEditable (a tap
|
|
1278
|
+
// would pop the soft keyboard), no stopPropagation (pans start on labels
|
|
1279
|
+
// too), and no onClick swallow (desktop uses it to keep an edit-click from
|
|
1280
|
+
// bubbling; on mobile the tap should bubble so a label tap opens focus).
|
|
1281
|
+
if (dcIsMobile) {
|
|
1282
|
+
return <T className="dc-editable" style={style}>{value}</T>;
|
|
1283
|
+
}
|
|
1089
1284
|
return (
|
|
1090
1285
|
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
|
1091
1286
|
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.20260717111512",
|
|
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,8 @@
|
|
|
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,
|
|
16
|
-
* content are left alone.
|
|
15
|
+
* prev/next (not in mobile browse mode — see (g)) — taps on links,
|
|
16
|
+
* buttons and other interactive slide content are left alone.
|
|
17
17
|
* (c) press R to reset to slide 0.
|
|
18
18
|
* (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
|
|
19
19
|
* scaled with `transform: scale()` to fit the viewport, letterboxed.
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
* (≤640px), and via the `no-rail` attribute. Rail mutations dispatch
|
|
35
35
|
* a `deckchange`
|
|
36
36
|
* CustomEvent on the element: detail = {action, from, to, slide}.
|
|
37
|
+
* (g) mobile — on mobile devices the same deck automatically renders as
|
|
38
|
+
* a scrollable vertical card list (browse) and a rotated fullscreen
|
|
39
|
+
* presenting mode. This needs NO authoring changes: never hand-roll
|
|
40
|
+
* mobile/responsive slide layouts. Desktop behaviour is unchanged.
|
|
37
41
|
*
|
|
38
42
|
* Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
|
|
39
43
|
* `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
|
|
@@ -116,6 +120,22 @@
|
|
|
116
120
|
// Slide-authored controls that should keep a tap instead of it navigating.
|
|
117
121
|
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
122
|
|
|
123
|
+
// Mirrors the host platform's isMobile() (UA regex + iPad maxTouchPoints);
|
|
124
|
+
// the product iframe is cross-origin, so the shared helper can't be
|
|
125
|
+
// imported. Deliberately NOT viewport-based: the desktop workbench renders
|
|
126
|
+
// decks in a narrow preview iframe that a width breakpoint would misread
|
|
127
|
+
// as mobile. Keep the regex in sync with design-canvas.jsx's copy.
|
|
128
|
+
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;
|
|
129
|
+
const isMobileUA = () =>
|
|
130
|
+
MOBILE_UA_RE.test(navigator.userAgent) ||
|
|
131
|
+
(/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
|
|
132
|
+
// Mobile visual metrics (values from the platform's mobile design spec).
|
|
133
|
+
const MOB_MARGIN = 12; // card-stream edge margin
|
|
134
|
+
const MOB_GAP = 8; // gap between cards
|
|
135
|
+
const MOB_RADIUS = 8; // card corner radius
|
|
136
|
+
const MOB_BADGE_IDLE_MS = 2000; // scroll idle before the page badge fades
|
|
137
|
+
const MOB_UI_HIDE_MS = 3000; // presenting controls auto-hide
|
|
138
|
+
|
|
119
139
|
const pad2 = (n) => String(n).padStart(2, '0');
|
|
120
140
|
|
|
121
141
|
// Label precedence: data-label → data-screen-label (number stripped) → first heading → "Slide".
|
|
@@ -380,6 +400,145 @@
|
|
|
380
400
|
.rail[data-presenting] + .rail-resize,
|
|
381
401
|
.rail[data-user-hidden] + .rail-resize { display: none; }
|
|
382
402
|
|
|
403
|
+
/* ── Mobile browse: vertical card stream ── */
|
|
404
|
+
:host([data-mobile-list]) .stage {
|
|
405
|
+
display: block;
|
|
406
|
+
overflow-y: auto;
|
|
407
|
+
-webkit-overflow-scrolling: touch;
|
|
408
|
+
background: #F8F9FA;
|
|
409
|
+
}
|
|
410
|
+
:host([data-mobile-list]) .canvas {
|
|
411
|
+
transform: none !important;
|
|
412
|
+
background: transparent;
|
|
413
|
+
will-change: auto;
|
|
414
|
+
}
|
|
415
|
+
:host([data-mobile-list][data-windowed]) .canvas { border: none; box-shadow: none; }
|
|
416
|
+
:host([data-mobile-list]) .rail, :host([data-mobile-list]) .rail-resize { display: none; }
|
|
417
|
+
:host([data-mobile-list]) ::slotted(*) {
|
|
418
|
+
opacity: 1;
|
|
419
|
+
visibility: visible;
|
|
420
|
+
pointer-events: auto;
|
|
421
|
+
transform-origin: 0 0;
|
|
422
|
+
box-sizing: border-box;
|
|
423
|
+
}
|
|
424
|
+
/* Skipped slides get no nth-child placement rule from _fitMobileList —
|
|
425
|
+
without this they'd fall back to the base inset:0 stacking rule and,
|
|
426
|
+
now force-visible, paint over the whole list canvas and swallow taps.
|
|
427
|
+
(The equivalent rule further down lives inside @media print only.) */
|
|
428
|
+
:host([data-mobile-list]) ::slotted([data-deck-skip]) { display: none !important; }
|
|
429
|
+
|
|
430
|
+
/* ── Mobile presenting: CSS pseudo-landscape (no orientation.lock) ──
|
|
431
|
+
Rotate only while the device is physically portrait. Once the user
|
|
432
|
+
turns the phone (system auto-rotate) the viewport itself is landscape,
|
|
433
|
+
and stacking another 90° would render slides sideways — so landscape
|
|
434
|
+
falls back to a plain full-bleed fit. _fitMobilePresent and
|
|
435
|
+
_mobPresentZone branch on the same orientation test. */
|
|
436
|
+
:host([data-mobile-present]) .stage { background: #000; }
|
|
437
|
+
@media (orientation: portrait) {
|
|
438
|
+
:host([data-mobile-present]) .stage {
|
|
439
|
+
inset: auto;
|
|
440
|
+
left: 50%;
|
|
441
|
+
top: 50%;
|
|
442
|
+
width: 100vh;
|
|
443
|
+
height: 100vw;
|
|
444
|
+
transform: translate(-50%, -50%) rotate(90deg);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* ── Mobile page badge ── */
|
|
449
|
+
.mob-badges { display: none; }
|
|
450
|
+
:host([data-mobile-list]) .mob-badges {
|
|
451
|
+
display: block;
|
|
452
|
+
position: absolute;
|
|
453
|
+
inset: 0;
|
|
454
|
+
pointer-events: none;
|
|
455
|
+
z-index: 10;
|
|
456
|
+
}
|
|
457
|
+
.mob-badge {
|
|
458
|
+
position: absolute;
|
|
459
|
+
height: 12px;
|
|
460
|
+
box-sizing: border-box;
|
|
461
|
+
padding: 0 2px;
|
|
462
|
+
border-radius: 3px;
|
|
463
|
+
background: rgba(31, 35, 41, 0.2);
|
|
464
|
+
color: #fff;
|
|
465
|
+
font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
|
|
466
|
+
font-size: 8px;
|
|
467
|
+
line-height: 12px;
|
|
468
|
+
text-align: center;
|
|
469
|
+
opacity: 0;
|
|
470
|
+
transition: opacity 0.1s linear;
|
|
471
|
+
}
|
|
472
|
+
:host([data-mob-scrolling]) .mob-badge { opacity: 1; }
|
|
473
|
+
|
|
474
|
+
/* ── Mobile presenting controls (back button + filmstrip) ── */
|
|
475
|
+
.mob-ui { display: none; }
|
|
476
|
+
:host([data-mobile-present][data-mob-ui]) .mob-ui {
|
|
477
|
+
display: block;
|
|
478
|
+
position: absolute;
|
|
479
|
+
inset: 0;
|
|
480
|
+
pointer-events: none;
|
|
481
|
+
z-index: 20;
|
|
482
|
+
}
|
|
483
|
+
.mob-back {
|
|
484
|
+
position: absolute;
|
|
485
|
+
left: max(16px, env(safe-area-inset-left));
|
|
486
|
+
top: 12px;
|
|
487
|
+
pointer-events: auto;
|
|
488
|
+
display: flex;
|
|
489
|
+
align-items: center;
|
|
490
|
+
gap: 6px;
|
|
491
|
+
border: none;
|
|
492
|
+
border-radius: 999px;
|
|
493
|
+
padding: 6px 14px;
|
|
494
|
+
background: rgba(0, 0, 0, 0.4);
|
|
495
|
+
color: #fff;
|
|
496
|
+
font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
|
|
497
|
+
font-size: 15px;
|
|
498
|
+
font-weight: 500;
|
|
499
|
+
cursor: pointer;
|
|
500
|
+
}
|
|
501
|
+
.mob-strip {
|
|
502
|
+
position: absolute;
|
|
503
|
+
left: 0;
|
|
504
|
+
right: 0;
|
|
505
|
+
bottom: 0;
|
|
506
|
+
pointer-events: auto;
|
|
507
|
+
display: flex;
|
|
508
|
+
gap: 8px;
|
|
509
|
+
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
|
510
|
+
/* Transparent: thumbnails carry their own white frames, and a solid
|
|
511
|
+
strip would blanket the slide behind the overlay. */
|
|
512
|
+
background: transparent;
|
|
513
|
+
overflow-x: auto;
|
|
514
|
+
}
|
|
515
|
+
.mob-strip .mthumb { flex: 0 0 132px; cursor: pointer; }
|
|
516
|
+
.mob-strip .mframe {
|
|
517
|
+
width: 132px;
|
|
518
|
+
height: 72px;
|
|
519
|
+
border-radius: 8px;
|
|
520
|
+
border: 0.5px solid #DEE0E3;
|
|
521
|
+
overflow: hidden;
|
|
522
|
+
position: relative;
|
|
523
|
+
background: #fff;
|
|
524
|
+
}
|
|
525
|
+
.mob-strip .mthumb[data-current] .mframe {
|
|
526
|
+
outline: 1px solid #336DF4;
|
|
527
|
+
outline-offset: 2px;
|
|
528
|
+
border-radius: 10px;
|
|
529
|
+
}
|
|
530
|
+
.mob-strip .mnum {
|
|
531
|
+
margin-top: 2px;
|
|
532
|
+
text-align: center;
|
|
533
|
+
font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
|
|
534
|
+
font-size: 12px;
|
|
535
|
+
line-height: 20px;
|
|
536
|
+
font-weight: 500;
|
|
537
|
+
/* The strip floats over the (dark) presenting stage — numbers must be light. */
|
|
538
|
+
color: rgba(255, 255, 255, 0.9);
|
|
539
|
+
}
|
|
540
|
+
.mob-strip .mthumb[data-current] .mnum { color: #336DF4; }
|
|
541
|
+
|
|
383
542
|
/* ── Speaker-notes dock ──────────────────────────────────────────────
|
|
384
543
|
Bottom panel spanning the non-rail width in edit mode: a resize grip
|
|
385
544
|
(drag to set height, persisted to localStorage) + an editable textarea
|
|
@@ -538,6 +697,8 @@
|
|
|
538
697
|
// windowed margin/shadow, see _fit. A bare load with no #N hash starts at
|
|
539
698
|
// slide 1, so ?thumbnail=1 alone yields a clean first-slide frame.
|
|
540
699
|
this._snthumb = /[?&](_snthumb|thumbnail)=/.test(location.search);
|
|
700
|
+
this._mobile = isMobileUA();
|
|
701
|
+
this._syncMobileMode();
|
|
541
702
|
if (this._snthumb) this.setAttribute('no-rail', '');
|
|
542
703
|
this._render();
|
|
543
704
|
this._loadNotes();
|
|
@@ -830,6 +991,9 @@
|
|
|
830
991
|
if (this._tweakTimer) clearTimeout(this._tweakTimer);
|
|
831
992
|
if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
|
|
832
993
|
if (this._notesPersistTimer) clearTimeout(this._notesPersistTimer);
|
|
994
|
+
clearTimeout(this._mobUiTimer);
|
|
995
|
+
clearTimeout(this._mobScrollIdleTimer);
|
|
996
|
+
if (this._mobScrollRaf) { cancelAnimationFrame(this._mobScrollRaf); this._mobScrollRaf = null; }
|
|
833
997
|
if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
|
|
834
998
|
if (this._liveObserver) this._liveObserver.disconnect();
|
|
835
999
|
if (this._railObserver) this._railObserver.disconnect();
|
|
@@ -845,6 +1009,7 @@
|
|
|
845
1009
|
if (this._rail) {
|
|
846
1010
|
this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
|
|
847
1011
|
}
|
|
1012
|
+
this._syncMobileMode();
|
|
848
1013
|
this._fit();
|
|
849
1014
|
this._scaleThumbs();
|
|
850
1015
|
this._syncPrintPageRule();
|
|
@@ -870,6 +1035,17 @@
|
|
|
870
1035
|
canvas.appendChild(slot);
|
|
871
1036
|
stage.appendChild(canvas);
|
|
872
1037
|
|
|
1038
|
+
this._onMobScroll = this._onMobScroll.bind(this);
|
|
1039
|
+
stage.addEventListener('scroll', this._onMobScroll, { passive: true });
|
|
1040
|
+
|
|
1041
|
+
// Per-slide placement rules for the mobile card stream; rebuilt by _fitMobileList().
|
|
1042
|
+
this._mobStyle = document.createElement('style');
|
|
1043
|
+
|
|
1044
|
+
this._mobBadges = document.createElement('div');
|
|
1045
|
+
this._mobBadges.className = 'mob-badges export-hidden';
|
|
1046
|
+
this._mobBadges.setAttribute('data-miaoda-chrome', '');
|
|
1047
|
+
canvas.appendChild(this._mobBadges);
|
|
1048
|
+
|
|
873
1049
|
// Thumbnail rail + context menu. Thumbnails are populated in
|
|
874
1050
|
// _renderRail() after _collectSlides().
|
|
875
1051
|
const rail = document.createElement('div');
|
|
@@ -1010,7 +1186,7 @@
|
|
|
1010
1186
|
// the note instead of navigating slides (same trick as the thumb keydown).
|
|
1011
1187
|
notesBody.addEventListener('keydown', (e) => e.stopPropagation());
|
|
1012
1188
|
|
|
1013
|
-
this._root.append(style, rail, resize, stage, notes, menu);
|
|
1189
|
+
this._root.append(style, this._mobStyle, rail, resize, stage, notes, menu);
|
|
1014
1190
|
this._canvas = canvas;
|
|
1015
1191
|
this._stage = stage;
|
|
1016
1192
|
this._slot = slot;
|
|
@@ -1285,9 +1461,162 @@
|
|
|
1285
1461
|
}));
|
|
1286
1462
|
}
|
|
1287
1463
|
|
|
1464
|
+
if (this.hasAttribute('data-mob-ui')) this._syncMobStrip();
|
|
1465
|
+
|
|
1288
1466
|
this._prevIndex = curr;
|
|
1289
1467
|
}
|
|
1290
1468
|
|
|
1469
|
+
// The two mobile presentations are mutually exclusive. noscale and
|
|
1470
|
+
// _snthumb are capture modes whose output must keep desktop geometry,
|
|
1471
|
+
// so they opt out of both.
|
|
1472
|
+
_syncMobileMode() {
|
|
1473
|
+
const eligible = this._mobile && !this.hasAttribute('noscale') && !this._snthumb;
|
|
1474
|
+
this.toggleAttribute('data-mobile-list', eligible && !this._presenting);
|
|
1475
|
+
this.toggleAttribute('data-mobile-present', eligible && !!this._presenting);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
_fitMobileList() {
|
|
1479
|
+
const dw = this.designWidth, dh = this.designHeight;
|
|
1480
|
+
const vw = window.innerWidth;
|
|
1481
|
+
this._mobLastVw = vw; // baseline for _onResize's height-only skip
|
|
1482
|
+
const stage = this._canvas.parentElement;
|
|
1483
|
+
// Clear the stage inset the desktop _fit() wrote inline.
|
|
1484
|
+
if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
|
|
1485
|
+
this.removeAttribute('data-windowed');
|
|
1486
|
+
const cardW = vw - 2 * MOB_MARGIN;
|
|
1487
|
+
const s = cardW / dw;
|
|
1488
|
+
const cardH = dh * s;
|
|
1489
|
+
const kids = Array.prototype.slice.call(this.children);
|
|
1490
|
+
const rules = [];
|
|
1491
|
+
const cards = [];
|
|
1492
|
+
let y = MOB_MARGIN;
|
|
1493
|
+
this._slides.forEach((slide, i) => {
|
|
1494
|
+
if (slide.hasAttribute('data-deck-skip')) return;
|
|
1495
|
+
const nth = kids.indexOf(slide) + 1;
|
|
1496
|
+
if (nth <= 0) return;
|
|
1497
|
+
rules.push(
|
|
1498
|
+
':host([data-mobile-list]) ::slotted(:nth-child(' + nth + ')) {' +
|
|
1499
|
+
' inset: auto !important; top: 0 !important; left: 0 !important;' +
|
|
1500
|
+
' width: ' + dw + 'px !important; height: ' + dh + 'px !important;' +
|
|
1501
|
+
' transform: translate(' + MOB_MARGIN + 'px, ' + y + 'px) scale(' + s + ') !important;' +
|
|
1502
|
+
// Radius/border are pre-divided by the scale so the slide's transform
|
|
1503
|
+
// lands them at 8px / 0.5px on screen.
|
|
1504
|
+
' border-radius: ' + (MOB_RADIUS / s) + 'px; overflow: hidden !important;' +
|
|
1505
|
+
' border: ' + (0.5 / s) + 'px solid #DEE0E3; }'
|
|
1506
|
+
);
|
|
1507
|
+
cards.push({ slide, i, y, h: cardH });
|
|
1508
|
+
y += cardH + MOB_GAP;
|
|
1509
|
+
});
|
|
1510
|
+
// Screen-only: these per-slide rules out-specify the print block's
|
|
1511
|
+
// ::slotted reset, so unwrapped they would drag the card-stream
|
|
1512
|
+
// transforms into the paginated print output.
|
|
1513
|
+
this._mobStyle.textContent = '@media screen {\n' + rules.join('\n') + '\n}';
|
|
1514
|
+
this._canvas.style.transform = 'none';
|
|
1515
|
+
this._canvas.style.width = vw + 'px';
|
|
1516
|
+
this._canvas.style.height = Math.max(y - MOB_GAP + MOB_MARGIN, 0) + 'px';
|
|
1517
|
+
this._mobCards = cards;
|
|
1518
|
+
this._mobScale = s;
|
|
1519
|
+
|
|
1520
|
+
// Badge geometry is fully determined by _mobCards, so badges are laid
|
|
1521
|
+
// out directly — no IntersectionObserver needed.
|
|
1522
|
+
this._mobBadges.textContent = '';
|
|
1523
|
+
cards.forEach((c, ordinal) => {
|
|
1524
|
+
const b = document.createElement('div');
|
|
1525
|
+
b.className = 'mob-badge';
|
|
1526
|
+
const n = ordinal + 1;
|
|
1527
|
+
b.textContent = String(n);
|
|
1528
|
+
b.style.width = (String(n).length >= 3 ? 20 : 16) + 'px';
|
|
1529
|
+
b.style.left = (MOB_MARGIN + 6) + 'px';
|
|
1530
|
+
b.style.top = (c.y + c.h - 6 - 12) + 'px';
|
|
1531
|
+
this._mobBadges.appendChild(b);
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
this._mobScrollTo(this._index, false);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
_onMobScroll() {
|
|
1538
|
+
if (!this.hasAttribute('data-mobile-list') || !this._mobCards || !this._mobCards.length) return;
|
|
1539
|
+
|
|
1540
|
+
// Programmatic scrolls count as scrolling too — either source means
|
|
1541
|
+
// "the position is changing", which is what the badge signals.
|
|
1542
|
+
this.setAttribute('data-mob-scrolling', '');
|
|
1543
|
+
clearTimeout(this._mobScrollIdleTimer);
|
|
1544
|
+
this._mobScrollIdleTimer = setTimeout(
|
|
1545
|
+
() => this.removeAttribute('data-mob-scrolling'), MOB_BADGE_IDLE_MS);
|
|
1546
|
+
|
|
1547
|
+
if (this._mobScrollRaf) return;
|
|
1548
|
+
this._mobScrollRaf = requestAnimationFrame(() => {
|
|
1549
|
+
this._mobScrollRaf = null;
|
|
1550
|
+
const stage = this._canvas.parentElement;
|
|
1551
|
+
const top = stage.scrollTop;
|
|
1552
|
+
const moved = this._mobLastTop == null ? Infinity : Math.abs(top - this._mobLastTop);
|
|
1553
|
+
this._mobLastTop = top; // keep tracking even while suppressed
|
|
1554
|
+
if (this._mobProgUntil && performance.now() < this._mobProgUntil) {
|
|
1555
|
+
// Events are still streaming from a programmatic scroll: extend
|
|
1556
|
+
// the window so any animation length is covered. It expires 120ms
|
|
1557
|
+
// after the stream stops; user scrolls then resume inference.
|
|
1558
|
+
this._mobProgUntil = Math.max(this._mobProgUntil, performance.now() + 120);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (moved < 1) return; // no real displacement (synthetic/terminal event)
|
|
1562
|
+
// A pure viewport-centre rule can never select the first/last page:
|
|
1563
|
+
// 16:9 cards are far shorter than half a portrait viewport, so their
|
|
1564
|
+
// centring targets clamp. Clamp at the extremes, centre in between.
|
|
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
|
+
// Broadcast through the existing core (hash + slide-changed +
|
|
1581
|
+
// slidechange). data-deck-active toggling is visually inert here.
|
|
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
|
+
// Suppress scroll-position inference while a programmatic scroll is
|
|
1593
|
+
// in flight: goto/deep-link already set _index directly, and the
|
|
1594
|
+
// centring targets of several edge cards clamp to the same scrollTop,
|
|
1595
|
+
// so the position alone cannot tell those pages apart.
|
|
1596
|
+
this._mobProgUntil = performance.now() + (smooth ? 800 : 200);
|
|
1597
|
+
// Seed the baseline with the target: a scrollTo that lands where the
|
|
1598
|
+
// stage already is fires no scroll event, and a null baseline would
|
|
1599
|
+
// make the next no-op event read as a real first displacement.
|
|
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
|
+
// Portrait: the stage is CSS-rotated, so its logical size is the
|
|
1611
|
+
// swapped viewport. Landscape (user physically rotated): no rotation,
|
|
1612
|
+
// plain fit. The CSS branches on the same orientation query.
|
|
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
|
+
// Card-stream geometry depends only on viewport width, and mobile
|
|
1673
|
+
// browser URL bars fire height-only resizes as they collapse/expand —
|
|
1674
|
+
// a full refit would rebuild everything and yank the list back to the
|
|
1675
|
+
// current page mid-scroll. Width changes (rotate/split) refit as usual.
|
|
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,122 @@
|
|
|
1431
1774
|
if (n === this._stage) break;
|
|
1432
1775
|
if (n.matches && n.matches(INTERACTIVE_SEL)) return;
|
|
1433
1776
|
}
|
|
1777
|
+
// Mobile browse is a scroll list — taps must not navigate.
|
|
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
|
+
// After rotate(90deg) the visual-landscape x axis maps onto viewport y
|
|
1795
|
+
// (direction confirmed on device; if prev/next ever read reversed, flip
|
|
1796
|
+
// these comparisons — single point of change).
|
|
1797
|
+
_mobPresentZone(e) {
|
|
1798
|
+
const rect = this._stage.getBoundingClientRect();
|
|
1799
|
+
// Portrait = stage is rotated, visual x comes from viewport y;
|
|
1800
|
+
// landscape = no rotation, viewport x directly. Same branch as the fit.
|
|
1801
|
+
const portrait = window.innerHeight >= window.innerWidth;
|
|
1802
|
+
const x = portrait
|
|
1803
|
+
? (e.clientY - rect.top) / Math.max(1, rect.height)
|
|
1804
|
+
: (e.clientX - rect.left) / Math.max(1, rect.width);
|
|
1805
|
+
return x < 1 / 3 ? 'prev' : x > 2 / 3 ? 'next' : 'menu';
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
_toggleMobUi(force) {
|
|
1809
|
+
const wasOpen = this.hasAttribute('data-mob-ui');
|
|
1810
|
+
const on = force != null ? !!force : !wasOpen;
|
|
1811
|
+
this.toggleAttribute('data-mob-ui', on);
|
|
1812
|
+
clearTimeout(this._mobUiTimer);
|
|
1813
|
+
if (!on) return;
|
|
1814
|
+
// Rebuild only when opening from hidden: the strip is a static
|
|
1815
|
+
// snapshot outside _liveObserver's refresh pipeline, so reopening is
|
|
1816
|
+
// what absorbs slide edits/reorders/skips. Calls while already open
|
|
1817
|
+
// (thumbnail taps resetting the timer) only re-highlight — rebuilding
|
|
1818
|
+
// on that hot path would clone every slide per tap.
|
|
1819
|
+
if (!wasOpen || !this._mobUi) {
|
|
1820
|
+
if (this._mobUi) { this._mobUi.remove(); this._mobUi = null; }
|
|
1821
|
+
this._buildMobUi();
|
|
1822
|
+
}
|
|
1823
|
+
this._syncMobStrip();
|
|
1824
|
+
this._mobUiTimer = setTimeout(() => this._toggleMobUi(false), MOB_UI_HIDE_MS);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
_buildMobUi() {
|
|
1828
|
+
// A pure-viewer host may never send rail-enabled, so the author-CSS
|
|
1829
|
+
// snapshot (normally built by _enableRail) can be missing and the
|
|
1830
|
+
// clones would render unstyled — build it here as a fallback.
|
|
1831
|
+
// _snapshotAuthorCss is re-entrant and generation-guarded.
|
|
1832
|
+
if (!this._adoptedSheet && this._authorCss == null) this._snapshotAuthorCss();
|
|
1833
|
+
const ui = document.createElement('div');
|
|
1834
|
+
ui.className = 'mob-ui export-hidden';
|
|
1835
|
+
ui.setAttribute('data-miaoda-chrome', '');
|
|
1836
|
+
const back = document.createElement('button');
|
|
1837
|
+
back.className = 'mob-back';
|
|
1838
|
+
back.type = 'button';
|
|
1839
|
+
back.textContent = '‹ 演示模式';
|
|
1840
|
+
back.addEventListener('click', (e) => {
|
|
1841
|
+
e.stopPropagation();
|
|
1842
|
+
this._presenting = false;
|
|
1843
|
+
this._syncMobileMode();
|
|
1844
|
+
this._syncRailHidden();
|
|
1845
|
+
this._syncNotesHidden();
|
|
1846
|
+
this._fit();
|
|
1847
|
+
this._toggleMobUi(false);
|
|
1848
|
+
try { window.parent.postMessage({ type: 'miaoda:deck:presenting-dismissed' }, '*'); } catch (err) {}
|
|
1849
|
+
});
|
|
1850
|
+
const strip = document.createElement('div');
|
|
1851
|
+
strip.className = 'mob-strip';
|
|
1852
|
+
this._mobStripEntries = this._slides
|
|
1853
|
+
.map((slide, i) => ({ slide, i }))
|
|
1854
|
+
.filter((e2) => !e2.slide.hasAttribute('data-deck-skip'))
|
|
1855
|
+
.map((e2, ordinal) => {
|
|
1856
|
+
const t = document.createElement('div');
|
|
1857
|
+
t.className = 'mthumb';
|
|
1858
|
+
const frame = document.createElement('div');
|
|
1859
|
+
frame.className = 'mframe';
|
|
1860
|
+
const num = document.createElement('div');
|
|
1861
|
+
num.className = 'mnum';
|
|
1862
|
+
num.textContent = String(ordinal + 1);
|
|
1863
|
+
t.append(frame, num);
|
|
1864
|
+
t.addEventListener('click', (ev) => {
|
|
1865
|
+
ev.stopPropagation();
|
|
1866
|
+
this._go(e2.i, 'click');
|
|
1867
|
+
this._toggleMobUi(true); // reset the auto-hide timer, keep the overlay up
|
|
1868
|
+
});
|
|
1869
|
+
strip.appendChild(t);
|
|
1870
|
+
// _materialize provides the full clone pipeline (media neutralised,
|
|
1871
|
+
// canvas snapshots) and mounts a host into entry.frame itself — but
|
|
1872
|
+
// it scales clones by the rail's _thumbScale, so rewrite the
|
|
1873
|
+
// transform for this strip's fixed 132px frames.
|
|
1874
|
+
const entry = { slide: e2.slide, frame, host: null, clone: null };
|
|
1875
|
+
this._materialize(entry);
|
|
1876
|
+
if (entry.clone) entry.clone.style.transform = 'scale(' + (132 / this.designWidth) + ')';
|
|
1877
|
+
return { el: t, i: e2.i };
|
|
1878
|
+
});
|
|
1879
|
+
// Clicks inside the overlay must not bubble into tap navigation.
|
|
1880
|
+
ui.addEventListener('click', (e) => e.stopPropagation());
|
|
1881
|
+
ui.append(back, strip);
|
|
1882
|
+
this._stage.appendChild(ui);
|
|
1883
|
+
this._mobUi = ui;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
_syncMobStrip() {
|
|
1887
|
+
(this._mobStripEntries || []).forEach((en) => {
|
|
1888
|
+
en.el.toggleAttribute('data-current', en.i === this._index);
|
|
1889
|
+
if (en.i === this._index) en.el.scrollIntoView({ block: 'nearest', inline: 'center' });
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1440
1893
|
_onKey(e) {
|
|
1441
1894
|
// Ignore when the user is typing.
|
|
1442
1895
|
const t = e.target;
|
|
@@ -1477,6 +1930,18 @@
|
|
|
1477
1930
|
_go(i, reason = 'api') {
|
|
1478
1931
|
if (!this._slides.length) return;
|
|
1479
1932
|
const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
|
|
1933
|
+
// Mobile browse: navigation sets _index directly (desktop parity) and
|
|
1934
|
+
// the scroll is only a visual follow. Inferring the page back from the
|
|
1935
|
+
// scroll position is unreliable here — edge cards' centring targets
|
|
1936
|
+
// clamp to the same scrollTop.
|
|
1937
|
+
if (this.hasAttribute('data-mobile-list')) {
|
|
1938
|
+
const changed = clamped !== this._index;
|
|
1939
|
+
this._index = clamped;
|
|
1940
|
+
// Same-page goto doesn't rebroadcast (desktop parity) but still recentres.
|
|
1941
|
+
if (changed) this._applyIndex({ broadcast: true, reason });
|
|
1942
|
+
this._mobScrollTo(clamped, true);
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1480
1945
|
if (clamped === this._index) return;
|
|
1481
1946
|
this._index = clamped;
|
|
1482
1947
|
this._applyIndex({ broadcast: true, reason });
|
|
@@ -1714,9 +2179,12 @@
|
|
|
1714
2179
|
if (entry.host) return;
|
|
1715
2180
|
const dw = this.designWidth, dh = this.designHeight;
|
|
1716
2181
|
let clone = entry.slide.cloneNode(true);
|
|
1717
|
-
clone
|
|
2182
|
+
// Keep ids: the clone lives in its own shadow root, so duplicate ids
|
|
2183
|
+
// can't clash with the document (getElementById doesn't pierce shadow
|
|
2184
|
+
// roots), while id-keyed author styles (#slide-01 { background: … })
|
|
2185
|
+
// MUST keep matching — stripping ids rendered such decks as blank
|
|
2186
|
+
// white thumbnails (styles all failed to match).
|
|
1718
2187
|
clone.removeAttribute('data-deck-active');
|
|
1719
|
-
clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
|
1720
2188
|
// Neuter heavy media; replace <video> with its poster so the box
|
|
1721
2189
|
// keeps a visual. <iframe>/<audio> become empty placeholders.
|
|
1722
2190
|
clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {
|