@embedpdf/plugin-stage 3.0.0-next.1 → 3.0.0-next.11

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,155 @@
1
- import { createCapabilityToken, definePlugin } from "@embedpdf/core";
2
- import { InteractionToken } from "@embedpdf/plugin-interaction";
1
+ import { destinationToReveal } from "./destination.js";
2
+ import { n as StageToken, t as ZoomMode } from "./contract-CPSX8ANN.js";
3
+ import { definePlugin } from "@embedpdf/core";
4
+ import { ActionsToken } from "@embedpdf/plugin-actions/contract";
5
+ import { ActionsToken as ActionsToken$1 } from "@embedpdf/plugin-actions/contract/host";
3
6
  import * as S from "@embedpdf/core-stage";
4
- import { ZoomMode, ZoomMode as ZoomMode$1 } from "@embedpdf/core-stage";
5
- import { addRotations, applyPoint, applyRect, displaySize, intersectRects, pageGeometry, pageTransform, rotateScaleMatrix, snapToDevice } from "@embedpdf/core-geometry";
7
+ import { ZoomMode as ZoomMode$1 } from "@embedpdf/core-stage";
8
+ import { addRotations, applyPoint, applyRect, displaySize, intersectRects, pageTransform, rotateScaleMatrix, snapToDevice } from "@embedpdf/core-geometry";
9
+ //#region src/motion.ts
10
+ /**
11
+ * The camera's MOTION vocabulary — every formula, pure.
12
+ *
13
+ * The capability owns the impure drivers (scheduler frames, dispatch, gesture
14
+ * state); the LAWS those drivers integrate live here, parameterized by dt/t
15
+ * with no state, no scheduler, no dispatch — unit-testable in isolation, and
16
+ * with zero plugin dependencies, liftable into stage-core if the native port
17
+ * ever wants them.
18
+ *
19
+ * Two families:
20
+ * • ballistic — one per-axis law for every free motion: `glideStep` (the
21
+ * touch fling, decaying on UIScrollView's curve) and `springStep` (a
22
+ * critically-damped return to the clamp). A glide that reaches a content
23
+ * edge converts to a spring with its velocity carried in, so the bounce
24
+ * grows out of physics rather than a scripted overshoot.
25
+ * • resistance — the iOS rubber-band curve `rubberOut` and its exact
26
+ * inverse `rubberIn`: dragging `d` px past an edge displays an
27
+ * asymptotically-bounded stretch, and re-entering a gesture over a
28
+ * stretched camera (the mid-bounce catch) reconstructs the
29
+ * finger-integrated position the curve came from.
30
+ * Plus the tween interpolators: `easeOutCubic` for coordinate tweens and
31
+ * `zoomLerp` — GEOMETRIC zoom interpolation (zoom is multiplicative; equal
32
+ * ratios per unit time, not equal deltas — the linear lerp's visible "dip").
33
+ */
34
+ /** Resistance coefficient of the rubber-band curve (the iOS feel). */
35
+ const RUBBER = .55;
36
+ /** Glide decay per ms — UIScrollView's normal deceleration rate. */
37
+ const FLING_DECAY = .998;
38
+ /** rad/ms — critically damped, ~400ms to settle. */
39
+ const SPRING_OMEGA = .015;
40
+ /**
41
+ * Overshoot distance → displayed stretch: asymptotic to the viewport
42
+ * dimension `dim`, so the content never leaves the screen however far the
43
+ * finger travels. `rubberOut(0) === 0` — inside the bounds the curve is
44
+ * invisible.
45
+ */
46
+ const rubberOut = (d, dim) => (1 - 1 / (d * RUBBER / dim + 1)) * dim;
47
+ /** Exact inverse of `rubberOut` (defined for `out < dim`). */
48
+ const rubberIn = (out, dim) => out * dim / (RUBBER * (dim - out));
49
+ /**
50
+ * One frame of free glide: exponential velocity decay, position advanced by
51
+ * the midpoint integral of the decay over `dt` (exact for the exponential —
52
+ * frame-rate independent).
53
+ */
54
+ const glideStep = (p, v, dt) => {
55
+ const k = Math.pow(FLING_DECAY, dt);
56
+ const np = p + v * (dt * (1 + k) / 2);
57
+ const nv = v * k;
58
+ return Math.abs(nv) < .02 ? {
59
+ p: np,
60
+ v: 0,
61
+ done: true
62
+ } : {
63
+ p: np,
64
+ v: nv,
65
+ done: false
66
+ };
67
+ };
68
+ /**
69
+ * One frame of critically-damped spring toward `edge` — semi-implicit Euler
70
+ * on x'' = −ω²(x−e) − 2ωx'. Lands exactly ON the edge (position snapped,
71
+ * velocity zeroed) once inside half a pixel at negligible speed.
72
+ */
73
+ const springStep = (p, v, edge, dt) => {
74
+ const nv = v + (-.015 * SPRING_OMEGA * (p - edge) - 2 * SPRING_OMEGA * v) * dt;
75
+ const np = p + nv * dt;
76
+ if (Math.abs(np - edge) < .5 && Math.abs(nv) < .01) return {
77
+ p: edge,
78
+ v: 0,
79
+ done: true
80
+ };
81
+ return {
82
+ p: np,
83
+ v: nv,
84
+ done: false
85
+ };
86
+ };
87
+ const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
88
+ /**
89
+ * Geometric zoom interpolation: `z0·(z1/z0)^k`. Zoom is multiplicative, so a
90
+ * constant-RATE zoom moves equal ratios per unit time; interpolating the
91
+ * level linearly instead makes the start rush and the end crawl (and, under
92
+ * a focal anchor, swings the anchored point along a curved path).
93
+ */
94
+ const zoomLerp = (z0, z1, k) => z0 * Math.pow(z1 / z0, k);
95
+ //#endregion
96
+ //#region src/responsive.ts
97
+ /**
98
+ * Container queries for the settings bag — the pure half. The capability owns
99
+ * the driver (when to re-resolve, how to react); the LAWS live here: what a
100
+ * box is, when a query matches, and what the effective settings are. No state,
101
+ * no dispatch — property-testable in isolation (the `wheel.ts` convention).
102
+ *
103
+ * The query vocabulary is deliberately just the box: a headless, DOM-free
104
+ * stage knows exactly one environmental fact — the viewport it was told.
105
+ * Anything else (pointer coarseness, platform, app state) is either per-event
106
+ * (`PointerSample.pointerType`) or the app's own business via `update()`.
107
+ */
108
+ /** The reported viewport as a queryable box. A square box is 'portrait' (CSS). */
109
+ const boxOf = (vp) => ({
110
+ width: vp.width,
111
+ height: vp.height,
112
+ orientation: vp.height >= vp.width ? "portrait" : "landscape"
113
+ });
114
+ /** All bounds inclusive, all fields optional, conditions AND-ed (CSS ranges). */
115
+ 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);
116
+ const ruleMatches = (rule, box) => typeof rule.when === "function" ? !!rule.when(box) : matchesQuery(rule.when, box);
117
+ /** Merge a patch over settings, skipping undefined values (Partial semantics). */
118
+ const mergeSettings = (into, patch) => {
119
+ const out = { ...into };
120
+ let key;
121
+ for (key in patch) {
122
+ const value = patch[key];
123
+ if (value !== void 0) Object.assign(out, { [key]: value });
124
+ }
125
+ return out;
126
+ };
127
+ /**
128
+ * Setting-value equality: primitives by identity, the flat objects the
129
+ * settings vocabulary uses (`{ px }`, `{ x, y }`, page frames, zoom specs) by
130
+ * one level of own-key comparison. Settings values are never nested deeper.
131
+ */
132
+ const eqSetting = (a, b) => {
133
+ if (a === b) return true;
134
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
135
+ const ka = Object.keys(a);
136
+ const kb = Object.keys(b);
137
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
138
+ };
139
+ const resolveResponsive = (base, rules, box) => {
140
+ let effective = base;
141
+ const active = [];
142
+ for (const rule of rules) {
143
+ if (!ruleMatches(rule, box)) continue;
144
+ if (rule.name !== void 0) active.push(rule.name);
145
+ if (rule.settings) effective = mergeSettings(effective, rule.settings);
146
+ }
147
+ return {
148
+ effective,
149
+ active
150
+ };
151
+ };
152
+ //#endregion
6
153
  //#region src/settings.ts
7
154
  /**
8
155
  * Out-of-the-box defaults — a sensible document-reading feel. They are JUST
@@ -48,6 +195,18 @@ const DEFAULT_SETTINGS = {
48
195
  scrollBehavior: "smooth",
49
196
  viewUnitsPerPoint: 96 / 72
50
197
  };
198
+ /**
199
+ * Out-of-the-box responsive rules — the platform feel with zero configuration:
200
+ * a compact container gets the thin gutter phones use (space, not device — an
201
+ * embedded 500px pane on a desktop is compact too). Override the whole list
202
+ * with `stagePlugin({ responsive: [...] })`; `responsive: []` opts out. The
203
+ * 'compact' name is queryable app-side via `stage.matches('compact')`.
204
+ */
205
+ const DEFAULT_RESPONSIVE = [{
206
+ name: "compact",
207
+ when: { maxWidth: 600 },
208
+ settings: { padding: 4 }
209
+ }];
51
210
  const SETTINGS_EFFECT = {
52
211
  flow: "reflow",
53
212
  layout: "scene",
@@ -86,8 +245,9 @@ const settingsEqual = (a, b) => SETTING_KEYS.every((k) => valueEq(a[k], b[k]));
86
245
  * Layering, stated honestly:
87
246
  * • stage-core — PURE spatial math (no DOM, no time). Ports to Rust later.
88
247
  * • 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.
248
+ * the camera tween. It is NOT pure; its one host dependency (frame timing)
249
+ * enters through the Scheduler seam — injectable, defaulting to the host's
250
+ * own frame clock when one exists (see the seam below).
91
251
  *
92
252
  * The model: EVERY camera move is defined by what it holds fixed.
93
253
  * • gestures (pan/pinch/wheel) — the content under the pointer. Physics, no setting.
@@ -373,12 +533,100 @@ function createStageCapability(ctx, config = {}) {
373
533
  fitAlign: ctx.getState().fitAlign,
374
534
  direction: ctx.getState().direction
375
535
  });
536
+ let placementStarted = false;
537
+ let gestureDepth = 0;
538
+ let gestureZoomed = false;
539
+ let gestureElastic = false;
540
+ let gestureRaw = null;
541
+ const REST_MS = 150;
542
+ let restRaf = 0;
543
+ const armRest = () => {
544
+ if (!canAnimate || !placementStarted) return;
545
+ if (ctx.getState().cameraResting) ctx.dispatch({
546
+ type: "CAMERA_REST",
547
+ resting: false
548
+ });
549
+ if (restRaf) scheduler.caf(restRaf);
550
+ let t0 = 0;
551
+ const tick = (ts) => {
552
+ restRaf = 0;
553
+ if (!t0) t0 = ts;
554
+ if (ts - t0 >= REST_MS) {
555
+ ctx.dispatch({
556
+ type: "CAMERA_REST",
557
+ resting: true
558
+ });
559
+ return;
560
+ }
561
+ restRaf = scheduler.raf(tick);
562
+ };
563
+ restRaf = scheduler.raf(tick);
564
+ };
376
565
  const setCam = (next, bounds = stayBounds()) => {
566
+ const clamped = S.clampCamera(next, bounds, vp(), constraint());
567
+ if (clamped.zoom !== cam().zoom) if (gestureDepth > 0) {
568
+ gestureZoomed = true;
569
+ if (ctx.getState().cameraResting) ctx.dispatch({
570
+ type: "CAMERA_REST",
571
+ resting: false
572
+ });
573
+ if (restRaf) {
574
+ scheduler.caf(restRaf);
575
+ restRaf = 0;
576
+ }
577
+ } else armRest();
377
578
  ctx.dispatch({
378
579
  type: "CAMERA",
379
- camera: S.clampCamera(next, bounds, vp(), constraint())
580
+ camera: clamped
380
581
  });
381
582
  };
583
+ const axisTravels = (origin, content, view, zoom) => !S.travelRange(origin, content, view, zoom, constraint().padding).fits;
584
+ /** Unclamped finger-integrated camera → the DISPLAYED camera: clamp, then
585
+ * re-apply the overshoot through the resistance curve, per travelling axis.
586
+ * Inside the bounds this is exactly the clamp (rubber of zero is zero);
587
+ * on a fitting axis it is exactly the clamp ALWAYS. */
588
+ const rubberize = (raw) => {
589
+ const clamped = S.clampCamera(raw, stayBounds(), vp(), constraint());
590
+ const v = vp();
591
+ const b = stayBounds();
592
+ const axis = (rawA, clampedA, dim, travels) => {
593
+ if (!travels) return clampedA;
594
+ const dWorld = rawA - clampedA;
595
+ if (dWorld === 0) return clampedA;
596
+ const out = rubberOut(Math.abs(dWorld) * raw.zoom, Math.max(1, dim));
597
+ return clampedA + Math.sign(dWorld) * out / raw.zoom;
598
+ };
599
+ return {
600
+ zoom: raw.zoom,
601
+ x: axis(raw.x, clamped.x, v.width, axisTravels(b.x, b.width, v.width, raw.zoom)),
602
+ y: axis(raw.y, clamped.y, v.height, axisTravels(b.y, b.height, v.height, raw.zoom))
603
+ };
604
+ };
605
+ /** Displayed camera → the raw position `rubberize` would have produced it
606
+ * from. Overshoot is capped just under the asymptote so the inverse stays
607
+ * finite whatever state a catch finds the camera in. */
608
+ const unrubberize = (displayed) => {
609
+ const clamped = S.clampCamera(displayed, stayBounds(), vp(), constraint());
610
+ const v = vp();
611
+ const b = stayBounds();
612
+ const axis = (dispA, clampedA, dim, travels) => {
613
+ if (!travels) return clampedA;
614
+ const dWorld = dispA - clampedA;
615
+ if (dWorld === 0) return clampedA;
616
+ const d = Math.max(1, dim);
617
+ const out = Math.min(Math.abs(dWorld) * displayed.zoom, d - 1);
618
+ return clampedA + Math.sign(dWorld) * rubberIn(out, d) / displayed.zoom;
619
+ };
620
+ return {
621
+ zoom: displayed.zoom,
622
+ x: axis(displayed.x, clamped.x, v.width, axisTravels(b.x, b.width, v.width, displayed.zoom)),
623
+ y: axis(displayed.y, clamped.y, v.height, axisTravels(b.y, b.height, v.height, displayed.zoom))
624
+ };
625
+ };
626
+ const setCamRaw = (c) => ctx.dispatch({
627
+ type: "CAMERA",
628
+ camera: c
629
+ });
382
630
  /**
383
631
  * Cursor reconciliation, one direction per interaction:
384
632
  * navigation → cursor is INTENT, set explicitly; the camera honors it as far
@@ -412,7 +660,6 @@ function createStageCapability(ctx, config = {}) {
412
660
  }
413
661
  cancelAnim();
414
662
  const from = cam();
415
- const ease = (t) => 1 - Math.pow(1 - t, 3);
416
663
  let started = false;
417
664
  let t0 = 0;
418
665
  const tick = (now) => {
@@ -420,7 +667,7 @@ function createStageCapability(ctx, config = {}) {
420
667
  started = true;
421
668
  t0 = now;
422
669
  }
423
- const k = ease(Math.min(1, (now - t0) / ms));
670
+ const k = easeOutCubic(Math.min(1, (now - t0) / ms));
424
671
  setCam({
425
672
  x: lerp(from.x, target.x, k),
426
673
  y: lerp(from.y, target.y, k),
@@ -434,6 +681,116 @@ function createStageCapability(ctx, config = {}) {
434
681
  };
435
682
  raf = scheduler.raf(tick);
436
683
  };
684
+ /**
685
+ * Zoom-anchored tween — animates THE INVARIANT, not the coordinates. A
686
+ * camera move is defined by what it holds fixed; for an anchored zoom that
687
+ * is "the focal page-point stays at `pt` while scale changes". Lerping
688
+ * x/y/zoom independently (the plain tween) breaks that mid-flight: holding
689
+ * a screen point requires `x(t) = focal.x − pt.x / zoom(t)` — hyperbolic in
690
+ * the zoom — so linear coordinates swing the tapped point along a curved
691
+ * path (the visible "dip"). Here the zoom interpolates GEOMETRICALLY
692
+ * (constant rate — zoom is multiplicative) and each frame's camera derives
693
+ * from the anchor at that zoom, then clamps: the focal point is stationary
694
+ * by construction, and a zoom-out rails into its fit progressively as the
695
+ * shrinking travel range lets the clamp take over.
696
+ */
697
+ const animateZoomAnchored = (focal, pt, targetZoom, ms = 240, then) => {
698
+ if (!canAnimate) {
699
+ const sc = buildScene();
700
+ if (sc.itemCount) setCam(S.cameraForAnchorAtScreen(focal, sc, pt, targetZoom));
701
+ then?.();
702
+ return;
703
+ }
704
+ cancelAnim();
705
+ const z0 = cam().zoom;
706
+ let started = false;
707
+ let t0 = 0;
708
+ const tick = (now) => {
709
+ if (!started) {
710
+ started = true;
711
+ t0 = now;
712
+ }
713
+ const k = easeOutCubic(Math.min(1, (now - t0) / ms));
714
+ const z = zoomLerp(z0, targetZoom, k);
715
+ const sc = buildScene();
716
+ if (sc.itemCount) setCam(S.cameraForAnchorAtScreen(focal, sc, pt, z));
717
+ if (k < 1) raf = scheduler.raf(tick);
718
+ else {
719
+ raf = 0;
720
+ then?.();
721
+ }
722
+ };
723
+ raf = scheduler.raf(tick);
724
+ };
725
+ const startCameraPhysics = (vxFinger, vyFinger) => {
726
+ if (!canAnimate) {
727
+ setCam(cam());
728
+ return;
729
+ }
730
+ cancelAnim();
731
+ const zoom = cam().zoom;
732
+ const c0 = cam();
733
+ const cl0 = S.clampCamera(c0, stayBounds(), vp(), constraint());
734
+ const b0 = stayBounds();
735
+ const v0 = vp();
736
+ const mk = (camA, clampedA, fingerV) => ({
737
+ p: camA * zoom,
738
+ v: -fingerV / 1e3,
739
+ springing: camA !== clampedA,
740
+ done: false
741
+ });
742
+ const ax = mk(c0.x, cl0.x, axisTravels(b0.x, b0.width, v0.width, zoom) ? vxFinger : 0);
743
+ const ay = mk(c0.y, cl0.y, axisTravels(b0.y, b0.height, v0.height, zoom) ? vyFinger : 0);
744
+ if (!ax.springing && !ay.springing && Math.hypot(ax.v, ay.v) < .02) return;
745
+ let started = false;
746
+ let last = 0;
747
+ const tick = (now) => {
748
+ raf = 0;
749
+ const dt = started ? Math.min(64, Math.max(1, now - last)) : 16;
750
+ started = true;
751
+ last = now;
752
+ const cl = S.clampCamera({
753
+ x: ax.p / zoom,
754
+ y: ay.p / zoom,
755
+ zoom
756
+ }, stayBounds(), vp(), constraint());
757
+ const step = (a, edgeWorld) => {
758
+ if (a.done) return;
759
+ const r = a.springing ? springStep(a.p, a.v, edgeWorld * zoom, dt) : glideStep(a.p, a.v, dt);
760
+ a.p = r.p;
761
+ a.v = r.v;
762
+ a.done = r.done;
763
+ };
764
+ step(ax, cl.x);
765
+ step(ay, cl.y);
766
+ const ncl = S.clampCamera({
767
+ x: ax.p / zoom,
768
+ y: ay.p / zoom,
769
+ zoom
770
+ }, stayBounds(), vp(), constraint());
771
+ const convert = (a, clampedA) => {
772
+ if (!a.springing && Math.abs(clampedA * zoom - a.p) > 1e-6) {
773
+ a.p = clampedA * zoom;
774
+ a.springing = true;
775
+ a.done = false;
776
+ }
777
+ };
778
+ convert(ax, ncl.x);
779
+ convert(ay, ncl.y);
780
+ setCamRaw({
781
+ x: ax.p / zoom,
782
+ y: ay.p / zoom,
783
+ zoom
784
+ });
785
+ syncCursorFromCamera();
786
+ if (ax.done && ay.done) {
787
+ setCam(cam());
788
+ return;
789
+ }
790
+ raf = scheduler.raf(tick);
791
+ };
792
+ raf = scheduler.raf(tick);
793
+ };
437
794
  const anchorAt = (at) => {
438
795
  const sc = buildScene();
439
796
  return sc.itemCount ? S.anchorFromCamera(cam(), sc, vp(), at) : {
@@ -478,7 +835,16 @@ function createStageCapability(ctx, config = {}) {
478
835
  if (isFitAll()) goToTarget(ctx.getState().cursor, { behavior: "instant" });
479
836
  else applyAnchor(anchor, align);
480
837
  };
838
+ /** Tag what drives the NEXT camera/cursor change — dispatched on flips
839
+ * only, read by the page-state feed (see StageState.motionCause). */
840
+ const markCause = (cause) => {
841
+ if (ctx.getState().motionCause !== cause) ctx.dispatch({
842
+ type: "MOTION_CAUSE",
843
+ cause
844
+ });
845
+ };
481
846
  const goToTarget = (pageIndex, opts) => {
847
+ markCause("programmatic");
482
848
  cancelAnim();
483
849
  const doc = ctx.document();
484
850
  if (!doc || doc.pageCount === 0) return;
@@ -561,8 +927,11 @@ function createStageCapability(ctx, config = {}) {
561
927
  } : unrotatedPoints(box);
562
928
  const c = cam();
563
929
  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);
930
+ const rawX = (box.x - c.x) * c.zoom;
931
+ const rawY = (box.y - c.y) * c.zoom;
932
+ const resting = ctx.getState().cameraResting;
933
+ const screenX = resting ? snapToDevice(rawX, ratio) : rawX;
934
+ const screenY = resting ? snapToDevice(rawY, ratio) : rawY;
566
935
  const transform = pageTransform({
567
936
  pageSize,
568
937
  rotation: box.rotation,
@@ -582,7 +951,7 @@ function createStageCapability(ctx, config = {}) {
582
951
  width: transform.viewWidth,
583
952
  height: transform.viewHeight
584
953
  });
585
- const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToPageRect(onScreen) : {
954
+ const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToContentRect(onScreen) : {
586
955
  x: 0,
587
956
  y: 0,
588
957
  width: 0,
@@ -597,8 +966,19 @@ function createStageCapability(ctx, config = {}) {
597
966
  visibleRect
598
967
  };
599
968
  };
969
+ const EMPTY_SCROLL_METRICS = {
970
+ scrollLeft: 0,
971
+ scrollTop: 0,
972
+ scrollWidth: 0,
973
+ scrollHeight: 0,
974
+ clientWidth: 0,
975
+ clientHeight: 0,
976
+ scrollableX: false,
977
+ scrollableY: false
978
+ };
600
979
  let scrollMemo = null;
601
980
  const scrollMetricsNow = () => {
981
+ if (!ctx.getState().placed) return EMPTY_SCROLL_METRICS;
602
982
  const sc = buildScene();
603
983
  const m = S.scrollMetrics(cam(), sc.itemCount ? stayBounds() : {
604
984
  x: 0,
@@ -611,14 +991,16 @@ function createStageCapability(ctx, config = {}) {
611
991
  scrollMemo = m;
612
992
  return m;
613
993
  };
994
+ const EMPTY_VISIBLE_PAGES = [];
614
995
  let visSig = "";
615
996
  let vis = [];
616
997
  const visiblePages = () => {
998
+ if (!ctx.getState().placed) return EMPTY_VISIBLE_PAGES;
617
999
  const c = cam();
618
1000
  const v = vp();
619
1001
  const sc = buildScene();
620
1002
  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()}`;
1003
+ 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
1004
  if (sig === visSig) return vis;
623
1005
  visSig = sig;
624
1006
  vis = items.flatMap((it) => it.pages).map(withTransform);
@@ -630,8 +1012,73 @@ function createStageCapability(ctx, config = {}) {
630
1012
  return out;
631
1013
  };
632
1014
  const snapshotSettings = () => pickSettings(ctx.getState());
1015
+ /**
1016
+ * Settings change, reacted per the registry — the strongest effect among the
1017
+ * touched settings wins: 'reflow' ⊃ 'scene'/'refit' ⊃ 'reclamp' ⊃ 'none'.
1018
+ * The ONE reactive applier: the public `update()` and the responsive driver
1019
+ * both land here, so a breakpoint crossing gets exactly the invariant a
1020
+ * hand-written update would.
1021
+ */
1022
+ const applySettings = (patch) => {
1023
+ cancelAnim();
1024
+ const touched = (effect) => SETTING_KEYS.some((k) => patch[k] !== void 0 && SETTINGS_EFFECT[k] === effect);
1025
+ const align = touched("refit") && !touched("scene") && !touched("reflow") ? ctx.getState().zoomAlign : ctx.getState().anchorAlign;
1026
+ const anchor = anchorAt(alignPoint(align, vp()));
1027
+ ctx.dispatch({
1028
+ type: "PATCH",
1029
+ patch
1030
+ });
1031
+ if (touched("scene")) sceneCache = null;
1032
+ if (touched("reflow")) goToTarget(ctx.getState().cursor, { behavior: "instant" });
1033
+ else if (touched("scene") || touched("refit")) reapply(anchor, align);
1034
+ else if (touched("reclamp")) setCam(cam());
1035
+ };
1036
+ let base = snapshotSettings();
1037
+ let rules = config.responsive ?? DEFAULT_RESPONSIVE;
1038
+ let lastEffective = base;
1039
+ const publishActive = (active) => {
1040
+ const prev = ctx.getState().activeRules;
1041
+ if (prev.length !== active.length || active.some((n, i) => n !== prev[i])) ctx.dispatch({
1042
+ type: "RESPONSIVE",
1043
+ active
1044
+ });
1045
+ };
1046
+ /**
1047
+ * Re-resolve and apply what CHANGED since the last resolution. `react: true`
1048
+ * routes the patch through `applySettings` (full anchor-preserving update);
1049
+ * `react: false` (inside `setViewport`, whose caller runs its own reframe)
1050
+ * just lands the patch + cache invalidation and reports the strongest
1051
+ * effect so the caller can escalate a rule-driven flow flip.
1052
+ */
1053
+ const syncResponsive = (react) => {
1054
+ const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1055
+ const patch = {};
1056
+ for (const k of SETTING_KEYS) if (!eqSetting(effective[k], lastEffective[k])) Object.assign(patch, { [k]: effective[k] });
1057
+ lastEffective = effective;
1058
+ publishActive(active);
1059
+ const keys = Object.keys(patch);
1060
+ if (keys.length === 0) return "none";
1061
+ if (react) {
1062
+ applySettings(patch);
1063
+ return "none";
1064
+ }
1065
+ ctx.dispatch({
1066
+ type: "PATCH",
1067
+ patch
1068
+ });
1069
+ const rank = {
1070
+ none: 0,
1071
+ reclamp: 1,
1072
+ refit: 2,
1073
+ scene: 3,
1074
+ reflow: 4
1075
+ };
1076
+ let strongest = "none";
1077
+ for (const k of keys) if (rank[SETTINGS_EFFECT[k]] > rank[strongest]) strongest = SETTINGS_EFFECT[k];
1078
+ if (strongest === "scene" || strongest === "reflow") sceneCache = null;
1079
+ return strongest;
1080
+ };
633
1081
  const initialViewProviders = [];
634
- let hasPlaced = false;
635
1082
  const api = {
636
1083
  camera: cam,
637
1084
  viewport: vp,
@@ -649,6 +1096,7 @@ function createStageCapability(ctx, config = {}) {
649
1096
  label: p.label ?? null
650
1097
  })),
651
1098
  pageRect: (pon) => {
1099
+ if (!ctx.getState().placed) return null;
652
1100
  const meta = ctx.document();
653
1101
  const index = meta ? meta.pages.findIndex((p) => p.pageObjectNumber === pon) : -1;
654
1102
  if (index < 0) return null;
@@ -663,7 +1111,7 @@ function createStageCapability(ctx, config = {}) {
663
1111
  const ly = screen.y - p.screenY;
664
1112
  if (lx >= 0 && ly >= 0 && lx <= p.transform.viewWidth && ly <= p.transform.viewHeight) return {
665
1113
  pon: p.pon,
666
- point: p.transform.viewToPage({
1114
+ point: p.transform.viewToContent({
667
1115
  x: lx,
668
1116
  y: ly
669
1117
  }),
@@ -677,7 +1125,7 @@ function createStageCapability(ctx, config = {}) {
677
1125
  pointOnPage: (pon, screen) => {
678
1126
  const p = visiblePages().find((v) => v.pon === pon);
679
1127
  if (!p) return null;
680
- return p.transform.viewToPage({
1128
+ return p.transform.viewToContent({
681
1129
  x: screen.x - p.screenX,
682
1130
  y: screen.y - p.screenY
683
1131
  });
@@ -749,11 +1197,12 @@ function createStageCapability(ctx, config = {}) {
749
1197
  anchor: currentAnchor()
750
1198
  }),
751
1199
  setViewport: (v) => {
752
- if (!hasPlaced) {
1200
+ if (!placementStarted) {
753
1201
  ctx.dispatch({
754
1202
  type: "VP",
755
1203
  vp: v
756
1204
  });
1205
+ syncResponsive(false);
757
1206
  if (v.width > 0 && v.height > 0 && (ctx.document()?.pageCount ?? 0) > 0) api.placeInitial();
758
1207
  return;
759
1208
  }
@@ -763,7 +1212,8 @@ function createStageCapability(ctx, config = {}) {
763
1212
  type: "VP",
764
1213
  vp: v
765
1214
  });
766
- reapply(anchor);
1215
+ if (syncResponsive(false) === "reflow") goToTarget(ctx.getState().cursor, { behavior: "instant" });
1216
+ else reapply(anchor);
767
1217
  },
768
1218
  setDevicePixelRatio: (ratio) => {
769
1219
  if (ratio > 0 && ratio !== dpr()) ctx.dispatch({
@@ -772,16 +1222,22 @@ function createStageCapability(ctx, config = {}) {
772
1222
  });
773
1223
  },
774
1224
  setCamera: (c) => {
1225
+ markCause("user");
775
1226
  cancelAnim();
776
1227
  setCam(c);
777
1228
  syncCursorFromCamera();
778
1229
  },
779
1230
  panBy: (dx, dy) => {
1231
+ markCause("user");
780
1232
  cancelAnim();
781
- setCam(S.panByScreen(cam(), dx, dy));
1233
+ if (gestureDepth > 0 && gestureElastic) {
1234
+ gestureRaw = S.panByScreen(gestureRaw ?? unrubberize(cam()), dx, dy);
1235
+ setCamRaw(rubberize(gestureRaw));
1236
+ } else setCam(S.panByScreen(cam(), dx, dy));
782
1237
  syncCursorFromCamera();
783
1238
  },
784
1239
  scrollTo: (opts) => {
1240
+ markCause("user");
785
1241
  cancelAnim();
786
1242
  if (!buildScene().itemCount) return;
787
1243
  const next = S.cameraFromScroll(cam(), stayBounds(), vp(), pad(), opts);
@@ -804,13 +1260,71 @@ function createStageCapability(ctx, config = {}) {
804
1260
  const before = buildScene();
805
1261
  const focal = before.itemCount ? S.anchorAtPoint(before, S.toWorld(cam(), pt)) : null;
806
1262
  setCam(S.zoomAround(cam(), pt, factor));
807
- ctx.dispatch({
1263
+ if (gestureDepth === 0) ctx.dispatch({
808
1264
  type: "PATCH",
809
1265
  patch: { zoom: { level: cam().zoom } }
810
1266
  });
811
1267
  const after = buildScene();
812
1268
  if (after !== before && focal && after.itemCount) setCam(S.cameraForAnchorAtScreen(focal, after, pt, cam().zoom));
1269
+ if (gestureDepth > 0 && gestureElastic) gestureRaw = unrubberize(cam());
1270
+ syncCursorFromCamera();
1271
+ },
1272
+ beginGesture: (options) => {
1273
+ gestureDepth++;
1274
+ if (gestureDepth === 1) {
1275
+ cancelAnim();
1276
+ gestureZoomed = false;
1277
+ gestureElastic = options?.elastic === true;
1278
+ gestureRaw = null;
1279
+ }
1280
+ },
1281
+ endGesture: () => {
1282
+ if (gestureDepth === 0) return;
1283
+ gestureDepth--;
1284
+ if (gestureDepth > 0) return;
1285
+ gestureRaw = null;
1286
+ const wasElastic = gestureElastic;
1287
+ gestureElastic = false;
1288
+ if (gestureZoomed) {
1289
+ gestureZoomed = false;
1290
+ ctx.dispatch({
1291
+ type: "PATCH",
1292
+ patch: { zoom: { level: cam().zoom } }
1293
+ });
1294
+ armRest();
1295
+ }
813
1296
  syncCursorFromCamera();
1297
+ if (wasElastic) {
1298
+ const c = cam();
1299
+ const cl = S.clampCamera(c, stayBounds(), vp(), constraint());
1300
+ if (cl.x !== c.x || cl.y !== c.y) startCameraPhysics(0, 0);
1301
+ }
1302
+ },
1303
+ fling: (vx, vy) => startCameraPhysics(vx, vy),
1304
+ cameraInMotion: () => raf !== 0,
1305
+ doubleTapZoom: (pt) => {
1306
+ cancelAnim();
1307
+ const sc0 = buildScene();
1308
+ if (!sc0.itemCount) return;
1309
+ const item = paged() ? sc0.items[0] : sc0.items[itemIndexOfPage(ctx.getState().cursor)];
1310
+ const fit = fitBox(item);
1311
+ const auto = S.resolveZoom({ mode: S.ZoomMode.Automatic }, fit, vp(), pad());
1312
+ const stops = [
1313
+ auto,
1314
+ S.resolveZoom({ mode: S.ZoomMode.FitWidth }, fit, vp(), pad()),
1315
+ Math.min(auto * 2.5, S.ZOOM_MAX)
1316
+ ].sort((a, b) => a - b).filter((z, i, all) => i === 0 || z > all[i - 1] * 1.1);
1317
+ const near = (z, stop) => z > stop / 1.1 && z < stop * 1.1;
1318
+ const onRung = stops.findIndex((s) => near(cam().zoom, s));
1319
+ const target = onRung >= 0 ? stops[(onRung + 1) % stops.length] : stops[0];
1320
+ ctx.dispatch({
1321
+ type: "PATCH",
1322
+ patch: { zoom: { level: target } }
1323
+ });
1324
+ const sc = buildScene();
1325
+ if (!sc.itemCount) return;
1326
+ const focal = S.anchorAtPoint(sc, S.toWorld(cam(), pt));
1327
+ animateZoomAnchored(focal, pt, target, 240, syncCursorFromCamera);
814
1328
  },
815
1329
  zoomIn: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1.2),
816
1330
  zoomOut: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1 / 1.2),
@@ -820,16 +1334,17 @@ function createStageCapability(ctx, config = {}) {
820
1334
  fitAll: () => api.update({ zoom: { mode: S.ZoomMode.FitAll } }),
821
1335
  automatic: () => api.update({ zoom: { mode: S.ZoomMode.Automatic } }),
822
1336
  refit: () => {
823
- if (!hasPlaced) return;
1337
+ if (!placementStarted) return;
824
1338
  cancelAnim();
825
1339
  reapply(currentAnchor());
826
1340
  },
827
1341
  goToPage: (pageIndex, opts) => goToTarget(pageIndex, opts),
828
1342
  reveal: (pageIndex, opts) => {
1343
+ markCause("programmatic");
829
1344
  const doc = ctx.document();
830
1345
  if (!doc || doc.pageCount === 0) return;
831
1346
  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"))) {
1347
+ if (!(!!opts && (opts.rect != null || opts.anchor !== void 0 || opts.zoom !== void 0 && opts.zoom !== "keep"))) {
833
1348
  if (paged()) {
834
1349
  goToTarget(target, opts);
835
1350
  return;
@@ -895,20 +1410,17 @@ function createStageCapability(ctx, config = {}) {
895
1410
  },
896
1411
  next: (opts) => step(1, opts),
897
1412
  prev: (opts) => step(-1, opts),
1413
+ lensId: () => ctx.id,
898
1414
  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());
1415
+ base = mergeSettings(base, patch);
1416
+ syncResponsive(true);
911
1417
  },
1418
+ setResponsive: (next) => {
1419
+ rules = next;
1420
+ syncResponsive(true);
1421
+ },
1422
+ matches: (name) => ctx.getState().activeRules.includes(name),
1423
+ activeRules: () => ctx.getState().activeRules,
912
1424
  setFlow: (flow) => api.update({ flow }),
913
1425
  setLayout: (layout) => api.update({ layout }),
914
1426
  setSpread: (spread) => api.update({ spread }),
@@ -928,9 +1440,13 @@ function createStageCapability(ctx, config = {}) {
928
1440
  setScrollBehavior: (behavior) => api.update({ scrollBehavior: behavior }),
929
1441
  applyViewState: (view) => {
930
1442
  cancelAnim();
1443
+ base = mergeSettings(base, pickSettings(view));
1444
+ const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1445
+ lastEffective = effective;
1446
+ publishActive(active);
931
1447
  ctx.dispatch({
932
1448
  type: "PATCH",
933
- patch: pickSettings(view)
1449
+ patch: effective
934
1450
  });
935
1451
  ctx.dispatch({
936
1452
  type: "CURSOR",
@@ -946,97 +1462,92 @@ function createStageCapability(ctx, config = {}) {
946
1462
  });
947
1463
  },
948
1464
  placeInitial: () => {
949
- hasPlaced = true;
1465
+ placementStarted = true;
950
1466
  const sorted = [...initialViewProviders].sort((a, b) => b.priority - a.priority);
951
1467
  for (const p of sorted) {
952
1468
  const view = p.fn();
953
1469
  if (view) {
954
1470
  api.applyViewState(view);
1471
+ ctx.dispatch({ type: "PLACED" });
955
1472
  return;
956
1473
  }
957
1474
  }
958
1475
  api.resetView();
1476
+ ctx.dispatch({ type: "PLACED" });
959
1477
  },
960
1478
  resetView: () => goToTarget(0, { behavior: "instant" })
961
1479
  };
962
1480
  return api;
963
1481
  }
964
1482
  //#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
1483
  //#region src/effects.ts
1006
1484
  /**
1007
- * The one effect a stage lens needs: when the document's page registry changes
1008
- * (rotate/move/delete bump `DocumentMeta.revision`), the page geometry under
1009
- * this lens just changed, so re-fit — re-resolve the active zoom intent and
1010
- * re-place against the new footprint.
1485
+ * Stage lens effects.
1011
1486
  *
1012
- * Without it, fit/pixel zoom modes stay anchored to the pre-mutation dimensions:
1013
- * `pageWidth: 110` renders wider after a 90° rotation (width↔height swap), and
1014
- * `fitPage` over- or under-fills. The scene already re-keys on the revision, so
1015
- * only the resolved `cam.zoom` was stale; `refit()` recomputes it.
1487
+ * 1. Re-fit on page-registry mutations: when rotate/move/delete bump
1488
+ * `DocumentMeta.revision`, the page geometry under this lens changed, so
1489
+ * re-resolve the active zoom intent and re-place against the new
1490
+ * footprint. Steady-state only — it reacts to revision CHANGES, never the
1491
+ * initial value, so it can't race the capability's level-triggered
1492
+ * initial placement.
1016
1493
  *
1017
- * Steady-state only: this reacts to revision CHANGES, never the initial value,
1018
- * so it can't race the capability's level-triggered initial placement (the
1019
- * reason the rest of the plugin is effect-free).
1494
+ * 2. The page-state feed (main lens only, actions plugin present): stage is
1495
+ * AUTHORITATIVE for what page the viewer is on; the action engine's
1496
+ * lifecycle coordinator owns WHEN page-lifecycle triggers fire. This feed
1497
+ * pushes `{ currentPon, visiblePons, placed, cause }` snapshots — pons,
1498
+ * not indexes (reorder-safe) — on placement and on every change; the
1499
+ * coordinator buffers them behind the document-open barrier and diffs
1500
+ * against its last-emitted state, so this side stays a dumb reporter.
1020
1501
  */
1021
- function registerStageEffects(ctx, token) {
1502
+ function registerStageEffects(ctx, token, feedActions = false) {
1022
1503
  ctx.watch(() => ctx.document()?.revision ?? 0, () => ctx.get(token).refit());
1504
+ if (!feedActions) return;
1505
+ const actions = ctx.tryGet(ActionsToken$1);
1506
+ if (!actions) return;
1507
+ const snapshot = () => {
1508
+ const state = ctx.getState();
1509
+ if (!state.placed) return null;
1510
+ const stage = ctx.get(token);
1511
+ return {
1512
+ currentPon: stage.pages()[state.cursor]?.pon ?? null,
1513
+ visiblePons: stage.visiblePages().map((page) => page.pon),
1514
+ cause: state.motionCause
1515
+ };
1516
+ };
1517
+ ctx.watch(() => {
1518
+ const s = snapshot();
1519
+ return s === null ? "unplaced" : `${s.currentPon ?? -1}|${[...s.visiblePons].sort((a, b) => a - b).join(",")}`;
1520
+ }, () => {
1521
+ const s = snapshot();
1522
+ if (s === null) return;
1523
+ actions.reportPageState({
1524
+ currentPon: s.currentPon,
1525
+ visiblePons: s.visiblePons,
1526
+ placed: true,
1527
+ cause: s.cause
1528
+ });
1529
+ });
1023
1530
  }
1024
1531
  //#endregion
1025
1532
  //#region src/reducer.ts
1026
1533
  const initialStageState = (config) => {
1027
- const { scheduler: _scheduler, ...overrides } = config;
1534
+ const { scheduler: _scheduler, responsive: _responsive, ...overrides } = config;
1028
1535
  return {
1029
1536
  camera: {
1030
1537
  x: 0,
1031
1538
  y: 0,
1032
1539
  zoom: 1
1033
1540
  },
1541
+ placed: false,
1542
+ cameraResting: true,
1034
1543
  vp: {
1035
1544
  width: 0,
1036
1545
  height: 0
1037
1546
  },
1038
1547
  dpr: 1,
1039
1548
  cursor: 0,
1549
+ motionCause: "user",
1550
+ activeRules: [],
1040
1551
  ...DEFAULT_SETTINGS,
1041
1552
  ...overrides
1042
1553
  };
@@ -1062,6 +1573,14 @@ const stageReducer = (state, a) => {
1062
1573
  ...state,
1063
1574
  camera: a.camera
1064
1575
  };
1576
+ case "CAMERA_REST": return state.cameraResting === a.resting ? state : {
1577
+ ...state,
1578
+ cameraResting: a.resting
1579
+ };
1580
+ case "PLACED": return state.placed ? state : {
1581
+ ...state,
1582
+ placed: true
1583
+ };
1065
1584
  case "VP": return {
1066
1585
  ...state,
1067
1586
  vp: a.vp
@@ -1074,14 +1593,19 @@ const stageReducer = (state, a) => {
1074
1593
  ...state,
1075
1594
  cursor: a.cursor
1076
1595
  };
1596
+ case "MOTION_CAUSE": return {
1597
+ ...state,
1598
+ motionCause: a.cause
1599
+ };
1077
1600
  case "PATCH": return applyPatch(state, a.patch);
1601
+ case "RESPONSIVE": return {
1602
+ ...state,
1603
+ activeRules: a.active
1604
+ };
1078
1605
  default: return state;
1079
1606
  }
1080
1607
  };
1081
1608
  //#endregion
1082
- //#region src/types.ts
1083
- const StageToken = createCapabilityToken("stage");
1084
- //#endregion
1085
1609
  //#region src/stage.plugin.ts
1086
1610
  /**
1087
1611
  * Wires the parts into a kernel plugin. This file is the "manifest": it says what
@@ -1089,178 +1613,111 @@ const StageToken = createCapabilityToken("stage");
1089
1613
  * sibling files.
1090
1614
  */
1091
1615
  const stagePlugin = (options = {}) => {
1092
- const { id = "stage", token = StageToken, interaction = false, panFallback = true, ...config } = options;
1616
+ const { id = "stage", token = StageToken, ...config } = options;
1093
1617
  return definePlugin({
1094
1618
  id,
1095
1619
  token,
1096
1620
  scope: "document",
1097
- optional: interaction ? [InteractionToken] : void 0,
1621
+ optional: [ActionsToken],
1098
1622
  initialState: () => initialStageState(config),
1099
1623
  reduce: stageReducer,
1100
1624
  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
- effects: (ctx) => registerStageEffects(ctx, token)
1625
+ effects: (ctx) => registerStageEffects(ctx, token, id === "stage"),
1626
+ init: (ctx) => {
1627
+ if (id !== "stage") return;
1628
+ const actions = ctx.tryGet(ActionsToken$1);
1629
+ if (!actions) return;
1630
+ ctx.cleanup(actions.registerExecutor("goto", (node) => {
1631
+ if (node.type !== "goto") return {
1632
+ status: "inert",
1633
+ reason: "not a goto node"
1634
+ };
1635
+ const stage = ctx.tryGet(token);
1636
+ const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === node.destination.pageObjectNumber);
1637
+ if (!stage || !layout) return {
1638
+ status: "failed",
1639
+ error: "no stage or destination page available"
1640
+ };
1641
+ const { pageIndex, options: reveal } = destinationToReveal(node.destination, layout);
1642
+ stage.reveal(pageIndex, {
1643
+ ...reveal,
1644
+ behavior: "smooth"
1645
+ });
1646
+ return { status: "executed" };
1647
+ }));
1648
+ ctx.cleanup(actions.registerExecutor("named", (node) => {
1649
+ if (node.type !== "named") return {
1650
+ status: "inert",
1651
+ reason: "not a named node"
1652
+ };
1653
+ const stage = ctx.tryGet(token);
1654
+ if (!stage) return {
1655
+ status: "failed",
1656
+ error: "no stage available"
1657
+ };
1658
+ switch (node.name) {
1659
+ case "NextPage":
1660
+ stage.next({ behavior: "smooth" });
1661
+ return { status: "executed" };
1662
+ case "PrevPage":
1663
+ stage.prev({ behavior: "smooth" });
1664
+ return { status: "executed" };
1665
+ case "FirstPage":
1666
+ stage.goToPage(0, { behavior: "smooth" });
1667
+ return { status: "executed" };
1668
+ case "LastPage":
1669
+ stage.goToPage(Math.max(0, stage.pageCount() - 1), { behavior: "smooth" });
1670
+ return { status: "executed" };
1671
+ default: return {
1672
+ status: "inert",
1673
+ reason: `unknown named action '${node.name}'`
1674
+ };
1675
+ }
1676
+ }));
1677
+ }
1106
1678
  });
1107
1679
  };
1108
1680
  //#endregion
1109
- //#region src/destination.ts
1681
+ //#region src/scroll-handler.ts
1110
1682
  /**
1111
- * PDF destination → reveal: the pure translator between the PDF protocol's
1112
- * navigation vocabulary (ISO 32000-1 §12.3.2.2) and the Stage's arrival
1113
- * primitive. Outline clicks, link annotations, and `/OpenAction` all
1114
- * resolve (engine-side) to an explicit `PdfDestination`; this maps it onto
1115
- * ONE `reveal(pageIndex, options)` call — no destination-specific camera
1116
- * code exists anywhere else.
1117
- *
1118
- * The whole protocol collapses onto three knobs:
1119
- * rect — what to look at (a point for /XYZ, a rect for /FitR, the
1120
- * page for /Fit, the content bounding box for /FitB*)
1121
- * zoom — 'keep' | {level} | 'fit' | 'fit-width' | 'fit-height'
1122
- * anchor — where it lands; 'keep' encodes the spec's null-means-retain
1683
+ * Pan-the-camera as an interaction handler. Live under the built-in `pan` tool
1684
+ * (the `scroll` tag) and — with `panFallback` — under every tool, where it then
1685
+ * captures ONLY page-gap drags (on-page downs belong to the active tool, which
1686
+ * claims them first at higher priority). It uses the sample's viewport delta, so
1687
+ * it pans over the whole viewport (pages and gaps).
1123
1688
  *
1124
- * Coordinates convert PDF user space (y-up, absolute) → content space
1125
- * (y-down, crop-relative) through the SAME `pdfToContent` matrix selection
1126
- * and search use, so a destination and an overlay can never disagree.
1127
- */
1128
- /**
1129
- * Translate one explicit destination against its target page's layout.
1130
- * `bbox` is the page's content BOUNDING box in CONTENT space, for the
1131
- * `/FitB*` kinds — pass it when known; the crop box is the spec-tolerated
1132
- * fallback until the engine exposes it.
1689
+ * Cursor feel: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
1690
+ * hovering a gap under a non-pan tool. The pan tool's own resting `grab` comes
1691
+ * from its `Tool.cursor`, so hovering it needs no claim here.
1133
1692
  */
1134
- function destinationToReveal(dest, layout, bbox) {
1135
- const geo = pageGeometry({
1136
- crop: layout.boxes.crop,
1137
- rotation: layout.rotation,
1138
- userUnit: layout.userUnit
1139
- }, 1);
1140
- const crop = layout.boxes.crop;
1141
- const toContent = (x, y) => applyPoint(geo.pdfToContent, {
1142
- x,
1143
- y
1144
- });
1145
- const page = {
1693
+ function createScrollHandler(stage, interaction, options = {}) {
1694
+ const panFallback = options.panFallback ?? true;
1695
+ let last = {
1146
1696
  x: 0,
1147
- y: 0,
1148
- width: layout.size.width,
1149
- height: layout.size.height
1697
+ y: 0
1150
1698
  };
1151
- const box = bbox ?? page;
1152
- const options = (() => {
1153
- switch (dest.kind) {
1154
- case "xyz": {
1155
- const hasLeft = dest.left != null;
1156
- const hasTop = dest.top != null;
1157
- const p = toContent(dest.left ?? crop.left, dest.top ?? crop.top);
1158
- return {
1159
- rect: {
1160
- x: p.x,
1161
- y: p.y,
1162
- width: 0,
1163
- height: 0
1164
- },
1165
- anchor: {
1166
- x: hasLeft ? "start" : "keep",
1167
- y: hasTop ? "start" : "keep"
1168
- },
1169
- zoom: dest.zoom != null && dest.zoom !== 0 ? { level: dest.zoom } : "keep"
1170
- };
1171
- }
1172
- case "fit": return { zoom: "fit" };
1173
- case "fitH": {
1174
- const hasTop = dest.top != null;
1175
- return {
1176
- rect: {
1177
- x: 0,
1178
- y: hasTop ? toContent(crop.left, dest.top).y : 0,
1179
- width: page.width,
1180
- height: 0
1181
- },
1182
- zoom: "fit-width",
1183
- anchor: { y: hasTop ? "start" : "keep" }
1184
- };
1185
- }
1186
- case "fitV": {
1187
- const hasLeft = dest.left != null;
1188
- return {
1189
- rect: {
1190
- x: hasLeft ? toContent(dest.left, crop.top).x : 0,
1191
- y: 0,
1192
- width: 0,
1193
- height: page.height
1194
- },
1195
- zoom: "fit-height",
1196
- anchor: { x: hasLeft ? "start" : "keep" }
1197
- };
1198
- }
1199
- case "fitR": {
1200
- const tl = toContent(dest.left, dest.top);
1201
- const br = toContent(dest.right, dest.bottom);
1202
- return {
1203
- rect: {
1204
- x: Math.min(tl.x, br.x),
1205
- y: Math.min(tl.y, br.y),
1206
- width: Math.abs(br.x - tl.x),
1207
- height: Math.abs(br.y - tl.y)
1208
- },
1209
- zoom: "fit"
1210
- };
1211
- }
1212
- case "fitB": return {
1213
- rect: box,
1214
- zoom: "fit"
1215
- };
1216
- case "fitBH": {
1217
- const hasTop = dest.top != null;
1218
- const y = hasTop ? toContent(crop.left, dest.top).y : box.y;
1219
- return {
1220
- rect: {
1221
- x: box.x,
1222
- y,
1223
- width: box.width,
1224
- height: 0
1225
- },
1226
- zoom: "fit-width",
1227
- anchor: { y: hasTop ? "start" : "keep" }
1228
- };
1229
- }
1230
- case "fitBV": {
1231
- const hasLeft = dest.left != null;
1232
- return {
1233
- rect: {
1234
- x: hasLeft ? toContent(dest.left, crop.top).x : box.x,
1235
- y: box.y,
1236
- width: 0,
1237
- height: box.height
1238
- },
1239
- zoom: "fit-height",
1240
- anchor: { x: hasLeft ? "start" : "keep" }
1241
- };
1242
- }
1243
- }
1244
- })();
1699
+ const isPanTool = () => interaction.activeTool().enables.has("scroll");
1245
1700
  return {
1246
- pageIndex: layout.index,
1247
- options
1701
+ id: "stage-scroll",
1702
+ priority: 10,
1703
+ enabledFor: (tool) => tool.enables.has("scroll") || panFallback,
1704
+ onDown: (s) => {
1705
+ if (!isPanTool() && s.page) return false;
1706
+ last = s.viewport;
1707
+ interaction.setCursor("stage-grab", "grabbing", 40);
1708
+ return true;
1709
+ },
1710
+ onMove: (s) => {
1711
+ stage.panBy(s.viewport.x - last.x, s.viewport.y - last.y);
1712
+ last = s.viewport;
1713
+ },
1714
+ onUp: () => interaction.setCursor("stage-grab", null),
1715
+ onHover: (s) => {
1716
+ if (panFallback && !isPanTool()) interaction.setCursor("stage-pan-fallback", s.page ? null : "grab", 5);
1717
+ }
1248
1718
  };
1249
1719
  }
1250
1720
  //#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);
1262
- }
1263
- //#endregion
1264
- export { DEFAULT_SETTINGS, StageToken, ZoomMode, destinationToReveal, settingsEqual, stagePlugin, wheelZoomFactor };
1721
+ export { DEFAULT_RESPONSIVE, DEFAULT_SETTINGS, StageToken, ZoomMode, boxOf, createScrollHandler, destinationToReveal, matchesQuery, resolveResponsive, settingsEqual, stagePlugin };
1265
1722
 
1266
1723
  //# sourceMappingURL=index.js.map