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