@embedpdf/plugin-stage 3.0.0-next.0 → 3.0.0-next.10

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/dist/index.js CHANGED
@@ -1,8 +1,151 @@
1
1
  import { createCapabilityToken, definePlugin } from "@embedpdf/core";
2
- import { InteractionToken } from "@embedpdf/plugin-interaction";
3
2
  import * as S from "@embedpdf/core-stage";
4
3
  import { ZoomMode, ZoomMode as ZoomMode$1 } from "@embedpdf/core-stage";
5
4
  import { addRotations, applyPoint, applyRect, displaySize, intersectRects, pageGeometry, pageTransform, rotateScaleMatrix, snapToDevice } from "@embedpdf/core-geometry";
5
+ //#region src/motion.ts
6
+ /**
7
+ * The camera's MOTION vocabulary — every formula, pure.
8
+ *
9
+ * The capability owns the impure drivers (scheduler frames, dispatch, gesture
10
+ * state); the LAWS those drivers integrate live here, parameterized by dt/t
11
+ * with no state, no scheduler, no dispatch — unit-testable in isolation, and
12
+ * with zero plugin dependencies, liftable into stage-core if the native port
13
+ * ever wants them.
14
+ *
15
+ * Two families:
16
+ * • ballistic — one per-axis law for every free motion: `glideStep` (the
17
+ * touch fling, decaying on UIScrollView's curve) and `springStep` (a
18
+ * critically-damped return to the clamp). A glide that reaches a content
19
+ * edge converts to a spring with its velocity carried in, so the bounce
20
+ * grows out of physics rather than a scripted overshoot.
21
+ * • resistance — the iOS rubber-band curve `rubberOut` and its exact
22
+ * inverse `rubberIn`: dragging `d` px past an edge displays an
23
+ * asymptotically-bounded stretch, and re-entering a gesture over a
24
+ * stretched camera (the mid-bounce catch) reconstructs the
25
+ * finger-integrated position the curve came from.
26
+ * Plus the tween interpolators: `easeOutCubic` for coordinate tweens and
27
+ * `zoomLerp` — GEOMETRIC zoom interpolation (zoom is multiplicative; equal
28
+ * ratios per unit time, not equal deltas — the linear lerp's visible "dip").
29
+ */
30
+ /** Resistance coefficient of the rubber-band curve (the iOS feel). */
31
+ const RUBBER = .55;
32
+ /** Glide decay per ms — UIScrollView's normal deceleration rate. */
33
+ const FLING_DECAY = .998;
34
+ /** rad/ms — critically damped, ~400ms to settle. */
35
+ const SPRING_OMEGA = .015;
36
+ /**
37
+ * Overshoot distance → displayed stretch: asymptotic to the viewport
38
+ * dimension `dim`, so the content never leaves the screen however far the
39
+ * finger travels. `rubberOut(0) === 0` — inside the bounds the curve is
40
+ * invisible.
41
+ */
42
+ const rubberOut = (d, dim) => (1 - 1 / (d * RUBBER / dim + 1)) * dim;
43
+ /** Exact inverse of `rubberOut` (defined for `out < dim`). */
44
+ const rubberIn = (out, dim) => out * dim / (RUBBER * (dim - out));
45
+ /**
46
+ * One frame of free glide: exponential velocity decay, position advanced by
47
+ * the midpoint integral of the decay over `dt` (exact for the exponential —
48
+ * frame-rate independent).
49
+ */
50
+ const glideStep = (p, v, dt) => {
51
+ const k = Math.pow(FLING_DECAY, dt);
52
+ const np = p + v * (dt * (1 + k) / 2);
53
+ const nv = v * k;
54
+ return Math.abs(nv) < .02 ? {
55
+ p: np,
56
+ v: 0,
57
+ done: true
58
+ } : {
59
+ p: np,
60
+ v: nv,
61
+ done: false
62
+ };
63
+ };
64
+ /**
65
+ * One frame of critically-damped spring toward `edge` — semi-implicit Euler
66
+ * on x'' = −ω²(x−e) − 2ωx'. Lands exactly ON the edge (position snapped,
67
+ * velocity zeroed) once inside half a pixel at negligible speed.
68
+ */
69
+ const springStep = (p, v, edge, dt) => {
70
+ const nv = v + (-.015 * SPRING_OMEGA * (p - edge) - 2 * SPRING_OMEGA * v) * dt;
71
+ const np = p + nv * dt;
72
+ if (Math.abs(np - edge) < .5 && Math.abs(nv) < .01) return {
73
+ p: edge,
74
+ v: 0,
75
+ done: true
76
+ };
77
+ return {
78
+ p: np,
79
+ v: nv,
80
+ done: false
81
+ };
82
+ };
83
+ const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
84
+ /**
85
+ * Geometric zoom interpolation: `z0·(z1/z0)^k`. Zoom is multiplicative, so a
86
+ * constant-RATE zoom moves equal ratios per unit time; interpolating the
87
+ * level linearly instead makes the start rush and the end crawl (and, under
88
+ * a focal anchor, swings the anchored point along a curved path).
89
+ */
90
+ const zoomLerp = (z0, z1, k) => z0 * Math.pow(z1 / z0, k);
91
+ //#endregion
92
+ //#region src/responsive.ts
93
+ /**
94
+ * Container queries for the settings bag — the pure half. The capability owns
95
+ * the driver (when to re-resolve, how to react); the LAWS live here: what a
96
+ * box is, when a query matches, and what the effective settings are. No state,
97
+ * no dispatch — property-testable in isolation (the `wheel.ts` convention).
98
+ *
99
+ * The query vocabulary is deliberately just the box: a headless, DOM-free
100
+ * stage knows exactly one environmental fact — the viewport it was told.
101
+ * Anything else (pointer coarseness, platform, app state) is either per-event
102
+ * (`PointerSample.pointerType`) or the app's own business via `update()`.
103
+ */
104
+ /** The reported viewport as a queryable box. A square box is 'portrait' (CSS). */
105
+ const boxOf = (vp) => ({
106
+ width: vp.width,
107
+ height: vp.height,
108
+ orientation: vp.height >= vp.width ? "portrait" : "landscape"
109
+ });
110
+ /** All bounds inclusive, all fields optional, conditions AND-ed (CSS ranges). */
111
+ const matchesQuery = (q, box) => (q.minWidth === void 0 || box.width >= q.minWidth) && (q.maxWidth === void 0 || box.width <= q.maxWidth) && (q.minHeight === void 0 || box.height >= q.minHeight) && (q.maxHeight === void 0 || box.height <= q.maxHeight) && (q.orientation === void 0 || box.orientation === q.orientation);
112
+ const ruleMatches = (rule, box) => typeof rule.when === "function" ? !!rule.when(box) : matchesQuery(rule.when, box);
113
+ /** Merge a patch over settings, skipping undefined values (Partial semantics). */
114
+ const mergeSettings = (into, patch) => {
115
+ const out = { ...into };
116
+ let key;
117
+ for (key in patch) {
118
+ const value = patch[key];
119
+ if (value !== void 0) Object.assign(out, { [key]: value });
120
+ }
121
+ return out;
122
+ };
123
+ /**
124
+ * Setting-value equality: primitives by identity, the flat objects the
125
+ * settings vocabulary uses (`{ px }`, `{ x, y }`, page frames, zoom specs) by
126
+ * one level of own-key comparison. Settings values are never nested deeper.
127
+ */
128
+ const eqSetting = (a, b) => {
129
+ if (a === b) return true;
130
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
131
+ const ka = Object.keys(a);
132
+ const kb = Object.keys(b);
133
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
134
+ };
135
+ const resolveResponsive = (base, rules, box) => {
136
+ let effective = base;
137
+ const active = [];
138
+ for (const rule of rules) {
139
+ if (!ruleMatches(rule, box)) continue;
140
+ if (rule.name !== void 0) active.push(rule.name);
141
+ if (rule.settings) effective = mergeSettings(effective, rule.settings);
142
+ }
143
+ return {
144
+ effective,
145
+ active
146
+ };
147
+ };
148
+ //#endregion
6
149
  //#region src/settings.ts
7
150
  /**
8
151
  * Out-of-the-box defaults — a sensible document-reading feel. They are JUST
@@ -48,6 +191,18 @@ const DEFAULT_SETTINGS = {
48
191
  scrollBehavior: "smooth",
49
192
  viewUnitsPerPoint: 96 / 72
50
193
  };
194
+ /**
195
+ * Out-of-the-box responsive rules — the platform feel with zero configuration:
196
+ * a compact container gets the thin gutter phones use (space, not device — an
197
+ * embedded 500px pane on a desktop is compact too). Override the whole list
198
+ * with `stagePlugin({ responsive: [...] })`; `responsive: []` opts out. The
199
+ * 'compact' name is queryable app-side via `stage.matches('compact')`.
200
+ */
201
+ const DEFAULT_RESPONSIVE = [{
202
+ name: "compact",
203
+ when: { maxWidth: 600 },
204
+ settings: { padding: 4 }
205
+ }];
51
206
  const SETTINGS_EFFECT = {
52
207
  flow: "reflow",
53
208
  layout: "scene",
@@ -86,8 +241,9 @@ const settingsEqual = (a, b) => SETTING_KEYS.every((k) => valueEq(a[k], b[k]));
86
241
  * Layering, stated honestly:
87
242
  * • stage-core — PURE spatial math (no DOM, no time). Ports to Rust later.
88
243
  * • this capability — the IMPURE platform shell. It dispatches, caches, and owns
89
- * the camera tween. It is NOT pure; the one host dependency it has (frame
90
- * timing) enters through an injected Scheduler, never a hidden global.
244
+ * the camera tween. It is NOT pure; its one host dependency (frame timing)
245
+ * enters through the Scheduler seam — injectable, defaulting to the host's
246
+ * own frame clock when one exists (see the seam below).
91
247
  *
92
248
  * The model: EVERY camera move is defined by what it holds fixed.
93
249
  * • gestures (pan/pinch/wheel) — the content under the pointer. Physics, no setting.
@@ -373,12 +529,100 @@ function createStageCapability(ctx, config = {}) {
373
529
  fitAlign: ctx.getState().fitAlign,
374
530
  direction: ctx.getState().direction
375
531
  });
532
+ let placementStarted = false;
533
+ let gestureDepth = 0;
534
+ let gestureZoomed = false;
535
+ let gestureElastic = false;
536
+ let gestureRaw = null;
537
+ const REST_MS = 150;
538
+ let restRaf = 0;
539
+ const armRest = () => {
540
+ if (!canAnimate || !placementStarted) return;
541
+ if (ctx.getState().cameraResting) ctx.dispatch({
542
+ type: "CAMERA_REST",
543
+ resting: false
544
+ });
545
+ if (restRaf) scheduler.caf(restRaf);
546
+ let t0 = 0;
547
+ const tick = (ts) => {
548
+ restRaf = 0;
549
+ if (!t0) t0 = ts;
550
+ if (ts - t0 >= REST_MS) {
551
+ ctx.dispatch({
552
+ type: "CAMERA_REST",
553
+ resting: true
554
+ });
555
+ return;
556
+ }
557
+ restRaf = scheduler.raf(tick);
558
+ };
559
+ restRaf = scheduler.raf(tick);
560
+ };
376
561
  const setCam = (next, bounds = stayBounds()) => {
562
+ const clamped = S.clampCamera(next, bounds, vp(), constraint());
563
+ if (clamped.zoom !== cam().zoom) if (gestureDepth > 0) {
564
+ gestureZoomed = true;
565
+ if (ctx.getState().cameraResting) ctx.dispatch({
566
+ type: "CAMERA_REST",
567
+ resting: false
568
+ });
569
+ if (restRaf) {
570
+ scheduler.caf(restRaf);
571
+ restRaf = 0;
572
+ }
573
+ } else armRest();
377
574
  ctx.dispatch({
378
575
  type: "CAMERA",
379
- camera: S.clampCamera(next, bounds, vp(), constraint())
576
+ camera: clamped
380
577
  });
381
578
  };
579
+ const axisTravels = (origin, content, view, zoom) => !S.travelRange(origin, content, view, zoom, constraint().padding).fits;
580
+ /** Unclamped finger-integrated camera → the DISPLAYED camera: clamp, then
581
+ * re-apply the overshoot through the resistance curve, per travelling axis.
582
+ * Inside the bounds this is exactly the clamp (rubber of zero is zero);
583
+ * on a fitting axis it is exactly the clamp ALWAYS. */
584
+ const rubberize = (raw) => {
585
+ const clamped = S.clampCamera(raw, stayBounds(), vp(), constraint());
586
+ const v = vp();
587
+ const b = stayBounds();
588
+ const axis = (rawA, clampedA, dim, travels) => {
589
+ if (!travels) return clampedA;
590
+ const dWorld = rawA - clampedA;
591
+ if (dWorld === 0) return clampedA;
592
+ const out = rubberOut(Math.abs(dWorld) * raw.zoom, Math.max(1, dim));
593
+ return clampedA + Math.sign(dWorld) * out / raw.zoom;
594
+ };
595
+ return {
596
+ zoom: raw.zoom,
597
+ x: axis(raw.x, clamped.x, v.width, axisTravels(b.x, b.width, v.width, raw.zoom)),
598
+ y: axis(raw.y, clamped.y, v.height, axisTravels(b.y, b.height, v.height, raw.zoom))
599
+ };
600
+ };
601
+ /** Displayed camera → the raw position `rubberize` would have produced it
602
+ * from. Overshoot is capped just under the asymptote so the inverse stays
603
+ * finite whatever state a catch finds the camera in. */
604
+ const unrubberize = (displayed) => {
605
+ const clamped = S.clampCamera(displayed, stayBounds(), vp(), constraint());
606
+ const v = vp();
607
+ const b = stayBounds();
608
+ const axis = (dispA, clampedA, dim, travels) => {
609
+ if (!travels) return clampedA;
610
+ const dWorld = dispA - clampedA;
611
+ if (dWorld === 0) return clampedA;
612
+ const d = Math.max(1, dim);
613
+ const out = Math.min(Math.abs(dWorld) * displayed.zoom, d - 1);
614
+ return clampedA + Math.sign(dWorld) * rubberIn(out, d) / displayed.zoom;
615
+ };
616
+ return {
617
+ zoom: displayed.zoom,
618
+ x: axis(displayed.x, clamped.x, v.width, axisTravels(b.x, b.width, v.width, displayed.zoom)),
619
+ y: axis(displayed.y, clamped.y, v.height, axisTravels(b.y, b.height, v.height, displayed.zoom))
620
+ };
621
+ };
622
+ const setCamRaw = (c) => ctx.dispatch({
623
+ type: "CAMERA",
624
+ camera: c
625
+ });
382
626
  /**
383
627
  * Cursor reconciliation, one direction per interaction:
384
628
  * navigation → cursor is INTENT, set explicitly; the camera honors it as far
@@ -412,7 +656,6 @@ function createStageCapability(ctx, config = {}) {
412
656
  }
413
657
  cancelAnim();
414
658
  const from = cam();
415
- const ease = (t) => 1 - Math.pow(1 - t, 3);
416
659
  let started = false;
417
660
  let t0 = 0;
418
661
  const tick = (now) => {
@@ -420,7 +663,7 @@ function createStageCapability(ctx, config = {}) {
420
663
  started = true;
421
664
  t0 = now;
422
665
  }
423
- const k = ease(Math.min(1, (now - t0) / ms));
666
+ const k = easeOutCubic(Math.min(1, (now - t0) / ms));
424
667
  setCam({
425
668
  x: lerp(from.x, target.x, k),
426
669
  y: lerp(from.y, target.y, k),
@@ -434,6 +677,116 @@ function createStageCapability(ctx, config = {}) {
434
677
  };
435
678
  raf = scheduler.raf(tick);
436
679
  };
680
+ /**
681
+ * Zoom-anchored tween — animates THE INVARIANT, not the coordinates. A
682
+ * camera move is defined by what it holds fixed; for an anchored zoom that
683
+ * is "the focal page-point stays at `pt` while scale changes". Lerping
684
+ * x/y/zoom independently (the plain tween) breaks that mid-flight: holding
685
+ * a screen point requires `x(t) = focal.x − pt.x / zoom(t)` — hyperbolic in
686
+ * the zoom — so linear coordinates swing the tapped point along a curved
687
+ * path (the visible "dip"). Here the zoom interpolates GEOMETRICALLY
688
+ * (constant rate — zoom is multiplicative) and each frame's camera derives
689
+ * from the anchor at that zoom, then clamps: the focal point is stationary
690
+ * by construction, and a zoom-out rails into its fit progressively as the
691
+ * shrinking travel range lets the clamp take over.
692
+ */
693
+ const animateZoomAnchored = (focal, pt, targetZoom, ms = 240, then) => {
694
+ if (!canAnimate) {
695
+ const sc = buildScene();
696
+ if (sc.itemCount) setCam(S.cameraForAnchorAtScreen(focal, sc, pt, targetZoom));
697
+ then?.();
698
+ return;
699
+ }
700
+ cancelAnim();
701
+ const z0 = cam().zoom;
702
+ let started = false;
703
+ let t0 = 0;
704
+ const tick = (now) => {
705
+ if (!started) {
706
+ started = true;
707
+ t0 = now;
708
+ }
709
+ const k = easeOutCubic(Math.min(1, (now - t0) / ms));
710
+ const z = zoomLerp(z0, targetZoom, k);
711
+ const sc = buildScene();
712
+ if (sc.itemCount) setCam(S.cameraForAnchorAtScreen(focal, sc, pt, z));
713
+ if (k < 1) raf = scheduler.raf(tick);
714
+ else {
715
+ raf = 0;
716
+ then?.();
717
+ }
718
+ };
719
+ raf = scheduler.raf(tick);
720
+ };
721
+ const startCameraPhysics = (vxFinger, vyFinger) => {
722
+ if (!canAnimate) {
723
+ setCam(cam());
724
+ return;
725
+ }
726
+ cancelAnim();
727
+ const zoom = cam().zoom;
728
+ const c0 = cam();
729
+ const cl0 = S.clampCamera(c0, stayBounds(), vp(), constraint());
730
+ const b0 = stayBounds();
731
+ const v0 = vp();
732
+ const mk = (camA, clampedA, fingerV) => ({
733
+ p: camA * zoom,
734
+ v: -fingerV / 1e3,
735
+ springing: camA !== clampedA,
736
+ done: false
737
+ });
738
+ const ax = mk(c0.x, cl0.x, axisTravels(b0.x, b0.width, v0.width, zoom) ? vxFinger : 0);
739
+ const ay = mk(c0.y, cl0.y, axisTravels(b0.y, b0.height, v0.height, zoom) ? vyFinger : 0);
740
+ if (!ax.springing && !ay.springing && Math.hypot(ax.v, ay.v) < .02) return;
741
+ let started = false;
742
+ let last = 0;
743
+ const tick = (now) => {
744
+ raf = 0;
745
+ const dt = started ? Math.min(64, Math.max(1, now - last)) : 16;
746
+ started = true;
747
+ last = now;
748
+ const cl = S.clampCamera({
749
+ x: ax.p / zoom,
750
+ y: ay.p / zoom,
751
+ zoom
752
+ }, stayBounds(), vp(), constraint());
753
+ const step = (a, edgeWorld) => {
754
+ if (a.done) return;
755
+ const r = a.springing ? springStep(a.p, a.v, edgeWorld * zoom, dt) : glideStep(a.p, a.v, dt);
756
+ a.p = r.p;
757
+ a.v = r.v;
758
+ a.done = r.done;
759
+ };
760
+ step(ax, cl.x);
761
+ step(ay, cl.y);
762
+ const ncl = S.clampCamera({
763
+ x: ax.p / zoom,
764
+ y: ay.p / zoom,
765
+ zoom
766
+ }, stayBounds(), vp(), constraint());
767
+ const convert = (a, clampedA) => {
768
+ if (!a.springing && Math.abs(clampedA * zoom - a.p) > 1e-6) {
769
+ a.p = clampedA * zoom;
770
+ a.springing = true;
771
+ a.done = false;
772
+ }
773
+ };
774
+ convert(ax, ncl.x);
775
+ convert(ay, ncl.y);
776
+ setCamRaw({
777
+ x: ax.p / zoom,
778
+ y: ay.p / zoom,
779
+ zoom
780
+ });
781
+ syncCursorFromCamera();
782
+ if (ax.done && ay.done) {
783
+ setCam(cam());
784
+ return;
785
+ }
786
+ raf = scheduler.raf(tick);
787
+ };
788
+ raf = scheduler.raf(tick);
789
+ };
437
790
  const anchorAt = (at) => {
438
791
  const sc = buildScene();
439
792
  return sc.itemCount ? S.anchorFromCamera(cam(), sc, vp(), at) : {
@@ -561,8 +914,11 @@ function createStageCapability(ctx, config = {}) {
561
914
  } : unrotatedPoints(box);
562
915
  const c = cam();
563
916
  const ratio = dpr();
564
- const screenX = snapToDevice((box.x - c.x) * c.zoom, ratio);
565
- const screenY = snapToDevice((box.y - c.y) * c.zoom, ratio);
917
+ const rawX = (box.x - c.x) * c.zoom;
918
+ const rawY = (box.y - c.y) * c.zoom;
919
+ const resting = ctx.getState().cameraResting;
920
+ const screenX = resting ? snapToDevice(rawX, ratio) : rawX;
921
+ const screenY = resting ? snapToDevice(rawY, ratio) : rawY;
566
922
  const transform = pageTransform({
567
923
  pageSize,
568
924
  rotation: box.rotation,
@@ -582,7 +938,7 @@ function createStageCapability(ctx, config = {}) {
582
938
  width: transform.viewWidth,
583
939
  height: transform.viewHeight
584
940
  });
585
- const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToPageRect(onScreen) : {
941
+ const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToContentRect(onScreen) : {
586
942
  x: 0,
587
943
  y: 0,
588
944
  width: 0,
@@ -597,8 +953,19 @@ function createStageCapability(ctx, config = {}) {
597
953
  visibleRect
598
954
  };
599
955
  };
956
+ const EMPTY_SCROLL_METRICS = {
957
+ scrollLeft: 0,
958
+ scrollTop: 0,
959
+ scrollWidth: 0,
960
+ scrollHeight: 0,
961
+ clientWidth: 0,
962
+ clientHeight: 0,
963
+ scrollableX: false,
964
+ scrollableY: false
965
+ };
600
966
  let scrollMemo = null;
601
967
  const scrollMetricsNow = () => {
968
+ if (!ctx.getState().placed) return EMPTY_SCROLL_METRICS;
602
969
  const sc = buildScene();
603
970
  const m = S.scrollMetrics(cam(), sc.itemCount ? stayBounds() : {
604
971
  x: 0,
@@ -611,14 +978,16 @@ function createStageCapability(ctx, config = {}) {
611
978
  scrollMemo = m;
612
979
  return m;
613
980
  };
981
+ const EMPTY_VISIBLE_PAGES = [];
614
982
  let visSig = "";
615
983
  let vis = [];
616
984
  const visiblePages = () => {
985
+ if (!ctx.getState().placed) return EMPTY_VISIBLE_PAGES;
617
986
  const c = cam();
618
987
  const v = vp();
619
988
  const sc = buildScene();
620
989
  const items = paged() ? sc.items.slice(0, 1) : sc.query(S.cameraWorldRect(c, v));
621
- const sig = `${sceneCache.key}|${ctx.getState().flow}|${c.x},${c.y},${c.zoom}|${v.width}x${v.height}|${dpr()}`;
990
+ const sig = `${sceneCache.key}|${ctx.getState().flow}|${c.x},${c.y},${c.zoom}|${v.width}x${v.height}|${dpr()}|r${ctx.getState().cameraResting ? 1 : 0}`;
622
991
  if (sig === visSig) return vis;
623
992
  visSig = sig;
624
993
  vis = items.flatMap((it) => it.pages).map(withTransform);
@@ -630,8 +999,73 @@ function createStageCapability(ctx, config = {}) {
630
999
  return out;
631
1000
  };
632
1001
  const snapshotSettings = () => pickSettings(ctx.getState());
1002
+ /**
1003
+ * Settings change, reacted per the registry — the strongest effect among the
1004
+ * touched settings wins: 'reflow' ⊃ 'scene'/'refit' ⊃ 'reclamp' ⊃ 'none'.
1005
+ * The ONE reactive applier: the public `update()` and the responsive driver
1006
+ * both land here, so a breakpoint crossing gets exactly the invariant a
1007
+ * hand-written update would.
1008
+ */
1009
+ const applySettings = (patch) => {
1010
+ cancelAnim();
1011
+ const touched = (effect) => SETTING_KEYS.some((k) => patch[k] !== void 0 && SETTINGS_EFFECT[k] === effect);
1012
+ const align = touched("refit") && !touched("scene") && !touched("reflow") ? ctx.getState().zoomAlign : ctx.getState().anchorAlign;
1013
+ const anchor = anchorAt(alignPoint(align, vp()));
1014
+ ctx.dispatch({
1015
+ type: "PATCH",
1016
+ patch
1017
+ });
1018
+ if (touched("scene")) sceneCache = null;
1019
+ if (touched("reflow")) goToTarget(ctx.getState().cursor, { behavior: "instant" });
1020
+ else if (touched("scene") || touched("refit")) reapply(anchor, align);
1021
+ else if (touched("reclamp")) setCam(cam());
1022
+ };
1023
+ let base = snapshotSettings();
1024
+ let rules = config.responsive ?? DEFAULT_RESPONSIVE;
1025
+ let lastEffective = base;
1026
+ const publishActive = (active) => {
1027
+ const prev = ctx.getState().activeRules;
1028
+ if (prev.length !== active.length || active.some((n, i) => n !== prev[i])) ctx.dispatch({
1029
+ type: "RESPONSIVE",
1030
+ active
1031
+ });
1032
+ };
1033
+ /**
1034
+ * Re-resolve and apply what CHANGED since the last resolution. `react: true`
1035
+ * routes the patch through `applySettings` (full anchor-preserving update);
1036
+ * `react: false` (inside `setViewport`, whose caller runs its own reframe)
1037
+ * just lands the patch + cache invalidation and reports the strongest
1038
+ * effect so the caller can escalate a rule-driven flow flip.
1039
+ */
1040
+ const syncResponsive = (react) => {
1041
+ const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1042
+ const patch = {};
1043
+ for (const k of SETTING_KEYS) if (!eqSetting(effective[k], lastEffective[k])) Object.assign(patch, { [k]: effective[k] });
1044
+ lastEffective = effective;
1045
+ publishActive(active);
1046
+ const keys = Object.keys(patch);
1047
+ if (keys.length === 0) return "none";
1048
+ if (react) {
1049
+ applySettings(patch);
1050
+ return "none";
1051
+ }
1052
+ ctx.dispatch({
1053
+ type: "PATCH",
1054
+ patch
1055
+ });
1056
+ const rank = {
1057
+ none: 0,
1058
+ reclamp: 1,
1059
+ refit: 2,
1060
+ scene: 3,
1061
+ reflow: 4
1062
+ };
1063
+ let strongest = "none";
1064
+ for (const k of keys) if (rank[SETTINGS_EFFECT[k]] > rank[strongest]) strongest = SETTINGS_EFFECT[k];
1065
+ if (strongest === "scene" || strongest === "reflow") sceneCache = null;
1066
+ return strongest;
1067
+ };
633
1068
  const initialViewProviders = [];
634
- let hasPlaced = false;
635
1069
  const api = {
636
1070
  camera: cam,
637
1071
  viewport: vp,
@@ -649,6 +1083,7 @@ function createStageCapability(ctx, config = {}) {
649
1083
  label: p.label ?? null
650
1084
  })),
651
1085
  pageRect: (pon) => {
1086
+ if (!ctx.getState().placed) return null;
652
1087
  const meta = ctx.document();
653
1088
  const index = meta ? meta.pages.findIndex((p) => p.pageObjectNumber === pon) : -1;
654
1089
  if (index < 0) return null;
@@ -663,7 +1098,7 @@ function createStageCapability(ctx, config = {}) {
663
1098
  const ly = screen.y - p.screenY;
664
1099
  if (lx >= 0 && ly >= 0 && lx <= p.transform.viewWidth && ly <= p.transform.viewHeight) return {
665
1100
  pon: p.pon,
666
- point: p.transform.viewToPage({
1101
+ point: p.transform.viewToContent({
667
1102
  x: lx,
668
1103
  y: ly
669
1104
  }),
@@ -677,7 +1112,7 @@ function createStageCapability(ctx, config = {}) {
677
1112
  pointOnPage: (pon, screen) => {
678
1113
  const p = visiblePages().find((v) => v.pon === pon);
679
1114
  if (!p) return null;
680
- return p.transform.viewToPage({
1115
+ return p.transform.viewToContent({
681
1116
  x: screen.x - p.screenX,
682
1117
  y: screen.y - p.screenY
683
1118
  });
@@ -749,11 +1184,12 @@ function createStageCapability(ctx, config = {}) {
749
1184
  anchor: currentAnchor()
750
1185
  }),
751
1186
  setViewport: (v) => {
752
- if (!hasPlaced) {
1187
+ if (!placementStarted) {
753
1188
  ctx.dispatch({
754
1189
  type: "VP",
755
1190
  vp: v
756
1191
  });
1192
+ syncResponsive(false);
757
1193
  if (v.width > 0 && v.height > 0 && (ctx.document()?.pageCount ?? 0) > 0) api.placeInitial();
758
1194
  return;
759
1195
  }
@@ -763,7 +1199,8 @@ function createStageCapability(ctx, config = {}) {
763
1199
  type: "VP",
764
1200
  vp: v
765
1201
  });
766
- reapply(anchor);
1202
+ if (syncResponsive(false) === "reflow") goToTarget(ctx.getState().cursor, { behavior: "instant" });
1203
+ else reapply(anchor);
767
1204
  },
768
1205
  setDevicePixelRatio: (ratio) => {
769
1206
  if (ratio > 0 && ratio !== dpr()) ctx.dispatch({
@@ -778,7 +1215,10 @@ function createStageCapability(ctx, config = {}) {
778
1215
  },
779
1216
  panBy: (dx, dy) => {
780
1217
  cancelAnim();
781
- setCam(S.panByScreen(cam(), dx, dy));
1218
+ if (gestureDepth > 0 && gestureElastic) {
1219
+ gestureRaw = S.panByScreen(gestureRaw ?? unrubberize(cam()), dx, dy);
1220
+ setCamRaw(rubberize(gestureRaw));
1221
+ } else setCam(S.panByScreen(cam(), dx, dy));
782
1222
  syncCursorFromCamera();
783
1223
  },
784
1224
  scrollTo: (opts) => {
@@ -804,14 +1244,72 @@ function createStageCapability(ctx, config = {}) {
804
1244
  const before = buildScene();
805
1245
  const focal = before.itemCount ? S.anchorAtPoint(before, S.toWorld(cam(), pt)) : null;
806
1246
  setCam(S.zoomAround(cam(), pt, factor));
807
- ctx.dispatch({
1247
+ if (gestureDepth === 0) ctx.dispatch({
808
1248
  type: "PATCH",
809
1249
  patch: { zoom: { level: cam().zoom } }
810
1250
  });
811
1251
  const after = buildScene();
812
1252
  if (after !== before && focal && after.itemCount) setCam(S.cameraForAnchorAtScreen(focal, after, pt, cam().zoom));
1253
+ if (gestureDepth > 0 && gestureElastic) gestureRaw = unrubberize(cam());
813
1254
  syncCursorFromCamera();
814
1255
  },
1256
+ beginGesture: (options) => {
1257
+ gestureDepth++;
1258
+ if (gestureDepth === 1) {
1259
+ cancelAnim();
1260
+ gestureZoomed = false;
1261
+ gestureElastic = options?.elastic === true;
1262
+ gestureRaw = null;
1263
+ }
1264
+ },
1265
+ endGesture: () => {
1266
+ if (gestureDepth === 0) return;
1267
+ gestureDepth--;
1268
+ if (gestureDepth > 0) return;
1269
+ gestureRaw = null;
1270
+ const wasElastic = gestureElastic;
1271
+ gestureElastic = false;
1272
+ if (gestureZoomed) {
1273
+ gestureZoomed = false;
1274
+ ctx.dispatch({
1275
+ type: "PATCH",
1276
+ patch: { zoom: { level: cam().zoom } }
1277
+ });
1278
+ armRest();
1279
+ }
1280
+ syncCursorFromCamera();
1281
+ if (wasElastic) {
1282
+ const c = cam();
1283
+ const cl = S.clampCamera(c, stayBounds(), vp(), constraint());
1284
+ if (cl.x !== c.x || cl.y !== c.y) startCameraPhysics(0, 0);
1285
+ }
1286
+ },
1287
+ fling: (vx, vy) => startCameraPhysics(vx, vy),
1288
+ cameraInMotion: () => raf !== 0,
1289
+ doubleTapZoom: (pt) => {
1290
+ cancelAnim();
1291
+ const sc0 = buildScene();
1292
+ if (!sc0.itemCount) return;
1293
+ const item = paged() ? sc0.items[0] : sc0.items[itemIndexOfPage(ctx.getState().cursor)];
1294
+ const fit = fitBox(item);
1295
+ const auto = S.resolveZoom({ mode: S.ZoomMode.Automatic }, fit, vp(), pad());
1296
+ const stops = [
1297
+ auto,
1298
+ S.resolveZoom({ mode: S.ZoomMode.FitWidth }, fit, vp(), pad()),
1299
+ Math.min(auto * 2.5, S.ZOOM_MAX)
1300
+ ].sort((a, b) => a - b).filter((z, i, all) => i === 0 || z > all[i - 1] * 1.1);
1301
+ const near = (z, stop) => z > stop / 1.1 && z < stop * 1.1;
1302
+ const onRung = stops.findIndex((s) => near(cam().zoom, s));
1303
+ const target = onRung >= 0 ? stops[(onRung + 1) % stops.length] : stops[0];
1304
+ ctx.dispatch({
1305
+ type: "PATCH",
1306
+ patch: { zoom: { level: target } }
1307
+ });
1308
+ const sc = buildScene();
1309
+ if (!sc.itemCount) return;
1310
+ const focal = S.anchorAtPoint(sc, S.toWorld(cam(), pt));
1311
+ animateZoomAnchored(focal, pt, target, 240, syncCursorFromCamera);
1312
+ },
815
1313
  zoomIn: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1.2),
816
1314
  zoomOut: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1 / 1.2),
817
1315
  zoomTo: (spec) => api.update({ zoom: spec }),
@@ -820,7 +1318,7 @@ function createStageCapability(ctx, config = {}) {
820
1318
  fitAll: () => api.update({ zoom: { mode: S.ZoomMode.FitAll } }),
821
1319
  automatic: () => api.update({ zoom: { mode: S.ZoomMode.Automatic } }),
822
1320
  refit: () => {
823
- if (!hasPlaced) return;
1321
+ if (!placementStarted) return;
824
1322
  cancelAnim();
825
1323
  reapply(currentAnchor());
826
1324
  },
@@ -829,7 +1327,7 @@ function createStageCapability(ctx, config = {}) {
829
1327
  const doc = ctx.document();
830
1328
  if (!doc || doc.pageCount === 0) return;
831
1329
  const target = Math.max(0, Math.min(pageIndex, doc.pageCount - 1));
832
- if (!(!!opts && (opts.rect !== void 0 || opts.anchor !== void 0 || opts.zoom !== void 0 && opts.zoom !== "keep"))) {
1330
+ if (!(!!opts && (opts.rect != null || opts.anchor !== void 0 || opts.zoom !== void 0 && opts.zoom !== "keep"))) {
833
1331
  if (paged()) {
834
1332
  goToTarget(target, opts);
835
1333
  return;
@@ -895,20 +1393,17 @@ function createStageCapability(ctx, config = {}) {
895
1393
  },
896
1394
  next: (opts) => step(1, opts),
897
1395
  prev: (opts) => step(-1, opts),
1396
+ lensId: () => ctx.id,
898
1397
  update: (patch) => {
899
- cancelAnim();
900
- const touched = (effect) => SETTING_KEYS.some((k) => patch[k] !== void 0 && SETTINGS_EFFECT[k] === effect);
901
- const align = touched("refit") && !touched("scene") && !touched("reflow") ? ctx.getState().zoomAlign : ctx.getState().anchorAlign;
902
- const anchor = anchorAt(alignPoint(align, vp()));
903
- ctx.dispatch({
904
- type: "PATCH",
905
- patch
906
- });
907
- if (touched("scene")) sceneCache = null;
908
- if (touched("reflow")) goToTarget(ctx.getState().cursor, { behavior: "instant" });
909
- else if (touched("scene") || touched("refit")) reapply(anchor, align);
910
- else if (touched("reclamp")) setCam(cam());
1398
+ base = mergeSettings(base, patch);
1399
+ syncResponsive(true);
1400
+ },
1401
+ setResponsive: (next) => {
1402
+ rules = next;
1403
+ syncResponsive(true);
911
1404
  },
1405
+ matches: (name) => ctx.getState().activeRules.includes(name),
1406
+ activeRules: () => ctx.getState().activeRules,
912
1407
  setFlow: (flow) => api.update({ flow }),
913
1408
  setLayout: (layout) => api.update({ layout }),
914
1409
  setSpread: (spread) => api.update({ spread }),
@@ -928,9 +1423,13 @@ function createStageCapability(ctx, config = {}) {
928
1423
  setScrollBehavior: (behavior) => api.update({ scrollBehavior: behavior }),
929
1424
  applyViewState: (view) => {
930
1425
  cancelAnim();
1426
+ base = mergeSettings(base, pickSettings(view));
1427
+ const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1428
+ lastEffective = effective;
1429
+ publishActive(active);
931
1430
  ctx.dispatch({
932
1431
  type: "PATCH",
933
- patch: pickSettings(view)
1432
+ patch: effective
934
1433
  });
935
1434
  ctx.dispatch({
936
1435
  type: "CURSOR",
@@ -946,62 +1445,24 @@ function createStageCapability(ctx, config = {}) {
946
1445
  });
947
1446
  },
948
1447
  placeInitial: () => {
949
- hasPlaced = true;
1448
+ placementStarted = true;
950
1449
  const sorted = [...initialViewProviders].sort((a, b) => b.priority - a.priority);
951
1450
  for (const p of sorted) {
952
1451
  const view = p.fn();
953
1452
  if (view) {
954
1453
  api.applyViewState(view);
1454
+ ctx.dispatch({ type: "PLACED" });
955
1455
  return;
956
1456
  }
957
1457
  }
958
1458
  api.resetView();
1459
+ ctx.dispatch({ type: "PLACED" });
959
1460
  },
960
1461
  resetView: () => goToTarget(0, { behavior: "instant" })
961
1462
  };
962
1463
  return api;
963
1464
  }
964
1465
  //#endregion
965
- //#region src/scroll-handler.ts
966
- /**
967
- * Pan-the-camera as an interaction handler. Live under the built-in `pan` tool
968
- * (the `scroll` tag) and — with `panFallback` — under every tool, where it then
969
- * captures ONLY page-gap drags (on-page downs belong to the active tool, which
970
- * claims them first at higher priority). It uses the sample's viewport delta, so
971
- * it pans over the whole viewport (pages and gaps).
972
- *
973
- * Cursor feel: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
974
- * hovering a gap under a non-pan tool. The pan tool's own resting `grab` comes
975
- * from its `Tool.cursor`, so hovering it needs no claim here.
976
- */
977
- function createScrollHandler(stage, interaction, options = {}) {
978
- const panFallback = options.panFallback ?? true;
979
- let last = {
980
- x: 0,
981
- y: 0
982
- };
983
- const isPanTool = () => interaction.activeTool().enables.has("scroll");
984
- return {
985
- id: "stage-scroll",
986
- priority: 10,
987
- enabledFor: (tool) => tool.enables.has("scroll") || panFallback,
988
- onDown: (s) => {
989
- if (!isPanTool() && s.page) return false;
990
- last = s.viewport;
991
- interaction.setCursor("stage-grab", "grabbing", 40);
992
- return true;
993
- },
994
- onMove: (s) => {
995
- stage.panBy(s.viewport.x - last.x, s.viewport.y - last.y);
996
- last = s.viewport;
997
- },
998
- onUp: () => interaction.setCursor("stage-grab", null),
999
- onHover: (s) => {
1000
- if (panFallback && !isPanTool()) interaction.setCursor("stage-pan-fallback", s.page ? null : "grab", 5);
1001
- }
1002
- };
1003
- }
1004
- //#endregion
1005
1466
  //#region src/effects.ts
1006
1467
  /**
1007
1468
  * The one effect a stage lens needs: when the document's page registry changes
@@ -1024,19 +1485,22 @@ function registerStageEffects(ctx, token) {
1024
1485
  //#endregion
1025
1486
  //#region src/reducer.ts
1026
1487
  const initialStageState = (config) => {
1027
- const { scheduler: _scheduler, ...overrides } = config;
1488
+ const { scheduler: _scheduler, responsive: _responsive, ...overrides } = config;
1028
1489
  return {
1029
1490
  camera: {
1030
1491
  x: 0,
1031
1492
  y: 0,
1032
1493
  zoom: 1
1033
1494
  },
1495
+ placed: false,
1496
+ cameraResting: true,
1034
1497
  vp: {
1035
1498
  width: 0,
1036
1499
  height: 0
1037
1500
  },
1038
1501
  dpr: 1,
1039
1502
  cursor: 0,
1503
+ activeRules: [],
1040
1504
  ...DEFAULT_SETTINGS,
1041
1505
  ...overrides
1042
1506
  };
@@ -1062,6 +1526,14 @@ const stageReducer = (state, a) => {
1062
1526
  ...state,
1063
1527
  camera: a.camera
1064
1528
  };
1529
+ case "CAMERA_REST": return state.cameraResting === a.resting ? state : {
1530
+ ...state,
1531
+ cameraResting: a.resting
1532
+ };
1533
+ case "PLACED": return state.placed ? state : {
1534
+ ...state,
1535
+ placed: true
1536
+ };
1065
1537
  case "VP": return {
1066
1538
  ...state,
1067
1539
  vp: a.vp
@@ -1075,6 +1547,10 @@ const stageReducer = (state, a) => {
1075
1547
  cursor: a.cursor
1076
1548
  };
1077
1549
  case "PATCH": return applyPatch(state, a.patch);
1550
+ case "RESPONSIVE": return {
1551
+ ...state,
1552
+ activeRules: a.active
1553
+ };
1078
1554
  default: return state;
1079
1555
  }
1080
1556
  };
@@ -1089,19 +1565,14 @@ const StageToken = createCapabilityToken("stage");
1089
1565
  * sibling files.
1090
1566
  */
1091
1567
  const stagePlugin = (options = {}) => {
1092
- const { id = "stage", token = StageToken, interaction = false, panFallback = true, ...config } = options;
1568
+ const { id = "stage", token = StageToken, ...config } = options;
1093
1569
  return definePlugin({
1094
1570
  id,
1095
1571
  token,
1096
1572
  scope: "document",
1097
- optional: interaction ? [InteractionToken] : void 0,
1098
1573
  initialState: () => initialStageState(config),
1099
1574
  reduce: stageReducer,
1100
1575
  capability: (ctx) => createStageCapability(ctx, config),
1101
- init: interaction ? (ctx) => {
1102
- const ix = ctx.tryGet(InteractionToken);
1103
- if (ix) ix.registerHandler(createScrollHandler(ctx.get(token), ix, { panFallback }));
1104
- } : void 0,
1105
1576
  effects: (ctx) => registerStageEffects(ctx, token)
1106
1577
  });
1107
1578
  };
@@ -1248,19 +1719,46 @@ function destinationToReveal(dest, layout, bbox) {
1248
1719
  };
1249
1720
  }
1250
1721
  //#endregion
1251
- //#region src/wheel.ts
1252
- /** The per-notch step for discrete wheels — the zoom buttons' ratio. */
1253
- const NOTCH = 1.2;
1254
- /** Zoom factor for a ctrl/cmd wheel event (the caller decides zoom vs pan). */
1255
- function wheelZoomFactor(e) {
1256
- if (e.deltaMode !== 0) return Math.pow(NOTCH, -Math.sign(e.deltaY));
1257
- if (e.ctrlKey) {
1258
- if (Math.abs(e.deltaY) >= 100) return Math.pow(NOTCH, -Math.sign(e.deltaY));
1259
- return Math.exp(-e.deltaY / 100);
1260
- }
1261
- return Math.exp(-e.deltaY * .0015);
1722
+ //#region src/scroll-handler.ts
1723
+ /**
1724
+ * Pan-the-camera as an interaction handler. Live under the built-in `pan` tool
1725
+ * (the `scroll` tag) and — with `panFallback` — under every tool, where it then
1726
+ * captures ONLY page-gap drags (on-page downs belong to the active tool, which
1727
+ * claims them first at higher priority). It uses the sample's viewport delta, so
1728
+ * it pans over the whole viewport (pages and gaps).
1729
+ *
1730
+ * Cursor feel: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
1731
+ * hovering a gap under a non-pan tool. The pan tool's own resting `grab` comes
1732
+ * from its `Tool.cursor`, so hovering it needs no claim here.
1733
+ */
1734
+ function createScrollHandler(stage, interaction, options = {}) {
1735
+ const panFallback = options.panFallback ?? true;
1736
+ let last = {
1737
+ x: 0,
1738
+ y: 0
1739
+ };
1740
+ const isPanTool = () => interaction.activeTool().enables.has("scroll");
1741
+ return {
1742
+ id: "stage-scroll",
1743
+ priority: 10,
1744
+ enabledFor: (tool) => tool.enables.has("scroll") || panFallback,
1745
+ onDown: (s) => {
1746
+ if (!isPanTool() && s.page) return false;
1747
+ last = s.viewport;
1748
+ interaction.setCursor("stage-grab", "grabbing", 40);
1749
+ return true;
1750
+ },
1751
+ onMove: (s) => {
1752
+ stage.panBy(s.viewport.x - last.x, s.viewport.y - last.y);
1753
+ last = s.viewport;
1754
+ },
1755
+ onUp: () => interaction.setCursor("stage-grab", null),
1756
+ onHover: (s) => {
1757
+ if (panFallback && !isPanTool()) interaction.setCursor("stage-pan-fallback", s.page ? null : "grab", 5);
1758
+ }
1759
+ };
1262
1760
  }
1263
1761
  //#endregion
1264
- export { DEFAULT_SETTINGS, StageToken, ZoomMode, destinationToReveal, settingsEqual, stagePlugin, wheelZoomFactor };
1762
+ export { DEFAULT_RESPONSIVE, DEFAULT_SETTINGS, StageToken, ZoomMode, boxOf, createScrollHandler, destinationToReveal, matchesQuery, resolveResponsive, settingsEqual, stagePlugin };
1265
1763
 
1266
1764
  //# sourceMappingURL=index.js.map