@embedpdf/plugin-stage 3.0.0-next.14 → 3.0.0-next.16

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
@@ -1,43 +1,150 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region \0rolldown/runtime.js
3
- var __create = Object.create;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __getProtoOf = Object.getPrototypeOf;
8
- var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __copyProps = (to, from, except, desc) => {
10
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
- key = keys[i];
12
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
- get: ((k) => from[k]).bind(null, key),
14
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
- });
16
- }
17
- return to;
18
- };
19
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
- value: mod,
21
- enumerable: true
22
- }) : target, mod));
23
- //#endregion
24
- const require_destination = require("./destination.cjs");
25
- const require_contract = require("./contract-BU33R4oL.cjs");
2
+ const require_contract = require("./contract-iSNhoXLC.cjs");
26
3
  let _embedpdf_core = require("@embedpdf/core");
27
4
  let _embedpdf_plugin_actions_contract = require("@embedpdf/plugin-actions/contract");
28
5
  let _embedpdf_plugin_actions_contract_host = require("@embedpdf/plugin-actions/contract/host");
29
6
  let _embedpdf_core_stage = require("@embedpdf/core-stage");
30
- _embedpdf_core_stage = __toESM(_embedpdf_core_stage, 1);
31
7
  let _embedpdf_core_geometry = require("@embedpdf/core-geometry");
8
+ //#region src/working-set.ts
9
+ /** A lens's working set, from the pages it lays out. */
10
+ function workingSetOf(pages) {
11
+ return pages.map(({ ref, visibleRect, transform }) => {
12
+ if (visibleRect.width <= 0 || visibleRect.height <= 0) return {
13
+ page: ref,
14
+ role: "near",
15
+ pixels: 0
16
+ };
17
+ const pixels = visibleRect.width * visibleRect.height * transform.renderScale ** 2;
18
+ return {
19
+ page: ref,
20
+ role: "visible",
21
+ visible: visibleRect,
22
+ pixels: Math.round(pixels)
23
+ };
24
+ });
25
+ }
26
+ /**
27
+ * Tells the engine what the lens shows, now and whenever it changes: once per
28
+ * camera move, so at most once per input event while the camera moves.
29
+ */
30
+ function connectWorkingSet(ctx, stage) {
31
+ let told = "";
32
+ const tell = (shown) => {
33
+ const pages = workingSetOf(shown);
34
+ const key = keyOf(pages);
35
+ if (key === told) return;
36
+ told = key;
37
+ ctx.doc.setWorkingSet(ctx.id, pages);
38
+ };
39
+ ctx.watch(() => stage.listVisiblePages(), tell);
40
+ tell(stage.listVisiblePages());
41
+ }
42
+ /** A working set as one comparable string. */
43
+ function keyOf(pages) {
44
+ return pages.map(({ page, role, visible, pixels }) => {
45
+ const part = visible ? `${Math.round(visible.x)},${Math.round(visible.y)},${Math.round(visible.width)},${Math.round(visible.height)}` : "";
46
+ return `${page.objectNumber}:${role}:${pixels}:${part}`;
47
+ }).join("|");
48
+ }
49
+ //#endregion
50
+ //#region src/connect.ts
51
+ /** The id of the main lens; additional lenses register under their own id. */
52
+ const DEFAULT_LENS_ID = "stage";
53
+ function connectStage(ctx, stage) {
54
+ ctx.watch(() => ctx.document()?.revision ?? 0, () => stage.refit());
55
+ connectWorkingSet(ctx, stage);
56
+ if (ctx.id !== "stage") return;
57
+ const actions = ctx.tryGet(_embedpdf_plugin_actions_contract_host.ActionsToken);
58
+ if (!actions) return;
59
+ feedPageState(ctx, stage, actions);
60
+ registerNavigationExecutors(ctx, stage, actions);
61
+ }
62
+ /**
63
+ * The page-state feed. The stage is authoritative for what page the viewer is
64
+ * on; the actions plugin's lifecycle coordinator owns when page-lifecycle
65
+ * triggers fire. The feed reports `{ currentPage, visiblePages, placed, cause }`
66
+ * (page refs, not indexes, so reordering is safe) on placement and on every
67
+ * change; the coordinator buffers reports until the document has opened and
68
+ * diffs them against what it last emitted, so this side only reports.
69
+ */
70
+ function feedPageState(ctx, stage, actions) {
71
+ const snapshot = () => {
72
+ const state = ctx.state.get();
73
+ if (!state.placed) return null;
74
+ return {
75
+ currentPage: stage.getCurrentPage(),
76
+ visiblePages: stage.listVisiblePages().map((page) => page.ref),
77
+ cause: state.motionCause
78
+ };
79
+ };
80
+ ctx.watch(() => {
81
+ const current = snapshot();
82
+ return current === null ? "unplaced" : `${current.currentPage?.objectNumber ?? -1}|${current.visiblePages.map((page) => page.objectNumber).sort((left, right) => left - right).join(",")}`;
83
+ }, () => {
84
+ const current = snapshot();
85
+ if (current === null) return;
86
+ actions.reportPageState({
87
+ currentPage: current.currentPage,
88
+ visiblePages: current.visiblePages,
89
+ placed: true,
90
+ cause: current.cause
91
+ });
92
+ });
93
+ }
94
+ /**
95
+ * The GoTo and Named page verbs for the action engine. The dispatcher runs
96
+ * them as deferred navigation effects, after every document node of a tree
97
+ * succeeded, never in the middle of a walk. They move the view by its own
98
+ * `scrollBehavior` setting, as a link does. /N Print is not handled here: the
99
+ * dispatcher owns it (policy and the UI adapter).
100
+ */
101
+ function registerNavigationExecutors(ctx, stage, actions) {
102
+ ctx.cleanup(actions.registerExecutor("goto", (node) => {
103
+ if (node.type !== "goto") return {
104
+ status: "inert",
105
+ reason: "not a goto node"
106
+ };
107
+ if (!ctx.getPage(node.destination.page)) return {
108
+ status: "failed",
109
+ error: "the destination page is not in this document"
110
+ };
111
+ stage.goToDestination(node.destination);
112
+ return { status: "executed" };
113
+ }));
114
+ ctx.cleanup(actions.registerExecutor("named", (node) => {
115
+ if (node.type !== "named") return {
116
+ status: "inert",
117
+ reason: "not a named node"
118
+ };
119
+ switch (node.name) {
120
+ case "NextPage":
121
+ stage.nextPage();
122
+ return { status: "executed" };
123
+ case "PrevPage":
124
+ stage.previousPage();
125
+ return { status: "executed" };
126
+ case "FirstPage":
127
+ stage.goToFirstPage();
128
+ return { status: "executed" };
129
+ case "LastPage":
130
+ stage.goToLastPage();
131
+ return { status: "executed" };
132
+ default: return {
133
+ status: "inert",
134
+ reason: `unknown named action '${node.name}'`
135
+ };
136
+ }
137
+ }));
138
+ }
139
+ //#endregion
32
140
  //#region src/motion.ts
33
141
  /**
34
- * The camera's MOTION vocabulary — every formula, pure.
142
+ * The camera's motion vocabulary, every formula pure.
35
143
  *
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.
144
+ * The capability owns the impure drivers (the clock's frames, state writes,
145
+ * gesture state); the laws those drivers integrate live here, parameterized
146
+ * by elapsed time with no state and no clock, so they are unit-testable in
147
+ * isolation and have no plugin dependencies.
41
148
  *
42
149
  * Two families:
43
150
  * • ballistic — one per-axis law for every free motion: `glideStep` (the
@@ -45,119 +152,307 @@ let _embedpdf_core_geometry = require("@embedpdf/core-geometry");
45
152
  * critically-damped return to the clamp). A glide that reaches a content
46
153
  * edge converts to a spring with its velocity carried in, so the bounce
47
154
  * 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.
155
+ * • resistance — the iOS rubber-band curve `rubberOut` and its exact inverse
156
+ * `rubberIn`: dragging past an edge displays an asymptotically bounded
157
+ * stretch, and re-entering a gesture over a stretched camera (catching it
158
+ * mid-bounce) reconstructs the finger-integrated position the curve came
159
+ * from.
53
160
  * 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").
161
+ * `zoomLerp`, a geometric zoom interpolation (zoom is multiplicative: equal
162
+ * ratios per unit time, not equal deltas).
56
163
  */
57
164
  /** Resistance coefficient of the rubber-band curve (the iOS feel). */
58
165
  const RUBBER = .55;
59
- /** Glide decay per ms — UIScrollView's normal deceleration rate. */
166
+ /** Glide decay per ms: UIScrollView's normal deceleration rate. */
60
167
  const FLING_DECAY = .998;
61
- /** rad/ms — critically damped, ~400ms to settle. */
168
+ /** rad/ms; critically damped, settling in about 400 ms. */
62
169
  const SPRING_OMEGA = .015;
63
170
  /**
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.
171
+ * Overshoot distance → displayed stretch, asymptotic to the viewport
172
+ * dimension, so the content never leaves the screen however far the finger
173
+ * travels. `rubberOut(0) === 0`: inside the bounds the curve is invisible.
68
174
  */
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));
175
+ const rubberOut = (distance, dimension) => (1 - 1 / (distance * RUBBER / dimension + 1)) * dimension;
176
+ /** The exact inverse of `rubberOut` (defined for `stretch < dimension`). */
177
+ const rubberIn = (stretch, dimension) => stretch * dimension / (RUBBER * (dimension - stretch));
72
178
  /**
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).
179
+ * One frame of free glide: exponential velocity decay, the position advanced
180
+ * by the midpoint integral of the decay over `elapsedMs` (exact for the
181
+ * exponential, so frame-rate independent).
76
182
  */
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,
183
+ const glideStep = (position, velocity, elapsedMs) => {
184
+ const decay = Math.pow(FLING_DECAY, elapsedMs);
185
+ const nextPosition = position + velocity * (elapsedMs * (1 + decay) / 2);
186
+ const nextVelocity = velocity * decay;
187
+ return Math.abs(nextVelocity) < .02 ? {
188
+ position: nextPosition,
189
+ velocity: 0,
84
190
  done: true
85
191
  } : {
86
- p: np,
87
- v: nv,
192
+ position: nextPosition,
193
+ velocity: nextVelocity,
88
194
  done: false
89
195
  };
90
196
  };
91
197
  /**
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.
198
+ * One frame of a critically-damped spring toward `edge`: semi-implicit Euler
199
+ * on x'' = −ω²(x−e) − 2ωx'. Lands exactly on the edge (position snapped,
200
+ * velocity zeroed) once within half a pixel at negligible speed.
95
201
  */
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,
202
+ const springStep = (position, velocity, edge, elapsedMs) => {
203
+ const nextVelocity = velocity + (-.015 * SPRING_OMEGA * (position - edge) - 2 * SPRING_OMEGA * velocity) * elapsedMs;
204
+ const nextPosition = position + nextVelocity * elapsedMs;
205
+ if (Math.abs(nextPosition - edge) < .5 && Math.abs(nextVelocity) < .01) return {
206
+ position: edge,
207
+ velocity: 0,
102
208
  done: true
103
209
  };
104
210
  return {
105
- p: np,
106
- v: nv,
211
+ position: nextPosition,
212
+ velocity: nextVelocity,
107
213
  done: false
108
214
  };
109
215
  };
110
- const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
216
+ const easeOutCubic = (progress) => 1 - Math.pow(1 - progress, 3);
217
+ /**
218
+ * Geometric zoom interpolation: `from·(to/from)^progress`. Zoom is
219
+ * multiplicative, so a constant-rate zoom moves equal ratios per unit time;
220
+ * interpolating the level linearly makes the start rush and the end crawl
221
+ * (and, under a focal anchor, swings the anchored point along a curve).
222
+ */
223
+ const zoomLerp = (from, to, progress) => from * Math.pow(to / from, progress);
224
+ //#endregion
225
+ //#region src/camera/animation.ts
111
226
  /**
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).
227
+ * Camera motion over the host's frames (`ctx.clock`): the coordinate tween,
228
+ * the zoom-anchored tween (it animates the invariant, so the focal page-point
229
+ * holds still by construction), and the momentum and edge physics (glide,
230
+ * spring). One frame is waited for at a time: `cancelAnimation`, every verb's
231
+ * first act, is also "catch".
116
232
  */
117
- const zoomLerp = (z0, z1, k) => z0 * Math.pow(z1 / z0, k);
233
+ /** The default tween duration. */
234
+ const TWEEN_MS = 240;
235
+ const lerp = (from, to, progress) => from + (to - from) * progress;
236
+ function createAnimation({ events, clock, scene }, write) {
237
+ const { motionEnded } = events;
238
+ const { camera, viewport, buildScene, stayBounds, constraint } = scene;
239
+ const { writeCamera, writeCameraUnclamped, axisTravels, syncCursorFromCamera } = write;
240
+ let cancelFrame = null;
241
+ const cancelAnimation = () => {
242
+ cancelFrame?.();
243
+ cancelFrame = null;
244
+ };
245
+ /** A tween or fling reached its natural end. */
246
+ const endMotion = () => {
247
+ cancelFrame = null;
248
+ motionEnded.emit({ camera: camera() });
249
+ };
250
+ const animateTo = (target, bounds, durationMs = TWEEN_MS, then) => {
251
+ if (!clock.hasFrames) {
252
+ writeCamera(target, bounds);
253
+ then?.();
254
+ return;
255
+ }
256
+ cancelAnimation();
257
+ const from = camera();
258
+ let started = false;
259
+ let startedAt = 0;
260
+ const tick = (now) => {
261
+ if (!started) {
262
+ started = true;
263
+ startedAt = now;
264
+ }
265
+ const progress = easeOutCubic(Math.min(1, (now - startedAt) / durationMs));
266
+ writeCamera({
267
+ x: lerp(from.x, target.x, progress),
268
+ y: lerp(from.y, target.y, progress),
269
+ zoom: lerp(from.zoom, target.zoom, progress)
270
+ }, bounds);
271
+ if (progress < 1) cancelFrame = clock.nextFrame(tick);
272
+ else {
273
+ endMotion();
274
+ then?.();
275
+ }
276
+ };
277
+ cancelFrame = clock.nextFrame(tick);
278
+ };
279
+ /**
280
+ * The zoom-anchored tween animates the invariant, not the coordinates: the
281
+ * focal page-point stays at `point` while the scale changes. Lerping x, y
282
+ * and zoom independently breaks that mid-flight, because holding a screen
283
+ * point requires `x(t) = focal.x − point.x / zoom(t)`, hyperbolic in the
284
+ * zoom, so linear coordinates swing the tapped point along a curve (a
285
+ * visible dip). Here the zoom interpolates geometrically (zoom is
286
+ * multiplicative) and each frame's camera derives from the anchor at that
287
+ * zoom, then clamps: the focal point is stationary by construction, and a
288
+ * zoom-out rails into its fit as the shrinking travel range lets the clamp
289
+ * take over.
290
+ */
291
+ const animateZoomAnchored = (focal, point, targetZoom, durationMs = TWEEN_MS, then) => {
292
+ if (!clock.hasFrames) {
293
+ const scene = buildScene();
294
+ if (scene.itemCount) writeCamera((0, _embedpdf_core_stage.cameraForAnchorAtScreen)(focal, scene, point, targetZoom));
295
+ then?.();
296
+ return;
297
+ }
298
+ cancelAnimation();
299
+ const startZoom = camera().zoom;
300
+ let started = false;
301
+ let startedAt = 0;
302
+ const tick = (now) => {
303
+ if (!started) {
304
+ started = true;
305
+ startedAt = now;
306
+ }
307
+ const progress = easeOutCubic(Math.min(1, (now - startedAt) / durationMs));
308
+ const zoom = zoomLerp(startZoom, targetZoom, progress);
309
+ const scene = buildScene();
310
+ if (scene.itemCount) writeCamera((0, _embedpdf_core_stage.cameraForAnchorAtScreen)(focal, scene, point, zoom));
311
+ if (progress < 1) cancelFrame = clock.nextFrame(tick);
312
+ else {
313
+ endMotion();
314
+ then?.();
315
+ }
316
+ };
317
+ cancelFrame = clock.nextFrame(tick);
318
+ };
319
+ const startCameraPhysics = (fingerVelocityX, fingerVelocityY) => {
320
+ if (!clock.hasFrames) {
321
+ writeCamera(camera());
322
+ return;
323
+ }
324
+ cancelAnimation();
325
+ const zoom = camera().zoom;
326
+ const start = camera();
327
+ const startClamped = (0, _embedpdf_core_stage.clampCamera)(start, stayBounds(), viewport(), constraint());
328
+ const startBounds = stayBounds();
329
+ const startViewport = viewport();
330
+ const axisState = (cameraPosition, clampedPosition, fingerVelocity) => ({
331
+ position: cameraPosition * zoom,
332
+ velocity: -fingerVelocity / 1e3,
333
+ springing: cameraPosition !== clampedPosition,
334
+ done: false
335
+ });
336
+ const axisX = axisState(start.x, startClamped.x, axisTravels(startBounds.x, startBounds.width, startViewport.width, zoom) ? fingerVelocityX : 0);
337
+ const axisY = axisState(start.y, startClamped.y, axisTravels(startBounds.y, startBounds.height, startViewport.height, zoom) ? fingerVelocityY : 0);
338
+ if (!axisX.springing && !axisY.springing && Math.hypot(axisX.velocity, axisY.velocity) < .02) return;
339
+ let started = false;
340
+ let lastFrameAt = 0;
341
+ const tick = (now) => {
342
+ cancelFrame = null;
343
+ const elapsedMs = started ? Math.min(64, Math.max(1, now - lastFrameAt)) : 16;
344
+ started = true;
345
+ lastFrameAt = now;
346
+ const home = (0, _embedpdf_core_stage.clampCamera)({
347
+ x: axisX.position / zoom,
348
+ y: axisY.position / zoom,
349
+ zoom
350
+ }, stayBounds(), viewport(), constraint());
351
+ const step = (axis, edgeWorld) => {
352
+ if (axis.done) return;
353
+ const next = axis.springing ? springStep(axis.position, axis.velocity, edgeWorld * zoom, elapsedMs) : glideStep(axis.position, axis.velocity, elapsedMs);
354
+ axis.position = next.position;
355
+ axis.velocity = next.velocity;
356
+ axis.done = next.done;
357
+ };
358
+ step(axisX, home.x);
359
+ step(axisY, home.y);
360
+ const nextHome = (0, _embedpdf_core_stage.clampCamera)({
361
+ x: axisX.position / zoom,
362
+ y: axisY.position / zoom,
363
+ zoom
364
+ }, stayBounds(), viewport(), constraint());
365
+ const convert = (axis, clampedPosition) => {
366
+ if (!axis.springing && Math.abs(clampedPosition * zoom - axis.position) > 1e-6) {
367
+ axis.position = clampedPosition * zoom;
368
+ axis.springing = true;
369
+ axis.done = false;
370
+ }
371
+ };
372
+ convert(axisX, nextHome.x);
373
+ convert(axisY, nextHome.y);
374
+ writeCameraUnclamped({
375
+ x: axisX.position / zoom,
376
+ y: axisY.position / zoom,
377
+ zoom
378
+ });
379
+ syncCursorFromCamera();
380
+ if (axisX.done && axisY.done) {
381
+ writeCamera(camera());
382
+ motionEnded.emit({ camera: camera() });
383
+ return;
384
+ }
385
+ cancelFrame = clock.nextFrame(tick);
386
+ };
387
+ cancelFrame = clock.nextFrame(tick);
388
+ };
389
+ return {
390
+ cancelAnimation,
391
+ animateTo,
392
+ animateZoomAnchored,
393
+ startCameraPhysics,
394
+ api: {
395
+ fling: (velocityX, velocityY) => startCameraPhysics(velocityX, velocityY),
396
+ isMoving: () => cancelFrame !== null,
397
+ stopMotion: () => {
398
+ if (!cancelFrame) return;
399
+ cancelAnimation();
400
+ motionEnded.emit({ camera: camera() });
401
+ }
402
+ }
403
+ };
404
+ }
118
405
  //#endregion
119
406
  //#region src/responsive.ts
120
407
  /**
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).
408
+ * Container queries for the settings bag, the pure half. The capability owns
409
+ * the driver (when to re-resolve, how to react); the laws live here: what a
410
+ * box is, when a query matches, and what the effective settings are. No
411
+ * state, so they are property-testable in isolation.
125
412
  *
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()`.
413
+ * The query vocabulary is only the box: a headless, DOM-free stage knows
414
+ * exactly one environmental fact, the viewport it was told. Anything else
415
+ * (pointer coarseness, platform, app state) is either per event
416
+ * (`PointerSample.pointerType`) or the app's own business via
417
+ * `updateSettings()`.
130
418
  */
131
419
  /** 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"
420
+ const boxOf = (size) => ({
421
+ width: size.width,
422
+ height: size.height,
423
+ orientation: size.height >= size.width ? "portrait" : "landscape"
136
424
  });
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);
425
+ /** All bounds inclusive, all fields optional, every condition must hold (CSS ranges). */
426
+ const matchesQuery = (query, box) => (query.minWidth === void 0 || box.width >= query.minWidth) && (query.maxWidth === void 0 || box.width <= query.maxWidth) && (query.minHeight === void 0 || box.height >= query.minHeight) && (query.maxHeight === void 0 || box.height <= query.maxHeight) && (query.orientation === void 0 || box.orientation === query.orientation);
139
427
  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). */
428
+ /**
429
+ * Merge a patch over settings, skipping undefined values (Partial semantics).
430
+ * Each value replaces the setting whole; a `pageFrame` that names only some
431
+ * sides reserves 0 on the others.
432
+ */
141
433
  const mergeSettings = (into, patch) => {
142
- const out = { ...into };
434
+ const merged = { ...into };
143
435
  let key;
144
436
  for (key in patch) {
145
- const value = patch[key];
146
- if (value !== void 0) Object.assign(out, { [key]: value });
437
+ const value = key === "pageFrame" ? frameOf(patch.pageFrame) : patch[key];
438
+ if (value !== void 0) Object.assign(merged, { [key]: value });
147
439
  }
148
- return out;
440
+ return merged;
441
+ };
442
+ const frameOf = (frame) => frame === void 0 ? void 0 : {
443
+ ..._embedpdf_core_geometry.NO_FRAME,
444
+ ...frame
149
445
  };
150
446
  /**
151
447
  * Setting-value equality: primitives by identity, the flat objects the
152
448
  * settings vocabulary uses (`{ px }`, `{ x, y }`, page frames, zoom specs) by
153
449
  * one level of own-key comparison. Settings values are never nested deeper.
154
450
  */
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]);
451
+ const eqSetting = (left, right) => {
452
+ if (left === right) return true;
453
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
454
+ const leftKeys = Object.keys(left);
455
+ return leftKeys.length === Object.keys(right).length && leftKeys.every((key) => left[key] === right[key]);
161
456
  };
162
457
  const resolveResponsive = (base, rules, box) => {
163
458
  let effective = base;
@@ -175,11 +470,11 @@ const resolveResponsive = (base, rules, box) => {
175
470
  //#endregion
176
471
  //#region src/settings.ts
177
472
  /**
178
- * Out-of-the-box defaults — a sensible document-reading feel. They are JUST
179
- * defaults: every field is overridable in `stagePlugin(config)` and at runtime via
180
- * the setters / `update()`. The plugin ships NO named presets ("document",
181
- * "canvas", …) — a preset is simply an object the app keeps and passes to
182
- * `update()`, so that taxonomy stays a customer concern.
473
+ * Out-of-the-box defaults: a document-reading feel. Every field is overridable
474
+ * in `stagePlugin(config)` and at runtime through `updateSettings()`. The
475
+ * plugin ships no named presets ("document", "canvas", …): a preset is an
476
+ * object the app keeps and passes to `updateSettings()`, so that taxonomy
477
+ * stays the app's concern.
183
478
  */
184
479
  const DEFAULT_SETTINGS = {
185
480
  flow: "continuous",
@@ -216,14 +511,17 @@ const DEFAULT_SETTINGS = {
216
511
  viewRotation: 0,
217
512
  zoom: { mode: _embedpdf_core_stage.ZoomMode.Automatic },
218
513
  scrollBehavior: "smooth",
514
+ interaction: true,
515
+ panFallback: true,
516
+ zoomGestures: true,
219
517
  viewUnitsPerPoint: 96 / 72
220
518
  };
221
519
  /**
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
520
+ * Out-of-the-box responsive rules, the platform feel with zero configuration:
521
+ * a compact container gets the thin gutter phones use (space, not device: an
224
522
  * embedded 500px pane on a desktop is compact too). Override the whole list
225
523
  * with `stagePlugin({ responsive: [...] })`; `responsive: []` opts out. The
226
- * 'compact' name is queryable app-side via `stage.matches('compact')`.
524
+ * 'compact' name is queryable by the app through `stage.matchesRule('compact')`.
227
525
  */
228
526
  const DEFAULT_RESPONSIVE = [{
229
527
  name: "compact",
@@ -248,690 +546,702 @@ const SETTINGS_EFFECT = {
248
546
  viewRotation: "scene",
249
547
  zoom: "refit",
250
548
  scrollBehavior: "none",
549
+ interaction: "none",
550
+ panFallback: "none",
551
+ zoomGestures: "none",
251
552
  viewUnitsPerPoint: "scene"
252
553
  };
253
554
  const SETTING_KEYS = Object.keys(SETTINGS_EFFECT);
254
- const valueEq = (a, b) => {
255
- if (a === b) return true;
256
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
257
- const ka = Object.keys(a);
258
- return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
555
+ /** Field-by-field settings equality, derived from the registry, so a new
556
+ * setting is covered automatically: what an adapter compares settings with. */
557
+ const settingsEqual = (left, right) => SETTING_KEYS.every((key) => eqSetting(left[key], right[key]));
558
+ /** The settings slice of a larger object (state, or a saved view), derived
559
+ * from the registry so the shape is never spelled out by hand. */
560
+ const pickSettings = (source) => {
561
+ const settings = {};
562
+ for (const key of SETTING_KEYS) settings[key] = source[key];
563
+ return settings;
564
+ };
565
+ //#endregion
566
+ //#region src/model.ts
567
+ const initialStageState = (config) => {
568
+ const { responsive: _responsive, ...overrides } = config;
569
+ return {
570
+ camera: {
571
+ x: 0,
572
+ y: 0,
573
+ zoom: 1
574
+ },
575
+ placed: false,
576
+ cameraResting: true,
577
+ viewport: {
578
+ width: 0,
579
+ height: 0
580
+ },
581
+ dpr: 1,
582
+ cursor: 0,
583
+ motionCause: "user",
584
+ activeRules: [],
585
+ ...mergeSettings(DEFAULT_SETTINGS, overrides)
586
+ };
587
+ };
588
+ const setCamera = (state, camera) => state.camera === camera ? state : {
589
+ ...state,
590
+ camera
591
+ };
592
+ const setCameraResting = (state, resting) => state.cameraResting === resting ? state : {
593
+ ...state,
594
+ cameraResting: resting
595
+ };
596
+ const markPlaced = (state) => state.placed ? state : {
597
+ ...state,
598
+ placed: true
599
+ };
600
+ const setViewport = (state, viewport) => state.viewport === viewport ? state : {
601
+ ...state,
602
+ viewport
603
+ };
604
+ const setDpr = (state, dpr) => state.dpr === dpr ? state : {
605
+ ...state,
606
+ dpr
607
+ };
608
+ const setCursor = (state, cursor) => state.cursor === cursor ? state : {
609
+ ...state,
610
+ cursor
611
+ };
612
+ const setMotionCause = (state, motionCause) => state.motionCause === motionCause ? state : {
613
+ ...state,
614
+ motionCause
615
+ };
616
+ /**
617
+ * Merge a settings patch. Undefined values are skipped, so a partial restore
618
+ * never clears a setting; the area that patches decides what camera
619
+ * follow-up the change needs.
620
+ */
621
+ function patchSettings(state, patch) {
622
+ let next = state;
623
+ let key;
624
+ for (key in patch) {
625
+ const value = patch[key];
626
+ if (value === void 0 || Object.is(next[key], value)) continue;
627
+ if (next === state) next = { ...state };
628
+ Object.assign(next, { [key]: value });
629
+ }
630
+ return next;
631
+ }
632
+ const setActiveRules = (state, activeRules) => state.activeRules === activeRules ? state : {
633
+ ...state,
634
+ activeRules
259
635
  };
260
- /** Field-by-field settings equality, derived from the registry — a new setting is
261
- * covered automatically. (The React `useStageSettings` selector equality.) */
262
- const settingsEqual = (a, b) => SETTING_KEYS.every((k) => valueEq(a[k], b[k]));
263
636
  //#endregion
264
- //#region src/capability.ts
637
+ //#region src/camera/gestures.ts
265
638
  /**
266
- * The Stage capability — selectors (pure reads) + intents (the only writers).
267
- *
268
- * Layering, stated honestly:
269
- * • stage-core — PURE spatial math (no DOM, no time). Ports to Rust later.
270
- * • this capability — the IMPURE platform shell. It dispatches, caches, and owns
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).
274
- *
275
- * The model: EVERY camera move is defined by what it holds fixed.
276
- * • gestures (pan/pinch/wheel) — the content under the pointer. Physics, no setting.
277
- * • focal zoom (zoomIn/zoomTo/fit-mode switches) — the zoomAlign viewport point.
278
- * • reframes (resize/rotate/re-layout) — the page-point at the anchorAlign
279
- * viewport point: captured there before the change, restored there after.
280
- * • arrivals (goToPage/next/prev/reset) — nothing prior: a fresh landing at
281
- * arrivalAlign, THE SAME at every zoom (landing is policy, never a zoom side effect).
282
- * • explicit (reveal/destination/viewpoint) — whatever the call specifies.
283
- * fitAlign stands apart: the clamp's standing rest constraint wherever an axis has no
284
- * freedom against the TRUE bounds — it shapes every move above, and is why a fitting
285
- * axis settles identically whatever arrivalAlign says.
286
- *
287
- * "Does it fit the viewport?" still decides SIZE and SUBJECT — never alignment:
288
- * • step size: next/prev step by ITEM (spread) when the item fits, by PAGE when
289
- * zoomed in past it.
290
- * • subject: you arrive AT the unit that fits (item, page — or the whole scene
291
- * under fit-all).
292
- *
293
- * The `cursor` is THE current page in both flows: navigation sets it; continuous
294
- * scrolling syncs it from the camera; paged panning never moves it.
639
+ * Direct manipulation: set, pan, scroll and pinch-zoom the camera, and the
640
+ * gesture bracket. The camera moves freely and the cursor is derived from it
641
+ * (the manipulation direction of cursor reconciliation).
295
642
  */
296
- function createStageCapability(ctx, config = {}) {
297
- const host = globalThis;
298
- const canAnimate = !!config.scheduler || typeof host.requestAnimationFrame === "function";
299
- const scheduler = config.scheduler ?? (typeof host.requestAnimationFrame === "function" ? {
300
- raf: (cb) => host.requestAnimationFrame(cb),
301
- caf: (h) => host.cancelAnimationFrame(h)
302
- } : {
303
- raf: () => 0,
304
- caf: () => {}
305
- });
306
- const cam = () => ctx.getState().camera;
307
- const vp = () => ctx.getState().vp;
308
- const dpr = () => ctx.getState().dpr;
309
- const pad = () => ctx.getState().padding;
310
- const paged = () => ctx.getState().flow === "paged";
311
- const isFitAll = () => {
312
- const z = ctx.getState().zoom;
313
- return "mode" in z && z.mode === _embedpdf_core_stage.ZoomMode.FitAll;
314
- };
315
- let groupingCache = null;
316
- const grouping = () => {
317
- const doc = ctx.document();
318
- const st = ctx.getState();
319
- const key = `${doc ? doc.pageCount : 0}|${st.spread}`;
320
- if (groupingCache && groupingCache.key === key) return groupingCache;
321
- const g = _embedpdf_core_stage.groupPages(doc ? doc.pageCount : 0, st.spread);
322
- groupingCache = {
323
- key,
324
- grouping: g,
325
- firstPages: g.map((item) => item[0])
326
- };
327
- return groupingCache;
328
- };
329
- const itemIndexOfPage = (pageIndex) => {
330
- const fp = grouping().firstPages;
331
- let lo = 0;
332
- let hi = fp.length;
333
- while (lo < hi) {
334
- const mid = lo + hi >> 1;
335
- if (fp[mid] <= pageIndex) lo = mid + 1;
336
- else hi = mid;
643
+ function createGestures(ctx, { scene }, write, { cancelAnimation, animateTo, startCameraPhysics }, { scrollMetricsNow }) {
644
+ const { camera, viewport, padding, buildScene, stayBounds, constraint } = scene;
645
+ const { gesture, armRest, writeCamera, writeCameraUnclamped, rubberize, unrubberize, syncCursorFromCamera, markCause } = write;
646
+ const scrollTo = (options) => {
647
+ markCause("user");
648
+ cancelAnimation();
649
+ if (!buildScene().itemCount) return;
650
+ const next = (0, _embedpdf_core_stage.cameraFromScroll)(camera(), stayBounds(), viewport(), padding(), options);
651
+ if (options.behavior === "smooth") animateTo(next, stayBounds(), void 0, syncCursorFromCamera);
652
+ else {
653
+ writeCamera(next);
654
+ syncCursorFromCamera();
337
655
  }
338
- return Math.max(0, lo - 1);
339
- };
340
- const effectiveZoom = () => {
341
- const z = ctx.getState().zoom;
342
- return "level" in z ? z.level : Math.max(ctx.getState().camera.zoom, 1e-4);
343
656
  };
344
- const wrapLineWidth = () => Math.max(1, (vp().width - 2 * pad()) / effectiveZoom());
345
- const worldPageFrame = () => {
346
- const m = ctx.getState().pageFrame;
347
- if (!m.top && !m.right && !m.bottom && !m.left) return m;
348
- const ez = effectiveZoom();
349
- return {
350
- top: m.top / ez,
351
- right: m.right / ez,
352
- bottom: m.bottom / ez,
353
- left: m.left / ez
354
- };
355
- };
356
- const frameKey = () => {
357
- const m = ctx.getState().pageFrame;
358
- if (!m.top && !m.right && !m.bottom && !m.left) return "-";
359
- const w = worldPageFrame();
360
- return `${Math.round(w.top)},${Math.round(w.right)},${Math.round(w.bottom)},${Math.round(w.left)}`;
361
- };
362
- const worldGap = () => {
363
- const g = ctx.getState().gap;
364
- return typeof g === "number" ? g : g.px ? g.px / effectiveZoom() : 0;
657
+ return { api: {
658
+ setCamera: (next) => {
659
+ markCause("user");
660
+ cancelAnimation();
661
+ writeCamera(next);
662
+ syncCursorFromCamera();
663
+ },
664
+ panBy: (dx, dy) => {
665
+ markCause("user");
666
+ cancelAnimation();
667
+ if (gesture.depth > 0 && gesture.elastic) {
668
+ gesture.raw = (0, _embedpdf_core_stage.panByScreen)(gesture.raw ?? unrubberize(camera()), dx, dy);
669
+ writeCameraUnclamped(rubberize(gesture.raw));
670
+ } else writeCamera((0, _embedpdf_core_stage.panByScreen)(camera(), dx, dy));
671
+ syncCursorFromCamera();
672
+ },
673
+ scrollTo,
674
+ scrollBy: ({ left, top, behavior }) => {
675
+ const metrics = scrollMetricsNow();
676
+ scrollTo({
677
+ left: left === void 0 ? void 0 : metrics.scrollLeft + left,
678
+ top: top === void 0 ? void 0 : metrics.scrollTop + top,
679
+ behavior
680
+ });
681
+ },
682
+ zoomAround: (point, factor) => {
683
+ cancelAnimation();
684
+ const before = buildScene();
685
+ const focal = before.itemCount ? (0, _embedpdf_core_stage.anchorAtPoint)(before, (0, _embedpdf_core_stage.toWorld)(camera(), point)) : null;
686
+ writeCamera((0, _embedpdf_core_stage.zoomAround)(camera(), point, factor));
687
+ if (gesture.depth === 0) ctx.state.update(patchSettings, { zoom: { level: camera().zoom } });
688
+ const after = buildScene();
689
+ if (after !== before && focal && after.itemCount) writeCamera((0, _embedpdf_core_stage.cameraForAnchorAtScreen)(focal, after, point, camera().zoom));
690
+ if (gesture.depth > 0 && gesture.elastic) gesture.raw = unrubberize(camera());
691
+ syncCursorFromCamera();
692
+ },
693
+ beginGesture: (options) => {
694
+ gesture.depth++;
695
+ if (gesture.depth === 1) {
696
+ cancelAnimation();
697
+ gesture.zoomed = false;
698
+ gesture.elastic = options?.elastic === true;
699
+ gesture.raw = null;
700
+ }
701
+ },
702
+ endGesture: () => {
703
+ if (gesture.depth === 0) return;
704
+ gesture.depth--;
705
+ if (gesture.depth > 0) return;
706
+ gesture.raw = null;
707
+ const wasElastic = gesture.elastic;
708
+ gesture.elastic = false;
709
+ if (gesture.zoomed) {
710
+ gesture.zoomed = false;
711
+ ctx.state.update(patchSettings, { zoom: { level: camera().zoom } });
712
+ armRest();
713
+ }
714
+ syncCursorFromCamera();
715
+ if (wasElastic) {
716
+ const current = camera();
717
+ const clamped = (0, _embedpdf_core_stage.clampCamera)(current, stayBounds(), viewport(), constraint());
718
+ if (clamped.x !== current.x || clamped.y !== current.y) startCameraPhysics(0, 0);
719
+ }
720
+ }
721
+ } };
722
+ }
723
+ //#endregion
724
+ //#region src/camera/write.ts
725
+ /**
726
+ * The camera write path: the one low-level clamped write (`writeCamera`), the
727
+ * elastic unclamped write and its rubber-band curves, the rest detector that
728
+ * gates device snapping, the gesture bracket state, and cursor reconciliation
729
+ * from the camera (manipulation only; navigation sets the cursor as intent).
730
+ */
731
+ /** How long the zoom must hold still before the camera counts as resting. */
732
+ const REST_MS = 150;
733
+ function createCameraWrite(ctx, { clock, placement, scene }) {
734
+ const { camera, viewport, paged, buildScene, stayBounds, constraint } = scene;
735
+ const gesture = {
736
+ depth: 0,
737
+ zoomed: false,
738
+ elastic: false,
739
+ raw: null
365
740
  };
366
- const gapKey = () => {
367
- const g = ctx.getState().gap;
368
- return typeof g === "number" ? String(g) : `px:${Math.round(worldGap())}`;
741
+ let cancelRest = null;
742
+ const stopRestCountdown = () => {
743
+ cancelRest?.();
744
+ cancelRest = null;
369
745
  };
370
- const layoutFor = (groups) => {
371
- const st = ctx.getState();
372
- const raw = ctx.document()?.pages ?? [];
373
- const vr = st.viewRotation;
374
- const pages = vr === 0 ? raw : raw.map((p) => ({
375
- ...p,
376
- rotation: (0, _embedpdf_core_geometry.addRotations)(p.rotation, vr)
377
- }));
378
- const pageFrame = worldPageFrame();
379
- const gap = worldGap();
380
- const vupp = st.viewUnitsPerPoint;
381
- if (st.layout === "grid") return _embedpdf_core_stage.gridLayout(pages, groups, {
382
- gap,
383
- sizing: st.sizing,
384
- direction: st.direction,
385
- pageFrame,
386
- viewUnitsPerPoint: vupp,
387
- columns: typeof st.columns === "number" ? st.columns : void 0,
388
- lineWidth: st.columns === "auto" ? wrapLineWidth() : void 0
389
- });
390
- return st.layout === "horizontal" ? _embedpdf_core_stage.linearLayout(pages, groups, {
391
- axis: "x",
392
- gap,
393
- sizing: st.sizing,
394
- direction: st.direction,
395
- pageFrame,
396
- viewUnitsPerPoint: vupp
397
- }) : _embedpdf_core_stage.linearLayout(pages, groups, {
398
- axis: "y",
399
- gap,
400
- sizing: st.sizing,
401
- direction: st.direction,
402
- pageFrame,
403
- viewUnitsPerPoint: vupp
746
+ const armRest = () => {
747
+ if (!clock.hasFrames || !placement.started) return;
748
+ ctx.state.update(setCameraResting, false);
749
+ stopRestCountdown();
750
+ cancelRest = clock.after(REST_MS, () => {
751
+ cancelRest = null;
752
+ ctx.state.update(setCameraResting, true);
404
753
  });
405
754
  };
406
- const columnsKey = () => {
407
- const st = ctx.getState();
408
- if (st.layout !== "grid") return "-";
409
- return st.columns === "auto" ? `auto:${Math.round(wrapLineWidth())}` : String(st.columns);
410
- };
411
- const SCENE_KEY_FNS = {
412
- columns: columnsKey,
413
- gap: gapKey,
414
- pageFrame: frameKey
415
- };
416
- const SCENE_KEYS = SETTING_KEYS.filter((k) => SETTINGS_EFFECT[k] === "scene");
417
- const settingsKey = () => SCENE_KEYS.map((k) => {
418
- const fn = SCENE_KEY_FNS[k];
419
- if (fn) return fn();
420
- const v = ctx.getState()[k];
421
- return typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
422
- }).join("|");
423
- let sceneCache = null;
424
- const docKey = () => {
425
- const doc = ctx.document();
426
- return doc ? `${doc.pageCount}.${doc.revision}` : "0.0";
755
+ const writeCamera = (next, bounds = stayBounds()) => {
756
+ const clamped = (0, _embedpdf_core_stage.clampCamera)(next, bounds, viewport(), constraint());
757
+ if (clamped.zoom !== camera().zoom) if (gesture.depth > 0) {
758
+ gesture.zoomed = true;
759
+ ctx.state.update(setCameraResting, false);
760
+ stopRestCountdown();
761
+ } else armRest();
762
+ ctx.state.update(setCamera, clamped);
427
763
  };
428
- const buildScene = () => {
429
- const st = ctx.getState();
430
- const { grouping: g } = grouping();
431
- if (st.flow === "paged") {
432
- const idx = g.length ? Math.min(itemIndexOfPage(st.cursor), g.length - 1) : 0;
433
- const key = `paged|${docKey()}|${settingsKey()}|${idx}`;
434
- if (sceneCache && sceneCache.key === key) return sceneCache.scene;
435
- const scene = layoutFor(g.length ? [g[idx]] : []);
436
- sceneCache = {
437
- key,
438
- scene
439
- };
440
- return scene;
441
- }
442
- const key = `cont|${docKey()}|${settingsKey()}`;
443
- if (sceneCache && sceneCache.key === key) return sceneCache.scene;
444
- const scene = layoutFor(g);
445
- sceneCache = {
446
- key,
447
- scene
764
+ const axisTravels = (origin, content, view, zoom) => !(0, _embedpdf_core_stage.travelRange)(origin, content, view, zoom, constraint().padding).fits;
765
+ /** The unclamped finger-integrated camera → the displayed camera: clamp,
766
+ * then re-apply the overshoot through the resistance curve, per travelling
767
+ * axis. Inside the bounds this is exactly the clamp, and on a fitting axis
768
+ * it is always the clamp. */
769
+ const rubberize = (raw) => {
770
+ const clamped = (0, _embedpdf_core_stage.clampCamera)(raw, stayBounds(), viewport(), constraint());
771
+ const size = viewport();
772
+ const bounds = stayBounds();
773
+ const axis = (rawPosition, clampedPosition, dimension, travels) => {
774
+ if (!travels) return clampedPosition;
775
+ const overshootWorld = rawPosition - clampedPosition;
776
+ if (overshootWorld === 0) return clampedPosition;
777
+ const stretch = rubberOut(Math.abs(overshootWorld) * raw.zoom, Math.max(1, dimension));
778
+ return clampedPosition + Math.sign(overshootWorld) * stretch / raw.zoom;
448
779
  };
449
- return scene;
450
- };
451
- const sceneRect = () => {
452
- const { width, height } = buildScene().size;
453
780
  return {
454
- x: 0,
455
- y: 0,
456
- width,
457
- height
781
+ zoom: raw.zoom,
782
+ x: axis(raw.x, clamped.x, size.width, axisTravels(bounds.x, bounds.width, size.width, raw.zoom)),
783
+ y: axis(raw.y, clamped.y, size.height, axisTravels(bounds.y, bounds.height, size.height, raw.zoom))
458
784
  };
459
785
  };
460
- const itemRect = (it) => ({
461
- x: it.x,
462
- y: it.y,
463
- width: it.width,
464
- height: it.height
465
- });
466
- const pageRectOf = (it, pageIndex) => {
467
- const box = it.pages.find((p) => p.pageIndex === pageIndex) ?? it.pages[0];
786
+ /** The displayed camera → the raw position `rubberize` would have produced
787
+ * it from. The overshoot is capped just under the asymptote so the inverse
788
+ * stays finite whatever state a catch finds the camera in. */
789
+ const unrubberize = (displayed) => {
790
+ const clamped = (0, _embedpdf_core_stage.clampCamera)(displayed, stayBounds(), viewport(), constraint());
791
+ const size = viewport();
792
+ const bounds = stayBounds();
793
+ const axis = (displayedPosition, clampedPosition, dimension, travels) => {
794
+ if (!travels) return clampedPosition;
795
+ const overshootWorld = displayedPosition - clampedPosition;
796
+ if (overshootWorld === 0) return clampedPosition;
797
+ const span = Math.max(1, dimension);
798
+ const stretch = Math.min(Math.abs(overshootWorld) * displayed.zoom, span - 1);
799
+ return clampedPosition + Math.sign(overshootWorld) * rubberIn(stretch, span) / displayed.zoom;
800
+ };
468
801
  return {
469
- x: box.x,
470
- y: box.y,
471
- width: box.width,
472
- height: box.height
802
+ zoom: displayed.zoom,
803
+ x: axis(displayed.x, clamped.x, size.width, axisTravels(bounds.x, bounds.width, size.width, displayed.zoom)),
804
+ y: axis(displayed.y, clamped.y, size.height, axisTravels(bounds.y, bounds.height, size.height, displayed.zoom))
473
805
  };
474
806
  };
475
- /** The item shown for the cursor: the slice's only item (paged) / the full-scene item. */
476
- const cursorItem = () => {
477
- const sc = buildScene();
478
- return paged() ? sc.items[0] : sc.items[itemIndexOfPage(ctx.getState().cursor)];
479
- };
807
+ const writeCameraUnclamped = (next) => ctx.state.update(setCamera, next);
480
808
  /**
481
- * CONTENT-space rect on a page → WORLD rect: the same quarter-turn matrix
482
- * `pageRectToScreen` uses, minus the camera — so a positioned reveal and
483
- * the rendered overlay can never disagree about where a rect is.
484
- */
485
- const worldRectForContent = (it, pageIndex, rect) => {
486
- const box = it.pages.find((p) => p.pageIndex === pageIndex) ?? it.pages[0];
487
- const content = (0, _embedpdf_core_geometry.displaySize)({
488
- width: box.width,
489
- height: box.height
490
- }, box.rotation);
491
- const wr = (0, _embedpdf_core_geometry.applyRect)((0, _embedpdf_core_geometry.rotateScaleMatrix)(box.contentScale, content.width, content.height, box.rotation), rect);
492
- return {
493
- x: box.x + wr.x,
494
- y: box.y + wr.y,
495
- width: wr.width,
496
- height: wr.height
497
- };
498
- };
499
- /**
500
- * One axis of a positioned-reveal camera. `undefined` = 'nearest' (only
501
- * move if the target is outside the padded view) — unless the zoom just
502
- * changed, where "don't move" is meaningless and the spec's slack-axis
503
- * rule (center) applies. 'keep' never moves the axis (PDF /XYZ null).
504
- */
505
- const revealAxis = (a, camPos, rectPos, rectExtent, vpExtent, zoom, zoomChanged) => {
506
- if (a === "keep") return camPos;
507
- const p = pad();
508
- if (a === void 0) {
509
- if (!zoomChanged) {
510
- const lo = camPos + p / zoom;
511
- const hi = camPos + (vpExtent - p) / zoom;
512
- if (rectPos >= lo && rectPos + rectExtent <= hi) return camPos;
513
- if (rectExtent > hi - lo || rectPos < lo) return rectPos - p / zoom;
514
- return rectPos + rectExtent - (vpExtent - p) / zoom;
515
- }
516
- a = "center";
517
- }
518
- if (a === "start") return rectPos - p / zoom;
519
- if (a === "end") return rectPos + rectExtent - (vpExtent - p) / zoom;
520
- const f = a === "center" ? .5 : Math.min(1, Math.max(0, a));
521
- return rectPos + rectExtent / 2 - vpExtent * f / zoom;
522
- };
523
- const fits = (rect, zoom) => {
524
- const v = vp();
525
- const p = pad();
526
- const eps = .5;
527
- return rect.width * zoom <= v.width - 2 * p + eps && rect.height * zoom <= v.height - 2 * p + eps;
528
- };
529
- const alignFraction = (a) => a === "start" ? 0 : a === "center" ? .5 : a === "end" ? 1 : Math.min(1, Math.max(0, a));
530
- const alignPoint = (al, v) => {
531
- const rtl = ctx.getState().direction === "rtl";
532
- const ax = rtl && al.x === "start" ? "end" : rtl && al.x === "end" ? "start" : al.x;
533
- const p = pad();
534
- return {
535
- x: p + (v.width - 2 * p) * alignFraction(ax),
536
- y: p + (v.height - 2 * p) * alignFraction(al.y)
537
- };
538
- };
539
- const anchorPoint = () => alignPoint(ctx.getState().anchorAlign, vp());
540
- /** Fit-box for resolving the zoom intent: whole scene (fit-all), the current item
541
- * (paged — per-page fit), or the document max (continuous — doc-stable zoom). */
542
- const fitBox = (item) => {
543
- if (isFitAll()) return buildScene().size;
544
- return paged() ? {
545
- width: item.width,
546
- height: item.height
547
- } : buildScene().maxItemSize;
548
- };
549
- /** Clamp rect for a camera write targeting this item (scene-wide in continuous). */
550
- const boundsFor = (it) => paged() ? itemRect(it) : sceneRect();
551
- /** Clamp rect for a "stay" write (pan/zoom): the slice item (paged) / the scene. */
552
- const stayBounds = () => paged() ? itemRect(buildScene().items[0]) : sceneRect();
553
- const constraint = () => ({
554
- bounded: ctx.getState().bounded,
555
- padding: pad(),
556
- fitAlign: ctx.getState().fitAlign,
557
- direction: ctx.getState().direction
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
- };
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();
601
- ctx.dispatch({
602
- type: "CAMERA",
603
- camera: clamped
604
- });
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
- });
653
- /**
654
- * Cursor reconciliation, one direction per interaction:
655
- * navigation → cursor is INTENT, set explicitly; the camera honors it as far
656
- * as the clamp allows — and a clamped camera never revokes it.
657
- * manipulation → (pan / drag / pinch) the camera moves freely; the cursor is
658
- * DERIVED from it. Only those verbs call this. Paged never syncs.
809
+ * Cursor reconciliation, one direction per interaction:
810
+ * navigation → the cursor is intent, set explicitly; the camera honors
811
+ * it as far as the clamp allows, and a clamped camera
812
+ * never revokes it.
813
+ * manipulation → (pan, drag, pinch) the camera moves freely and the
814
+ * cursor is derived from it. Only those verbs call this.
815
+ * Paged flow never syncs.
659
816
  */
660
817
  const syncCursorFromCamera = () => {
661
818
  if (paged()) return;
662
- const sc = buildScene();
663
- if (!sc.itemCount) return;
664
- const page = _embedpdf_core_stage.anchorFromCamera(cam(), sc, vp()).pageIndex;
665
- if (page !== ctx.getState().cursor) ctx.dispatch({
666
- type: "CURSOR",
667
- cursor: page
668
- });
669
- };
670
- let raf = 0;
671
- const cancelAnim = () => {
672
- if (raf) {
673
- scheduler.caf(raf);
674
- raf = 0;
675
- }
676
- };
677
- const lerp = (a, b, k) => a + (b - a) * k;
678
- const animateTo = (target, bounds, ms = 240, then) => {
679
- if (!canAnimate) {
680
- setCam(target, bounds);
681
- then?.();
682
- return;
683
- }
684
- cancelAnim();
685
- const from = cam();
686
- let started = false;
687
- let t0 = 0;
688
- const tick = (now) => {
689
- if (!started) {
690
- started = true;
691
- t0 = now;
692
- }
693
- const k = easeOutCubic(Math.min(1, (now - t0) / ms));
694
- setCam({
695
- x: lerp(from.x, target.x, k),
696
- y: lerp(from.y, target.y, k),
697
- zoom: lerp(from.zoom, target.zoom, k)
698
- }, bounds);
699
- if (k < 1) raf = scheduler.raf(tick);
700
- else {
701
- raf = 0;
702
- then?.();
703
- }
704
- };
705
- raf = scheduler.raf(tick);
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);
819
+ const scene = buildScene();
820
+ if (!scene.itemCount) return;
821
+ ctx.state.update(setCursor, (0, _embedpdf_core_stage.anchorFromCamera)(camera(), scene, viewport()).pageIndex);
816
822
  };
817
- const anchorAt = (at) => {
818
- const sc = buildScene();
819
- return sc.itemCount ? _embedpdf_core_stage.anchorFromCamera(cam(), sc, vp(), at) : {
820
- pageIndex: ctx.getState().cursor,
821
- fx: .5,
822
- fy: 0
823
- };
823
+ /** Tag what drives the next camera or cursor change (see `StageState.motionCause`). */
824
+ const markCause = (cause) => ctx.state.update(setMotionCause, cause);
825
+ return {
826
+ gesture,
827
+ armRest,
828
+ writeCamera,
829
+ writeCameraUnclamped,
830
+ axisTravels,
831
+ rubberize,
832
+ unrubberize,
833
+ syncCursorFromCamera,
834
+ markCause
824
835
  };
825
- const currentAnchor = () => anchorAt(anchorPoint());
836
+ }
837
+ //#endregion
838
+ //#region src/services/placement-latch.ts
839
+ const createPlacementLatch = () => ({
840
+ started: false,
841
+ pending: null
842
+ });
843
+ /**
844
+ * Run a navigation verb now, or, before the first placement, when it happens.
845
+ * Before placement there is no viewport to move a camera in, and placement
846
+ * would reset it anyway, so the last call waits and lands instantly after the
847
+ * initial view: an app that reveals something as the document opens sees it
848
+ * on the first frame.
849
+ */
850
+ function whenPlaced(latch, options, run) {
851
+ if (latch.started) {
852
+ run(options);
853
+ return;
854
+ }
855
+ latch.pending = () => run({
856
+ ...options,
857
+ behavior: "instant"
858
+ });
859
+ }
860
+ //#endregion
861
+ //#region src/navigation/arrive.ts
862
+ /**
863
+ * Anchors and arrivals. The anchor is the durable "what am I looking at":
864
+ * captured before a structural change and re-applied after, one mechanism for
865
+ * layout, spread, zoom, resize and restore. Navigation is canonical:
866
+ * goToPage(N) always ends in the same camera state whatever the zoom or the
867
+ * starting point.
868
+ */
869
+ function createArrival(ctx, { scene, placement }, { writeCamera, markCause }, { cancelAnimation, animateTo }) {
870
+ const { camera, viewport, padding, paged, isFitAll, grouping, itemIndexOfPage, buildScene, sceneRect, itemRect, pageRectOf, cursorItem, fits, alignPoint, fitBox, boundsFor, constraint, indexOfTarget } = scene;
871
+ const state = () => ctx.state.get();
872
+ const pageCount = () => ctx.document()?.pageCount ?? 0;
826
873
  /**
827
- * Run a placement whose RESOLVED zoom may change the scene (wrapped mode: zoom is
828
- * a layout input). Re-run it until the scene it placed against is the scene that
829
- * results — every non-wrapped mode is stable after the first pass by construction,
830
- * and wrapped fit-modes converge on the second (the fit-box is wrap-independent).
831
- * Capped at 3 passes for the one genuinely circular case (fit-all + wrapped).
874
+ * Run a placement whose resolved zoom may change the scene (in wrapped mode
875
+ * zoom is a layout input), until the scene it placed against is the scene
876
+ * that results. Every non-wrapped mode is stable after the first pass, and
877
+ * wrapped fit modes converge on the second (the fit box does not depend on
878
+ * wrapping). Capped at 3 passes for the one circular case (fit-all +
879
+ * wrapped).
832
880
  */
833
881
  const stabilized = (place) => {
834
882
  for (let attempt = 0; attempt < 3; attempt++) {
835
- const scene = buildScene();
883
+ const before = buildScene();
836
884
  place();
837
- if (buildScene() === scene) return;
885
+ if (buildScene() === before) return;
838
886
  }
839
887
  };
840
888
  const applyAnchor = (anchor, align) => {
841
889
  stabilized(() => {
842
- const scene = buildScene();
843
- if (!scene.itemCount) return;
844
- const item = scene.items[scene.itemOfPage(anchor.pageIndex)];
845
- const zoom = _embedpdf_core_stage.resolveZoom(ctx.getState().zoom, fitBox(item), vp(), pad());
846
- const at = alignPoint(align ?? ctx.getState().anchorAlign, vp());
847
- setCam(_embedpdf_core_stage.cameraFromAnchor(anchor, scene, vp(), zoom, at), boundsFor(item));
890
+ const current = buildScene();
891
+ if (!current.itemCount) return;
892
+ const item = current.items[current.itemOfPage(anchor.pageIndex)];
893
+ const zoom = (0, _embedpdf_core_stage.resolveZoom)(state().zoom, fitBox(item), viewport(), padding());
894
+ const at = alignPoint(align ?? state().anchorAlign, viewport());
895
+ writeCamera((0, _embedpdf_core_stage.cameraFromAnchor)(anchor, current, viewport(), zoom, at), boundsFor(item));
848
896
  });
849
897
  };
850
898
  /**
851
- * Re-apply the view after a structural/zoom/viewport change. Normally anchor-
852
- * preserving (keep looking at the same spot, held at the invariant point).
853
- * Under fit-all the subject is the WHOLE scene, so "keep my anchor" is
854
- * meaningless — re-place instead (this is what centers the scene even when
855
- * unbounded).
899
+ * Re-apply the view after a structural, zoom or viewport change. Normally
900
+ * anchor-preserving (keep looking at the same spot, held at the invariant
901
+ * point). Under fit-all the subject is the whole scene, so keeping an
902
+ * anchor is meaningless: re-place instead (which also centers the scene
903
+ * when unbounded).
856
904
  */
857
905
  const reapply = (anchor, align) => {
858
- if (isFitAll()) goToTarget(ctx.getState().cursor, { behavior: "instant" });
906
+ if (isFitAll()) goToTarget(state().cursor, { behavior: "instant" });
859
907
  else applyAnchor(anchor, align);
860
908
  };
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
- };
869
- const goToTarget = (pageIndex, opts) => {
909
+ const goToTarget = (pageIndex, options) => {
870
910
  markCause("programmatic");
871
- cancelAnim();
872
- const doc = ctx.document();
873
- if (!doc || doc.pageCount === 0) return;
874
- const target = Math.max(0, Math.min(pageIndex, doc.pageCount - 1));
875
- if (target !== ctx.getState().cursor) ctx.dispatch({
876
- type: "CURSOR",
877
- cursor: target
878
- });
879
- if (opts?.viewpoint) {
880
- ctx.dispatch({
881
- type: "PATCH",
882
- patch: { zoom: opts.viewpoint.zoom }
883
- });
884
- applyAnchor(opts.viewpoint.anchor);
911
+ cancelAnimation();
912
+ if (pageCount() === 0) return;
913
+ const target = Math.max(0, Math.min(pageIndex, pageCount() - 1));
914
+ ctx.state.update(setCursor, target);
915
+ if (options?.viewpoint) {
916
+ ctx.state.update(patchSettings, { zoom: options.viewpoint.zoom });
917
+ applyAnchor(options.viewpoint.anchor);
885
918
  return;
886
919
  }
887
920
  const placement = () => {
888
- const sc = buildScene();
889
- if (!sc.itemCount) return null;
890
- const item = paged() ? sc.items[0] : sc.items[itemIndexOfPage(target)];
891
- const zoom = _embedpdf_core_stage.resolveZoom(ctx.getState().zoom, fitBox(item), vp(), pad());
921
+ const current = buildScene();
922
+ if (!current.itemCount) return null;
923
+ const item = paged() ? current.items[0] : current.items[itemIndexOfPage(target)];
924
+ const zoom = (0, _embedpdf_core_stage.resolveZoom)(state().zoom, fitBox(item), viewport(), padding());
892
925
  const subject = isFitAll() ? sceneRect() : fits(itemRect(item), zoom) ? itemRect(item) : pageRectOf(item, target);
893
- const want = {
894
- ...ctx.getState().arrivalAlign,
895
- ...opts?.arrivalAlign
926
+ const landing = {
927
+ ...state().arrivalAlign,
928
+ ...options?.arrivalAlign
896
929
  };
897
- const placed = _embedpdf_core_stage.placeCamera(subject, vp(), zoom, pad(), {
898
- x: want.x === "keep" ? "start" : want.x,
899
- y: want.y === "keep" ? "start" : want.y
900
- }, ctx.getState().direction);
930
+ const placed = (0, _embedpdf_core_stage.placeCamera)(subject, viewport(), zoom, padding(), {
931
+ x: landing.x === "keep" ? "start" : landing.x,
932
+ y: landing.y === "keep" ? "start" : landing.y
933
+ }, state().direction);
901
934
  const bounds = boundsFor(item);
902
- const settled = _embedpdf_core_stage.clampCamera(placed, bounds, vp(), {
935
+ const settled = (0, _embedpdf_core_stage.clampCamera)(placed, bounds, viewport(), {
903
936
  ...constraint(),
904
937
  bounded: true
905
938
  });
906
939
  return {
907
940
  camera: {
908
941
  zoom,
909
- x: want.x === "keep" ? cam().x : settled.x,
910
- y: want.y === "keep" ? cam().y : settled.y
942
+ x: landing.x === "keep" ? camera().x : settled.x,
943
+ y: landing.y === "keep" ? camera().y : settled.y
911
944
  },
912
945
  bounds
913
946
  };
914
947
  };
915
- if ((opts?.behavior ?? ctx.getState().scrollBehavior) === "smooth") {
916
- const p = placement();
917
- if (p) animateTo(p.camera, p.bounds);
948
+ if ((options?.behavior ?? state().scrollBehavior) === "smooth") {
949
+ const landing = placement();
950
+ if (landing) animateTo(landing.camera, landing.bounds);
918
951
  } else stabilized(() => {
919
- const p = placement();
920
- if (p) setCam(p.camera, p.bounds);
952
+ const landing = placement();
953
+ if (landing) writeCamera(landing.camera, landing.bounds);
921
954
  });
922
955
  };
923
- /** Step by the navigation unit: the ITEM when it fits the viewport, else the PAGE. */
924
- const step = (direction, opts) => {
925
- const st = ctx.getState();
956
+ /** Step by the navigation unit: the item when it fits the viewport, else the page. */
957
+ const step = (direction, options) => {
926
958
  const item = cursorItem();
927
959
  if (!item) return;
928
- const { grouping: g } = grouping();
929
- if (isFitAll() || fits(itemRect(item), cam().zoom)) {
930
- const idx = Math.max(0, Math.min(itemIndexOfPage(st.cursor) + direction, g.length - 1));
931
- goToTarget(g[idx][0], opts);
932
- } else goToTarget(st.cursor + direction, opts);
960
+ const { grouping: groups } = grouping();
961
+ if (isFitAll() || fits(itemRect(item), camera().zoom)) {
962
+ const index = Math.max(0, Math.min(itemIndexOfPage(state().cursor) + direction, groups.length - 1));
963
+ goToTarget(groups[index][0], options);
964
+ } else goToTarget(state().cursor + direction, options);
965
+ };
966
+ const resetView = () => goToTarget(0, { behavior: "instant" });
967
+ return {
968
+ stabilized,
969
+ applyAnchor,
970
+ reapply,
971
+ goToTarget,
972
+ resetView,
973
+ api: {
974
+ goToPage: (page, options) => {
975
+ const index = indexOfTarget(page);
976
+ if (index !== null) whenPlaced(placement, options, (go) => goToTarget(index, go));
977
+ },
978
+ goToFirstPage: (options) => whenPlaced(placement, options, (go) => goToTarget(0, go)),
979
+ goToLastPage: (options) => whenPlaced(placement, options, (go) => goToTarget(Math.max(0, (ctx.document()?.pageCount ?? 1) - 1), go)),
980
+ canGoNext: () => state().cursor < pageCount() - 1,
981
+ canGoPrevious: () => state().cursor > 0,
982
+ nextPage: (options) => whenPlaced(placement, options, (go) => step(1, go)),
983
+ previousPage: (options) => whenPlaced(placement, options, (go) => step(-1, go)),
984
+ resetView
985
+ }
986
+ };
987
+ }
988
+ //#endregion
989
+ //#region src/destination.ts
990
+ function revealOfDestination(destination, size) {
991
+ const { width, height } = size;
992
+ switch (destination.kind) {
993
+ case "xyz": {
994
+ const { x, y, zoom } = destination;
995
+ return {
996
+ rect: {
997
+ x: x ?? 0,
998
+ y: y ?? 0,
999
+ width: 0,
1000
+ height: 0
1001
+ },
1002
+ anchor: {
1003
+ x: x != null ? "start" : "keep",
1004
+ y: y != null ? "start" : "keep"
1005
+ },
1006
+ zoom: zoom != null && zoom !== 0 ? { level: zoom } : "keep"
1007
+ };
1008
+ }
1009
+ case "fit": return { zoom: "fit" };
1010
+ case "fitB": return {
1011
+ rect: {
1012
+ x: 0,
1013
+ y: 0,
1014
+ width,
1015
+ height
1016
+ },
1017
+ zoom: "fit"
1018
+ };
1019
+ case "fitH":
1020
+ case "fitBH": {
1021
+ const { y } = destination;
1022
+ return {
1023
+ rect: {
1024
+ x: 0,
1025
+ y: y ?? 0,
1026
+ width,
1027
+ height: 0
1028
+ },
1029
+ zoom: "fit-width",
1030
+ anchor: { y: y != null ? "start" : "keep" }
1031
+ };
1032
+ }
1033
+ case "fitV":
1034
+ case "fitBV": {
1035
+ const { x } = destination;
1036
+ return {
1037
+ rect: {
1038
+ x: x ?? 0,
1039
+ y: 0,
1040
+ width: 0,
1041
+ height
1042
+ },
1043
+ zoom: "fit-height",
1044
+ anchor: { x: x != null ? "start" : "keep" }
1045
+ };
1046
+ }
1047
+ case "fitR": {
1048
+ const { x, y, width: rectWidth, height: rectHeight } = destination;
1049
+ return {
1050
+ rect: {
1051
+ x,
1052
+ y,
1053
+ width: rectWidth,
1054
+ height: rectHeight
1055
+ },
1056
+ zoom: "fit"
1057
+ };
1058
+ }
1059
+ }
1060
+ }
1061
+ //#endregion
1062
+ //#region src/navigation/reveal.ts
1063
+ /**
1064
+ * Reveals and destinations. A bare reveal is not navigation: minimal
1065
+ * visibility, cursor untouched (in paged flow, revealing a page is navigating
1066
+ * to it). A positioned reveal (rect, anchor or zoom: a search hit) is an
1067
+ * arrival: the cursor is set up front, the camera lands per the anchor
1068
+ * policy, and a resolved zoom becomes the zoom intent. A destination is a
1069
+ * positioned reveal of the spot it names.
1070
+ */
1071
+ function createReveal(ctx, { scene, placement }, { writeCamera, markCause }, { cancelAnimation, animateTo }, { stabilized, goToTarget }) {
1072
+ const { camera, viewport, padding, paged, itemIndexOfPage, worldPageFrame, buildScene, sceneRect, pageRectOf, worldRectForContent, boundsFor, indexOfTarget } = scene;
1073
+ const state = () => ctx.state.get();
1074
+ /**
1075
+ * One axis of a positioned-reveal camera. `undefined` is 'nearest' (move
1076
+ * only when the target is outside the padded view), unless the zoom just
1077
+ * changed: then "do not move" is meaningless and the slack-axis rule
1078
+ * (center) applies. 'keep' never moves the axis (PDF /XYZ null).
1079
+ */
1080
+ const revealAxis = (anchor, cameraPosition, rectPosition, rectExtent, viewportExtent, zoom, zoomChanged) => {
1081
+ if (anchor === "keep") return cameraPosition;
1082
+ const inset = padding();
1083
+ if (anchor === void 0) {
1084
+ if (!zoomChanged) {
1085
+ const low = cameraPosition + inset / zoom;
1086
+ const high = cameraPosition + (viewportExtent - inset) / zoom;
1087
+ if (rectPosition >= low && rectPosition + rectExtent <= high) return cameraPosition;
1088
+ if (rectExtent > high - low || rectPosition < low) return rectPosition - inset / zoom;
1089
+ return rectPosition + rectExtent - (viewportExtent - inset) / zoom;
1090
+ }
1091
+ anchor = "center";
1092
+ }
1093
+ if (anchor === "start") return rectPosition - inset / zoom;
1094
+ if (anchor === "end") return rectPosition + rectExtent - (viewportExtent - inset) / zoom;
1095
+ const fraction = anchor === "center" ? .5 : Math.min(1, Math.max(0, anchor));
1096
+ return rectPosition + rectExtent / 2 - viewportExtent * fraction / zoom;
1097
+ };
1098
+ const revealAt = (pageIndex, options) => {
1099
+ markCause("programmatic");
1100
+ const pageCount = ctx.document()?.pageCount ?? 0;
1101
+ if (pageCount === 0) return;
1102
+ const target = Math.max(0, Math.min(pageIndex, pageCount - 1));
1103
+ if (!(!!options && (options.rect != null || options.anchor !== void 0 || options.zoom !== void 0 && options.zoom !== "keep"))) {
1104
+ if (paged()) {
1105
+ goToTarget(target, options);
1106
+ return;
1107
+ }
1108
+ const current = buildScene();
1109
+ if (!current.itemCount) return;
1110
+ const page = pageRectOf(current.items[itemIndexOfPage(target)], target);
1111
+ const frame = worldPageFrame();
1112
+ const box = {
1113
+ x: page.x - frame.left,
1114
+ y: page.y - frame.top,
1115
+ width: page.width + frame.left + frame.right,
1116
+ height: page.height + frame.top + frame.bottom
1117
+ };
1118
+ const next = (0, _embedpdf_core_stage.revealCamera)(camera(), box, viewport(), padding());
1119
+ const now = camera();
1120
+ if (next.x === now.x && next.y === now.y) return;
1121
+ cancelAnimation();
1122
+ if ((options?.behavior ?? state().scrollBehavior) === "smooth") animateTo(next, sceneRect());
1123
+ else writeCamera(next, sceneRect());
1124
+ return;
1125
+ }
1126
+ cancelAnimation();
1127
+ ctx.state.update(setCursor, target);
1128
+ const place = () => {
1129
+ const current = buildScene();
1130
+ if (!current.itemCount) return null;
1131
+ const item = paged() ? current.items[0] : current.items[itemIndexOfPage(target)];
1132
+ const world = options.rect ? worldRectForContent(item, target, options.rect) : pageRectOf(item, target);
1133
+ const zoomDirective = options.zoom ?? "keep";
1134
+ const availableWidth = Math.max(1, viewport().width - 2 * padding());
1135
+ const availableHeight = Math.max(1, viewport().height - 2 * padding());
1136
+ let zoom = typeof zoomDirective === "object" ? zoomDirective.level : zoomDirective === "fit" ? Math.min(availableWidth / world.width, availableHeight / world.height) : zoomDirective === "fit-width" ? availableWidth / world.width : zoomDirective === "fit-height" ? availableHeight / world.height : camera().zoom;
1137
+ if (!Number.isFinite(zoom) || zoom <= 0) zoom = camera().zoom;
1138
+ const zoomChanged = zoomDirective !== "keep";
1139
+ const anchor = options.anchor ?? {};
1140
+ return {
1141
+ camera: {
1142
+ x: revealAxis(anchor.x, camera().x, world.x, world.width, viewport().width, zoom, zoomChanged),
1143
+ y: revealAxis(anchor.y, camera().y, world.y, world.height, viewport().height, zoom, zoomChanged),
1144
+ zoom
1145
+ },
1146
+ bounds: boundsFor(item),
1147
+ zoomChanged
1148
+ };
1149
+ };
1150
+ const first = place();
1151
+ if (!first) return;
1152
+ if (first.zoomChanged) ctx.state.update(patchSettings, { zoom: { level: first.camera.zoom } });
1153
+ if ((options.behavior ?? state().scrollBehavior) === "smooth") {
1154
+ const landing = place() ?? first;
1155
+ animateTo(landing.camera, landing.bounds);
1156
+ } else stabilized(() => {
1157
+ const landing = place();
1158
+ if (landing) writeCamera(landing.camera, landing.bounds);
1159
+ });
933
1160
  };
934
- const ponForIndex = (index) => ctx.document()?.pages[index]?.pageObjectNumber ?? index + 1;
1161
+ const reveal = (page, options) => {
1162
+ const index = indexOfTarget(page);
1163
+ if (index !== null) whenPlaced(placement, options, (go) => revealAt(index, go));
1164
+ };
1165
+ const goToDestination = (destination, options) => {
1166
+ const layout = ctx.getPage(destination.page);
1167
+ if (!layout) return;
1168
+ whenPlaced(placement, {
1169
+ ...revealOfDestination(destination, layout.size),
1170
+ behavior: options?.behavior
1171
+ }, (go) => revealAt(layout.index, go));
1172
+ };
1173
+ return { api: {
1174
+ reveal,
1175
+ goToDestination
1176
+ } };
1177
+ }
1178
+ //#endregion
1179
+ //#region src/navigation/zoom.ts
1180
+ /**
1181
+ * Zoom verbs. Pointer-less zooms magnify around the zoomAlign focal point
1182
+ * (pinch and wheel pass their own pointer to `zoomAround`: physics beats
1183
+ * policy); fit modes are zoom intents written through the settings; the
1184
+ * double-tap ladder ascends reading postures.
1185
+ */
1186
+ /** The zoom factor of one zoomIn or zoomOut step. */
1187
+ const ZOOM_STEP = 1.2;
1188
+ function createZoom(ctx, { scene }, { syncCursorFromCamera }, { cancelAnimation, animateZoomAnchored }, gestures, settings) {
1189
+ const { camera, viewport, padding, paged, itemIndexOfPage, buildScene, alignPoint, fitBox } = scene;
1190
+ const state = () => ctx.state.get();
1191
+ const zoomAlignPoint = () => alignPoint(state().zoomAlign, viewport());
1192
+ const setViewRotation = (viewRotation) => settings.updateSettings({ viewRotation });
1193
+ return { api: {
1194
+ doubleTapZoom: (point) => {
1195
+ cancelAnimation();
1196
+ const before = buildScene();
1197
+ if (!before.itemCount) return;
1198
+ const item = paged() ? before.items[0] : before.items[itemIndexOfPage(state().cursor)];
1199
+ const fit = fitBox(item);
1200
+ const automatic = (0, _embedpdf_core_stage.resolveZoom)({ mode: _embedpdf_core_stage.ZoomMode.Automatic }, fit, viewport(), padding());
1201
+ const stops = [
1202
+ automatic,
1203
+ (0, _embedpdf_core_stage.resolveZoom)({ mode: _embedpdf_core_stage.ZoomMode.FitWidth }, fit, viewport(), padding()),
1204
+ Math.min(automatic * 2.5, _embedpdf_core_stage.ZOOM_MAX)
1205
+ ].sort((left, right) => left - right).filter((zoom, index, all) => index === 0 || zoom > all[index - 1] * 1.1);
1206
+ const near = (zoom, stop) => zoom > stop / 1.1 && zoom < stop * 1.1;
1207
+ const onRung = stops.findIndex((stop) => near(camera().zoom, stop));
1208
+ const target = onRung >= 0 ? stops[(onRung + 1) % stops.length] : stops[0];
1209
+ ctx.state.update(patchSettings, { zoom: { level: target } });
1210
+ const after = buildScene();
1211
+ if (!after.itemCount) return;
1212
+ animateZoomAnchored((0, _embedpdf_core_stage.anchorAtPoint)(after, (0, _embedpdf_core_stage.toWorld)(camera(), point)), point, target, void 0, syncCursorFromCamera);
1213
+ },
1214
+ zoomIn: () => gestures.zoomAround(zoomAlignPoint(), ZOOM_STEP),
1215
+ zoomOut: () => gestures.zoomAround(zoomAlignPoint(), 1 / ZOOM_STEP),
1216
+ zoomTo: (zoom, options) => {
1217
+ if (typeof zoom === "number") {
1218
+ if (options?.around) gestures.zoomAround(options.around, zoom / camera().zoom);
1219
+ else settings.updateSettings({ zoom: { level: zoom } });
1220
+ return;
1221
+ }
1222
+ settings.updateSettings({ zoom });
1223
+ },
1224
+ zoomBy: (factor, options) => gestures.zoomAround(options?.around ?? zoomAlignPoint(), factor),
1225
+ fitWidth: () => settings.updateSettings({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitWidth } }),
1226
+ fitPage: () => settings.updateSettings({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitPage } }),
1227
+ fitAll: () => settings.updateSettings({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitAll } }),
1228
+ fitAutomatic: () => settings.updateSettings({ zoom: { mode: _embedpdf_core_stage.ZoomMode.Automatic } }),
1229
+ setViewRotation,
1230
+ rotateViewBy: (delta) => setViewRotation((0, _embedpdf_core_geometry.addRotations)(state().viewRotation, delta === 90 ? 90 : 270))
1231
+ } };
1232
+ }
1233
+ //#endregion
1234
+ //#region src/read/pages.ts
1235
+ /**
1236
+ * The page reads: laid-out pages with their transforms (PDF points → view px
1237
+ * → device px), the scroller projection, the visible set (memoized so reads
1238
+ * stay reference-stable), coordinate conversions and the camera, zoom and
1239
+ * viewpoint reads.
1240
+ */
1241
+ function createPageReads(ctx, { scene }) {
1242
+ const { camera, viewport, dpr, padding, paged, buildScene, cacheKey, stayBounds, cursorItem, currentAnchor } = scene;
1243
+ const state = () => ctx.state.get();
1244
+ const pageObjectNumberAt = (index) => ctx.document()?.pages[index]?.ref.objectNumber ?? index + 1;
935
1245
  const unrotatedPoints = (box) => {
936
1246
  const content = (0, _embedpdf_core_geometry.displaySize)({
937
1247
  width: box.width,
@@ -943,38 +1253,38 @@ function createStageCapability(ctx, config = {}) {
943
1253
  };
944
1254
  };
945
1255
  const withTransform = (box) => {
946
- const reg = ctx.document()?.pages[box.pageIndex];
947
- const pageSize = reg ? {
948
- width: reg.size.width,
949
- height: reg.size.height
1256
+ const registered = ctx.document()?.pages[box.pageIndex];
1257
+ const pageSize = registered ? {
1258
+ width: registered.size.width,
1259
+ height: registered.size.height
950
1260
  } : unrotatedPoints(box);
951
- const c = cam();
1261
+ const current = camera();
952
1262
  const ratio = dpr();
953
- const rawX = (box.x - c.x) * c.zoom;
954
- const rawY = (box.y - c.y) * c.zoom;
955
- const resting = ctx.getState().cameraResting;
1263
+ const rawX = (box.x - current.x) * current.zoom;
1264
+ const rawY = (box.y - current.y) * current.zoom;
1265
+ const resting = state().cameraResting;
956
1266
  const screenX = resting ? (0, _embedpdf_core_geometry.snapToDevice)(rawX, ratio) : rawX;
957
1267
  const screenY = resting ? (0, _embedpdf_core_geometry.snapToDevice)(rawY, ratio) : rawY;
958
1268
  const transform = (0, _embedpdf_core_geometry.pageTransform)({
959
1269
  pageSize,
960
1270
  rotation: box.rotation,
961
- scale: box.contentScale * c.zoom,
962
- baseScale: ctx.getState().viewUnitsPerPoint * (reg?.userUnit ?? 1),
1271
+ scale: box.contentScale * current.zoom,
1272
+ baseScale: state().viewUnitsPerPoint * (registered?.userUnit ?? 1),
963
1273
  dpr: ratio
964
1274
  });
965
- const v = vp();
1275
+ const size = viewport();
966
1276
  const onScreen = (0, _embedpdf_core_geometry.intersectRects)({
967
1277
  x: -screenX,
968
1278
  y: -screenY,
969
- width: v.width,
970
- height: v.height
1279
+ width: size.width,
1280
+ height: size.height
971
1281
  }, {
972
1282
  x: 0,
973
1283
  y: 0,
974
1284
  width: transform.viewWidth,
975
1285
  height: transform.viewHeight
976
1286
  });
977
- const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToContentRect(onScreen) : {
1287
+ const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToPageRect(onScreen) : {
978
1288
  x: 0,
979
1289
  y: 0,
980
1290
  width: 0,
@@ -982,14 +1292,14 @@ function createStageCapability(ctx, config = {}) {
982
1292
  };
983
1293
  return {
984
1294
  ...box,
985
- pon: ponForIndex(box.pageIndex),
1295
+ ref: (0, _embedpdf_core.toPageRef)(pageObjectNumberAt(box.pageIndex)),
986
1296
  screenX,
987
1297
  screenY,
988
1298
  transform,
989
1299
  visibleRect
990
1300
  };
991
1301
  };
992
- const EMPTY_SCROLL_METRICS = {
1302
+ const emptyScrollMetrics = {
993
1303
  scrollLeft: 0,
994
1304
  scrollTop: 0,
995
1305
  scrollWidth: 0,
@@ -999,717 +1309,803 @@ function createStageCapability(ctx, config = {}) {
999
1309
  scrollableX: false,
1000
1310
  scrollableY: false
1001
1311
  };
1002
- let scrollMemo = null;
1312
+ let lastMetrics = null;
1003
1313
  const scrollMetricsNow = () => {
1004
- if (!ctx.getState().placed) return EMPTY_SCROLL_METRICS;
1005
- const sc = buildScene();
1006
- const m = _embedpdf_core_stage.scrollMetrics(cam(), sc.itemCount ? stayBounds() : {
1314
+ if (!state().placed) return emptyScrollMetrics;
1315
+ const metrics = (0, _embedpdf_core_stage.scrollMetrics)(camera(), buildScene().itemCount ? stayBounds() : {
1007
1316
  x: 0,
1008
1317
  y: 0,
1009
1318
  width: 0,
1010
1319
  height: 0
1011
- }, vp(), pad());
1012
- const p = scrollMemo;
1013
- if (p && p.scrollLeft === m.scrollLeft && p.scrollTop === m.scrollTop && p.scrollWidth === m.scrollWidth && p.scrollHeight === m.scrollHeight && p.clientWidth === m.clientWidth && p.clientHeight === m.clientHeight) return p;
1014
- scrollMemo = m;
1015
- return m;
1016
- };
1017
- const EMPTY_VISIBLE_PAGES = [];
1018
- let visSig = "";
1019
- let vis = [];
1020
- const visiblePages = () => {
1021
- if (!ctx.getState().placed) return EMPTY_VISIBLE_PAGES;
1022
- const c = cam();
1023
- const v = vp();
1024
- const sc = buildScene();
1025
- const items = paged() ? sc.items.slice(0, 1) : sc.query(_embedpdf_core_stage.cameraWorldRect(c, v));
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}`;
1027
- if (sig === visSig) return vis;
1028
- visSig = sig;
1029
- vis = items.flatMap((it) => it.pages).map(withTransform);
1030
- return vis;
1031
- };
1032
- const pickSettings = (src) => {
1033
- const out = {};
1034
- for (const k of SETTING_KEYS) out[k] = src[k];
1035
- return out;
1036
- };
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());
1320
+ }, viewport(), padding());
1321
+ const previous = lastMetrics;
1322
+ if (previous && previous.scrollLeft === metrics.scrollLeft && previous.scrollTop === metrics.scrollTop && previous.scrollWidth === metrics.scrollWidth && previous.scrollHeight === metrics.scrollHeight && previous.clientWidth === metrics.clientWidth && previous.clientHeight === metrics.clientHeight) return previous;
1323
+ lastMetrics = metrics;
1324
+ return metrics;
1058
1325
  };
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
- });
1326
+ const noVisiblePages = [];
1327
+ const placedVisiblePages = (0, _embedpdf_core.memo)(() => {
1328
+ const current = camera();
1329
+ const size = viewport();
1330
+ buildScene();
1331
+ return [
1332
+ cacheKey(),
1333
+ state().flow,
1334
+ current.x,
1335
+ current.y,
1336
+ current.zoom,
1337
+ size.width,
1338
+ size.height,
1339
+ dpr(),
1340
+ state().cameraResting
1341
+ ];
1342
+ }, (_sceneKey, _flow, x, y, zoom, width, height, _dpr, _resting) => {
1343
+ const scene = buildScene();
1344
+ return (paged() ? scene.items.slice(0, 1) : scene.query((0, _embedpdf_core_stage.cameraWorldRect)({
1345
+ x,
1346
+ y,
1347
+ zoom
1348
+ }, {
1349
+ width,
1350
+ height
1351
+ }))).flatMap((item) => item.pages).map(withTransform);
1352
+ });
1353
+ const visiblePages = () => state().placed ? placedVisiblePages() : noVisiblePages;
1354
+ const getPageFrame = (page) => {
1355
+ if (!state().placed) return null;
1356
+ const index = ctx.getPage(page)?.index ?? -1;
1357
+ if (index < 0) return null;
1358
+ const scene = buildScene();
1359
+ if (!scene.itemCount) return null;
1360
+ const box = scene.items[scene.itemOfPage(index)].pages.find((candidate) => candidate.pageIndex === index);
1361
+ return box ? withTransform(box) : null;
1068
1362
  };
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
1363
+ const pageToWorld = (page, point) => {
1364
+ const frame = getPageFrame(page);
1365
+ if (!frame) return null;
1366
+ const content = (0, _embedpdf_core_geometry.displaySize)({
1367
+ width: frame.width,
1368
+ height: frame.height
1369
+ }, frame.rotation);
1370
+ const offset = (0, _embedpdf_core_geometry.applyPoint)((0, _embedpdf_core_geometry.rotateScaleMatrix)(frame.contentScale, content.width, content.height, frame.rotation), point);
1371
+ return {
1372
+ x: frame.x + offset.x,
1373
+ y: frame.y + offset.y
1098
1374
  };
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
1375
  };
1104
- const initialViewProviders = [];
1105
- const api = {
1106
- camera: cam,
1107
- viewport: vp,
1108
- scrollMetrics: scrollMetricsNow,
1109
- pageCount: () => ctx.document()?.pageCount ?? 0,
1376
+ const visiblePageOf = (page) => {
1377
+ const objectNumber = ctx.getPage(page)?.ref.objectNumber;
1378
+ if (objectNumber === void 0) return null;
1379
+ return visiblePages().find((visible) => visible.ref.objectNumber === objectNumber) ?? null;
1380
+ };
1381
+ return {
1382
+ scrollMetricsNow,
1110
1383
  visiblePages,
1111
- currentPage: () => ctx.getState().cursor,
1112
- currentItemPages: () => {
1113
- const item = cursorItem();
1114
- return item ? [...item.pageIndexes] : [];
1115
- },
1116
- pages: () => (ctx.document()?.pages ?? []).map((p) => ({
1117
- index: p.index,
1118
- pon: p.pageObjectNumber,
1119
- label: p.label ?? null
1120
- })),
1121
- pageRect: (pon) => {
1122
- if (!ctx.getState().placed) return null;
1123
- const meta = ctx.document();
1124
- const index = meta ? meta.pages.findIndex((p) => p.pageObjectNumber === pon) : -1;
1125
- if (index < 0) return null;
1126
- const sc = buildScene();
1127
- if (!sc.itemCount) return null;
1128
- const box = sc.items[sc.itemOfPage(index)].pages.find((p) => p.pageIndex === index);
1129
- return box ? withTransform(box) : null;
1130
- },
1131
- pageAt: (screen) => {
1132
- for (const p of visiblePages()) {
1133
- const lx = screen.x - p.screenX;
1134
- const ly = screen.y - p.screenY;
1135
- if (lx >= 0 && ly >= 0 && lx <= p.transform.viewWidth && ly <= p.transform.viewHeight) return {
1136
- pon: p.pon,
1137
- point: p.transform.viewToContent({
1138
- x: lx,
1139
- y: ly
1140
- }),
1141
- scale: p.transform.viewScale,
1142
- rotation: p.rotation,
1143
- zoom: p.transform.zoom
1384
+ api: {
1385
+ getCamera: camera,
1386
+ getViewportSize: viewport,
1387
+ getScrollMetrics: scrollMetricsNow,
1388
+ listVisiblePages: visiblePages,
1389
+ isPageVisible: (page) => visiblePageOf(page) !== null,
1390
+ getCurrentPageIndex: () => state().cursor,
1391
+ getCurrentPage: () => ctx.document()?.pages[state().cursor]?.ref ?? null,
1392
+ getPageCount: () => ctx.document()?.pageCount ?? 0,
1393
+ listCurrentItemPages: () => {
1394
+ const item = cursorItem();
1395
+ const pages = ctx.document()?.pages ?? [];
1396
+ return item ? item.pageIndexes.map((index) => pages[index]).filter((page) => page !== void 0) : [];
1397
+ },
1398
+ getPageFrame,
1399
+ getPageAt: (point) => {
1400
+ for (const page of visiblePages()) {
1401
+ const localX = point.x - page.screenX;
1402
+ const localY = point.y - page.screenY;
1403
+ if (localX >= 0 && localY >= 0 && localX <= page.transform.viewWidth && localY <= page.transform.viewHeight) return {
1404
+ ref: page.ref,
1405
+ point: page.transform.viewToPage({
1406
+ x: localX,
1407
+ y: localY
1408
+ }),
1409
+ scale: page.transform.viewScale,
1410
+ rotation: page.rotation,
1411
+ zoom: page.transform.zoom
1412
+ };
1413
+ }
1414
+ return null;
1415
+ },
1416
+ viewportToPage: (page, point) => {
1417
+ const target = visiblePageOf(page);
1418
+ if (!target) return null;
1419
+ return target.transform.viewToPage({
1420
+ x: point.x - target.screenX,
1421
+ y: point.y - target.screenY
1422
+ });
1423
+ },
1424
+ pageToWorld,
1425
+ pageRectToViewport: (page, rect) => {
1426
+ const frame = getPageFrame(page);
1427
+ if (!frame) return null;
1428
+ const content = (0, _embedpdf_core_geometry.displaySize)({
1429
+ width: frame.width,
1430
+ height: frame.height
1431
+ }, frame.rotation);
1432
+ const world = (0, _embedpdf_core_geometry.applyRect)((0, _embedpdf_core_geometry.rotateScaleMatrix)(frame.contentScale, content.width, content.height, frame.rotation), rect);
1433
+ const current = camera();
1434
+ const topLeft = (0, _embedpdf_core_stage.toScreen)(current, {
1435
+ x: frame.x + world.x,
1436
+ y: frame.y + world.y
1437
+ });
1438
+ return {
1439
+ x: topLeft.x,
1440
+ y: topLeft.y,
1441
+ width: world.width * current.zoom,
1442
+ height: world.height * current.zoom
1144
1443
  };
1145
- }
1146
- return null;
1147
- },
1148
- pointOnPage: (pon, screen) => {
1149
- const p = visiblePages().find((v) => v.pon === pon);
1150
- if (!p) return null;
1151
- return p.transform.viewToContent({
1152
- x: screen.x - p.screenX,
1153
- y: screen.y - p.screenY
1154
- });
1155
- },
1156
- pageToWorld: (pon, pt) => {
1157
- const pr = api.pageRect(pon);
1158
- if (!pr) return null;
1159
- const content = (0, _embedpdf_core_geometry.displaySize)({
1160
- width: pr.width,
1161
- height: pr.height
1162
- }, pr.rotation);
1163
- const offset = (0, _embedpdf_core_geometry.applyPoint)((0, _embedpdf_core_geometry.rotateScaleMatrix)(pr.contentScale, content.width, content.height, pr.rotation), pt);
1164
- return {
1165
- x: pr.x + offset.x,
1166
- y: pr.y + offset.y
1167
- };
1168
- },
1169
- pageRectToScreen: (pon, rect) => {
1170
- const pr = api.pageRect(pon);
1171
- if (!pr) return null;
1172
- const content = (0, _embedpdf_core_geometry.displaySize)({
1173
- width: pr.width,
1174
- height: pr.height
1175
- }, pr.rotation);
1176
- const wr = (0, _embedpdf_core_geometry.applyRect)((0, _embedpdf_core_geometry.rotateScaleMatrix)(pr.contentScale, content.width, content.height, pr.rotation), rect);
1177
- const c = cam();
1178
- const tl = _embedpdf_core_stage.toScreen(c, {
1179
- x: pr.x + wr.x,
1180
- y: pr.y + wr.y
1181
- });
1182
- return {
1183
- x: tl.x,
1184
- y: tl.y,
1185
- width: wr.width * c.zoom,
1186
- height: wr.height * c.zoom
1187
- };
1188
- },
1189
- toScreen: (w) => _embedpdf_core_stage.toScreen(cam(), w),
1190
- toWorld: (s) => _embedpdf_core_stage.toWorld(cam(), s),
1191
- flow: () => ctx.getState().flow,
1192
- layout: () => ctx.getState().layout,
1193
- spread: () => ctx.getState().spread,
1194
- sizing: () => ctx.getState().sizing,
1195
- columns: () => ctx.getState().columns,
1196
- bounded: () => ctx.getState().bounded,
1197
- padding: () => ctx.getState().padding,
1198
- gap: () => ctx.getState().gap,
1199
- pageFrame: () => ctx.getState().pageFrame,
1200
- fitAlign: () => ctx.getState().fitAlign,
1201
- arrivalAlign: () => ctx.getState().arrivalAlign,
1202
- zoomAlign: () => ctx.getState().zoomAlign,
1203
- anchorAlign: () => ctx.getState().anchorAlign,
1204
- direction: () => ctx.getState().direction,
1205
- scrollBehavior: () => ctx.getState().scrollBehavior,
1206
- viewRotation: () => ctx.getState().viewRotation,
1207
- zoomLevel: () => cam().zoom,
1208
- zoomMode: () => {
1209
- const z = ctx.getState().zoom;
1210
- return "mode" in z ? z.mode : "custom";
1211
- },
1212
- viewpoint: () => ({
1213
- anchor: currentAnchor(),
1214
- zoom: ctx.getState().zoom
1215
- }),
1216
- settings: snapshotSettings,
1217
- viewState: () => ({
1218
- ...snapshotSettings(),
1219
- cursor: ctx.getState().cursor,
1220
- anchor: currentAnchor()
1221
- }),
1222
- setViewport: (v) => {
1223
- if (!placementStarted) {
1224
- ctx.dispatch({
1225
- type: "VP",
1226
- vp: v
1444
+ },
1445
+ worldToViewport: (world) => (0, _embedpdf_core_stage.toScreen)(camera(), world),
1446
+ viewportToWorld: (point) => (0, _embedpdf_core_stage.toWorld)(camera(), point),
1447
+ pageToViewport: (page, point) => {
1448
+ const world = pageToWorld(page, point);
1449
+ return world ? (0, _embedpdf_core_stage.toScreen)(camera(), world) : null;
1450
+ },
1451
+ getViewRotation: () => state().viewRotation,
1452
+ getZoomLevel: () => camera().zoom,
1453
+ getZoomMode: () => {
1454
+ const zoom = state().zoom;
1455
+ return "mode" in zoom ? zoom.mode : "custom";
1456
+ },
1457
+ getViewpoint: () => ({
1458
+ anchor: currentAnchor(),
1459
+ zoom: state().zoom
1460
+ }),
1461
+ getCurrentDestination: () => {
1462
+ const inset = padding();
1463
+ const current = camera();
1464
+ const first = visiblePages().filter((page) => {
1465
+ const corner = (0, _embedpdf_core_stage.toScreen)(current, {
1466
+ x: page.x + page.width,
1467
+ y: page.y + page.height
1468
+ });
1469
+ return corner.x > inset && corner.y > inset;
1470
+ }).sort((left, right) => left.pageIndex - right.pageIndex)[0];
1471
+ const size = first && ctx.getPage(first.ref)?.size;
1472
+ if (!first || !size) return null;
1473
+ const corner = first.transform.viewToPage({
1474
+ x: inset - first.screenX,
1475
+ y: inset - first.screenY
1227
1476
  });
1228
- syncResponsive(false);
1229
- if (v.width > 0 && v.height > 0 && (ctx.document()?.pageCount ?? 0) > 0) api.placeInitial();
1230
- return;
1477
+ const clamp = (value, max) => Math.round(Math.min(Math.max(value, 0), max) * 100) / 100;
1478
+ return {
1479
+ kind: "xyz",
1480
+ page: first.ref,
1481
+ x: clamp(corner.x, size.width),
1482
+ y: clamp(corner.y, size.height),
1483
+ zoom: null
1484
+ };
1231
1485
  }
1232
- cancelAnim();
1233
- const anchor = currentAnchor();
1234
- ctx.dispatch({
1235
- type: "VP",
1236
- vp: v
1237
- });
1238
- if (syncResponsive(false) === "reflow") goToTarget(ctx.getState().cursor, { behavior: "instant" });
1239
- else reapply(anchor);
1240
- },
1241
- setDevicePixelRatio: (ratio) => {
1242
- if (ratio > 0 && ratio !== dpr()) ctx.dispatch({
1243
- type: "DPR",
1244
- dpr: ratio
1245
- });
1246
- },
1247
- setCamera: (c) => {
1248
- markCause("user");
1249
- cancelAnim();
1250
- setCam(c);
1251
- syncCursorFromCamera();
1252
- },
1253
- panBy: (dx, dy) => {
1254
- markCause("user");
1255
- cancelAnim();
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));
1260
- syncCursorFromCamera();
1261
- },
1262
- scrollTo: (opts) => {
1263
- markCause("user");
1264
- cancelAnim();
1265
- if (!buildScene().itemCount) return;
1266
- const next = _embedpdf_core_stage.cameraFromScroll(cam(), stayBounds(), vp(), pad(), opts);
1267
- if (opts.behavior === "smooth") animateTo(next, stayBounds(), 240, syncCursorFromCamera);
1268
- else {
1269
- setCam(next);
1270
- syncCursorFromCamera();
1271
- }
1272
- },
1273
- scrollBy: ({ left, top, behavior }) => {
1274
- const m = scrollMetricsNow();
1275
- api.scrollTo({
1276
- left: left === void 0 ? void 0 : m.scrollLeft + left,
1277
- top: top === void 0 ? void 0 : m.scrollTop + top,
1278
- behavior
1279
- });
1280
- },
1281
- zoomAround: (pt, factor) => {
1282
- cancelAnim();
1283
- const before = buildScene();
1284
- const focal = before.itemCount ? _embedpdf_core_stage.anchorAtPoint(before, _embedpdf_core_stage.toWorld(cam(), pt)) : null;
1285
- setCam(_embedpdf_core_stage.zoomAround(cam(), pt, factor));
1286
- if (gestureDepth === 0) ctx.dispatch({
1287
- type: "PATCH",
1288
- patch: { zoom: { level: cam().zoom } }
1289
- });
1290
- const after = buildScene();
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
- }
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);
1351
- },
1352
- zoomIn: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1.2),
1353
- zoomOut: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1 / 1.2),
1354
- zoomTo: (spec) => api.update({ zoom: spec }),
1355
- fitWidth: () => api.update({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitWidth } }),
1356
- fitPage: () => api.update({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitPage } }),
1357
- fitAll: () => api.update({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitAll } }),
1358
- automatic: () => api.update({ zoom: { mode: _embedpdf_core_stage.ZoomMode.Automatic } }),
1359
- refit: () => {
1360
- if (!placementStarted) return;
1361
- cancelAnim();
1362
- reapply(currentAnchor());
1363
- },
1364
- goToPage: (pageIndex, opts) => goToTarget(pageIndex, opts),
1365
- reveal: (pageIndex, opts) => {
1366
- markCause("programmatic");
1367
- const doc = ctx.document();
1368
- if (!doc || doc.pageCount === 0) return;
1369
- const target = Math.max(0, Math.min(pageIndex, doc.pageCount - 1));
1370
- if (!(!!opts && (opts.rect != null || opts.anchor !== void 0 || opts.zoom !== void 0 && opts.zoom !== "keep"))) {
1371
- if (paged()) {
1372
- goToTarget(target, opts);
1373
- return;
1374
- }
1375
- const sc = buildScene();
1376
- if (!sc.itemCount) return;
1377
- const page = pageRectOf(sc.items[itemIndexOfPage(target)], target);
1378
- const m = worldPageFrame();
1379
- const box = {
1380
- x: page.x - m.left,
1381
- y: page.y - m.top,
1382
- width: page.width + m.left + m.right,
1383
- height: page.height + m.top + m.bottom
1384
- };
1385
- const camera = _embedpdf_core_stage.revealCamera(cam(), box, vp(), pad());
1386
- const current = cam();
1387
- if (camera.x === current.x && camera.y === current.y) return;
1388
- cancelAnim();
1389
- if ((opts?.behavior ?? ctx.getState().scrollBehavior) === "smooth") animateTo(camera, sceneRect());
1390
- else setCam(camera, sceneRect());
1391
- return;
1392
- }
1393
- cancelAnim();
1394
- if (target !== ctx.getState().cursor) ctx.dispatch({
1395
- type: "CURSOR",
1396
- cursor: target
1397
- });
1398
- const place = () => {
1399
- const sc = buildScene();
1400
- if (!sc.itemCount) return null;
1401
- const item = paged() ? sc.items[0] : sc.items[itemIndexOfPage(target)];
1402
- const world = opts.rect ? worldRectForContent(item, target, opts.rect) : pageRectOf(item, target);
1403
- const zd = opts.zoom ?? "keep";
1404
- const availW = Math.max(1, vp().width - 2 * pad());
1405
- const availH = Math.max(1, vp().height - 2 * pad());
1406
- let zoom = typeof zd === "object" ? zd.level : zd === "fit" ? Math.min(availW / world.width, availH / world.height) : zd === "fit-width" ? availW / world.width : zd === "fit-height" ? availH / world.height : cam().zoom;
1407
- if (!Number.isFinite(zoom) || zoom <= 0) zoom = cam().zoom;
1408
- const zoomChanged = zd !== "keep";
1409
- const a = opts.anchor ?? {};
1410
- return {
1411
- camera: {
1412
- x: revealAxis(a.x, cam().x, world.x, world.width, vp().width, zoom, zoomChanged),
1413
- y: revealAxis(a.y, cam().y, world.y, world.height, vp().height, zoom, zoomChanged),
1414
- zoom
1415
- },
1416
- bounds: boundsFor(item),
1417
- zoomChanged
1418
- };
1419
- };
1420
- const first = place();
1421
- if (!first) return;
1422
- if (first.zoomChanged) ctx.dispatch({
1423
- type: "PATCH",
1424
- patch: { zoom: { level: first.camera.zoom } }
1425
- });
1426
- if ((opts.behavior ?? ctx.getState().scrollBehavior) === "smooth") {
1427
- const p = place() ?? first;
1428
- animateTo(p.camera, p.bounds);
1429
- } else stabilized(() => {
1430
- const p = place();
1431
- if (p) setCam(p.camera, p.bounds);
1432
- });
1433
- },
1434
- next: (opts) => step(1, opts),
1435
- prev: (opts) => step(-1, opts),
1436
- lensId: () => ctx.id,
1437
- update: (patch) => {
1438
- base = mergeSettings(base, patch);
1439
- syncResponsive(true);
1440
- },
1441
- setResponsive: (next) => {
1442
- rules = next;
1443
- syncResponsive(true);
1444
- },
1445
- matches: (name) => ctx.getState().activeRules.includes(name),
1446
- activeRules: () => ctx.getState().activeRules,
1447
- setFlow: (flow) => api.update({ flow }),
1448
- setLayout: (layout) => api.update({ layout }),
1449
- setSpread: (spread) => api.update({ spread }),
1450
- setSizing: (sizing) => api.update({ sizing }),
1451
- setColumns: (columns) => api.update({ columns }),
1452
- setBounded: (bounded) => api.update({ bounded }),
1453
- setPadding: (padding) => api.update({ padding }),
1454
- setGap: (gap) => api.update({ gap }),
1455
- setPageFrame: (pageFrame) => api.update({ pageFrame }),
1456
- setFitAlign: (fitAlign) => api.update({ fitAlign }),
1457
- setArrivalAlign: (arrivalAlign) => api.update({ arrivalAlign }),
1458
- setZoomAlign: (zoomAlign) => api.update({ zoomAlign }),
1459
- setAnchorAlign: (anchorAlign) => api.update({ anchorAlign }),
1460
- setDirection: (direction) => api.update({ direction }),
1461
- setViewRotation: (viewRotation) => api.update({ viewRotation }),
1462
- rotateView: (delta) => api.setViewRotation((0, _embedpdf_core_geometry.addRotations)(ctx.getState().viewRotation, delta === 90 ? 90 : 270)),
1463
- setScrollBehavior: (behavior) => api.update({ scrollBehavior: behavior }),
1464
- applyViewState: (view) => {
1465
- cancelAnim();
1466
- base = mergeSettings(base, pickSettings(view));
1467
- const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1468
- lastEffective = effective;
1469
- publishActive(active);
1470
- ctx.dispatch({
1471
- type: "PATCH",
1472
- patch: effective
1473
- });
1474
- ctx.dispatch({
1475
- type: "CURSOR",
1476
- cursor: view.cursor ?? 0
1477
- });
1478
- sceneCache = null;
1479
- applyAnchor(view.anchor);
1480
- },
1481
- provideInitialView: (priority, fn) => {
1482
- initialViewProviders.push({
1483
- priority,
1484
- fn
1485
- });
1486
- },
1487
- placeInitial: () => {
1488
- placementStarted = true;
1489
- const sorted = [...initialViewProviders].sort((a, b) => b.priority - a.priority);
1490
- for (const p of sorted) {
1491
- const view = p.fn();
1492
- if (view) {
1493
- api.applyViewState(view);
1494
- ctx.dispatch({ type: "PLACED" });
1495
- return;
1496
- }
1497
- }
1498
- api.resetView();
1499
- ctx.dispatch({ type: "PLACED" });
1500
- },
1501
- resetView: () => goToTarget(0, { behavior: "instant" })
1486
+ }
1502
1487
  };
1503
- return api;
1504
1488
  }
1505
1489
  //#endregion
1506
- //#region src/effects.ts
1507
- /**
1508
- * Stage lens effects.
1509
- *
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.
1516
- *
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.
1524
- */
1525
- function registerStageEffects(ctx, token, feedActions = false) {
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
1490
+ //#region src/services/events.ts
1491
+ function createEvents(ctx) {
1492
+ const pageChanged = ctx.events.source();
1493
+ const zoomChanged = ctx.events.source();
1494
+ const cameraChanged = ctx.events.source();
1495
+ const motionEnded = ctx.events.source();
1496
+ const settingsChanged = ctx.events.source();
1497
+ const viewportChanged = ctx.events.source();
1498
+ ctx.state.onChange(({ previous, next }) => {
1499
+ if (previous.camera !== next.camera) {
1500
+ cameraChanged.emit({ camera: next.camera });
1501
+ if (previous.camera.zoom !== next.camera.zoom) zoomChanged.emit({
1502
+ level: next.camera.zoom,
1503
+ previousLevel: previous.camera.zoom,
1504
+ mode: "mode" in next.zoom ? next.zoom.mode : "custom"
1505
+ });
1506
+ }
1507
+ if (previous.cursor !== next.cursor) pageChanged.emit({
1508
+ page: ctx.document()?.pages[next.cursor]?.ref ?? null,
1509
+ pageIndex: next.cursor,
1510
+ previousPageIndex: previous.cursor
1511
+ });
1512
+ if (previous.viewport !== next.viewport) viewportChanged.emit({ size: next.viewport });
1513
+ const changed = SETTING_KEYS.filter((key) => !eqSetting(previous[key], next[key]));
1514
+ if (changed.length) settingsChanged.emit({
1515
+ settings: pickSettings(next),
1516
+ changed
1551
1517
  });
1552
1518
  });
1553
- }
1554
- //#endregion
1555
- //#region src/reducer.ts
1556
- const initialStageState = (config) => {
1557
- const { scheduler: _scheduler, responsive: _responsive, ...overrides } = config;
1558
1519
  return {
1559
- camera: {
1560
- x: 0,
1561
- y: 0,
1562
- zoom: 1
1563
- },
1564
- placed: false,
1565
- cameraResting: true,
1566
- vp: {
1567
- width: 0,
1568
- height: 0
1569
- },
1570
- dpr: 1,
1571
- cursor: 0,
1572
- motionCause: "user",
1573
- activeRules: [],
1574
- ...DEFAULT_SETTINGS,
1575
- ...overrides
1520
+ pageChanged,
1521
+ zoomChanged,
1522
+ cameraChanged,
1523
+ motionEnded,
1524
+ settingsChanged,
1525
+ viewportChanged
1576
1526
  };
1577
- };
1578
- /** Merge a settings patch, ignoring undefined values (safe for partial restores). */
1579
- const applyPatch = (state, patch) => {
1580
- const next = { ...state };
1581
- let key;
1582
- for (key in patch) {
1583
- const value = patch[key];
1584
- if (value !== void 0) Object.assign(next, { [key]: value });
1585
- }
1586
- return next;
1587
- };
1527
+ }
1528
+ //#endregion
1529
+ //#region src/services/scene.ts
1588
1530
  /**
1589
- * Pure. Every transition is here; nothing else mutates Stage state. Settings are a
1590
- * flat bag of primitives — one PATCH action sets any subset (the capability decides
1591
- * what camera follow-up, if any, each change needs).
1531
+ * The scene model: the document's item grouping, the layout, the memoized
1532
+ * scene (continuous flow lays out the whole document; paged flow a one-item
1533
+ * slice at the origin), and the pure geometry reads every area builds on:
1534
+ * fit, alignment points, clamp bounds and anchors. Reads only; nothing here
1535
+ * changes state.
1592
1536
  */
1593
- const stageReducer = (state, a) => {
1594
- switch (a.type) {
1595
- case "CAMERA": return {
1596
- ...state,
1597
- camera: a.camera
1537
+ function createScene(ctx) {
1538
+ const state = () => ctx.state.get();
1539
+ const camera = () => state().camera;
1540
+ const viewport = () => state().viewport;
1541
+ const dpr = () => state().dpr;
1542
+ const padding = () => state().padding;
1543
+ const paged = () => state().flow === "paged";
1544
+ const isFitAll = () => {
1545
+ const zoom = state().zoom;
1546
+ return "mode" in zoom && zoom.mode === _embedpdf_core_stage.ZoomMode.FitAll;
1547
+ };
1548
+ const grouping = (0, _embedpdf_core.memo)(() => [ctx.document()?.pageCount ?? 0, state().spread], (pageCount, spread) => {
1549
+ const groups = (0, _embedpdf_core_stage.groupPages)(pageCount, spread);
1550
+ return {
1551
+ grouping: groups,
1552
+ firstPages: groups.map((item) => item[0])
1598
1553
  };
1599
- case "CAMERA_REST": return state.cameraResting === a.resting ? state : {
1600
- ...state,
1601
- cameraResting: a.resting
1554
+ });
1555
+ const itemCountFull = () => grouping().grouping.length;
1556
+ const itemIndexOfPage = (pageIndex) => {
1557
+ const firstPages = grouping().firstPages;
1558
+ let low = 0;
1559
+ let high = firstPages.length;
1560
+ while (low < high) {
1561
+ const middle = low + high >> 1;
1562
+ if (firstPages[middle] <= pageIndex) low = middle + 1;
1563
+ else high = middle;
1564
+ }
1565
+ return Math.max(0, low - 1);
1566
+ };
1567
+ const effectiveZoom = () => {
1568
+ const zoom = state().zoom;
1569
+ return "level" in zoom ? zoom.level : Math.max(state().camera.zoom, 1e-4);
1570
+ };
1571
+ const wrapLineWidth = () => Math.max(1, (viewport().width - 2 * padding()) / effectiveZoom());
1572
+ const worldPageFrame = () => {
1573
+ const frame = state().pageFrame;
1574
+ if (!frame.top && !frame.right && !frame.bottom && !frame.left) return frame;
1575
+ const zoom = effectiveZoom();
1576
+ return {
1577
+ top: frame.top / zoom,
1578
+ right: frame.right / zoom,
1579
+ bottom: frame.bottom / zoom,
1580
+ left: frame.left / zoom
1602
1581
  };
1603
- case "PLACED": return state.placed ? state : {
1604
- ...state,
1605
- placed: true
1582
+ };
1583
+ const frameKey = () => {
1584
+ const frame = state().pageFrame;
1585
+ if (!frame.top && !frame.right && !frame.bottom && !frame.left) return "-";
1586
+ const world = worldPageFrame();
1587
+ return `${Math.round(world.top)},${Math.round(world.right)},${Math.round(world.bottom)},${Math.round(world.left)}`;
1588
+ };
1589
+ const worldGap = () => {
1590
+ const gap = state().gap;
1591
+ return typeof gap === "number" ? gap : gap.px ? gap.px / effectiveZoom() : 0;
1592
+ };
1593
+ const gapKey = () => {
1594
+ const gap = state().gap;
1595
+ return typeof gap === "number" ? String(gap) : `px:${Math.round(worldGap())}`;
1596
+ };
1597
+ const layoutFor = (groups) => {
1598
+ const settings = state();
1599
+ const registryPages = ctx.document()?.pages ?? [];
1600
+ const viewRotation = settings.viewRotation;
1601
+ const pages = viewRotation === 0 ? registryPages : registryPages.map((page) => ({
1602
+ ...page,
1603
+ rotation: (0, _embedpdf_core_geometry.addRotations)(page.rotation, viewRotation)
1604
+ }));
1605
+ const pageFrame = worldPageFrame();
1606
+ const gap = worldGap();
1607
+ const viewUnitsPerPoint = settings.viewUnitsPerPoint;
1608
+ if (settings.layout === "grid") return (0, _embedpdf_core_stage.gridLayout)(pages, groups, {
1609
+ gap,
1610
+ sizing: settings.sizing,
1611
+ direction: settings.direction,
1612
+ pageFrame,
1613
+ viewUnitsPerPoint,
1614
+ columns: typeof settings.columns === "number" ? settings.columns : void 0,
1615
+ lineWidth: settings.columns === "auto" ? wrapLineWidth() : void 0
1616
+ });
1617
+ return (0, _embedpdf_core_stage.linearLayout)(pages, groups, {
1618
+ axis: settings.layout === "horizontal" ? "x" : "y",
1619
+ gap,
1620
+ sizing: settings.sizing,
1621
+ direction: settings.direction,
1622
+ pageFrame,
1623
+ viewUnitsPerPoint
1624
+ });
1625
+ };
1626
+ const columnsKey = () => {
1627
+ const settings = state();
1628
+ if (settings.layout !== "grid") return "-";
1629
+ return settings.columns === "auto" ? `auto:${Math.round(wrapLineWidth())}` : String(settings.columns);
1630
+ };
1631
+ const SCENE_KEY_FUNCTIONS = {
1632
+ columns: columnsKey,
1633
+ gap: gapKey,
1634
+ pageFrame: frameKey
1635
+ };
1636
+ const SCENE_KEYS = SETTING_KEYS.filter((key) => SETTINGS_EFFECT[key] === "scene");
1637
+ const settingsKey = () => SCENE_KEYS.map((key) => {
1638
+ const keyFunction = SCENE_KEY_FUNCTIONS[key];
1639
+ if (keyFunction) return keyFunction();
1640
+ const value = state()[key];
1641
+ return typeof value === "object" && value !== null ? JSON.stringify(value) : String(value);
1642
+ }).join("|");
1643
+ let sceneCache = null;
1644
+ const documentKey = () => {
1645
+ const document = ctx.document();
1646
+ return document ? `${document.pageCount}.${document.revision}` : "0.0";
1647
+ };
1648
+ const buildScene = () => {
1649
+ const { grouping: groups } = grouping();
1650
+ if (state().flow === "paged") {
1651
+ const index = groups.length ? Math.min(itemIndexOfPage(state().cursor), groups.length - 1) : 0;
1652
+ const key = `paged|${documentKey()}|${settingsKey()}|${index}`;
1653
+ if (sceneCache && sceneCache.key === key) return sceneCache.scene;
1654
+ const scene = layoutFor(groups.length ? [groups[index]] : []);
1655
+ sceneCache = {
1656
+ key,
1657
+ scene
1658
+ };
1659
+ return scene;
1660
+ }
1661
+ const key = `continuous|${documentKey()}|${settingsKey()}`;
1662
+ if (sceneCache && sceneCache.key === key) return sceneCache.scene;
1663
+ const scene = layoutFor(groups);
1664
+ sceneCache = {
1665
+ key,
1666
+ scene
1606
1667
  };
1607
- case "VP": return {
1608
- ...state,
1609
- vp: a.vp
1668
+ return scene;
1669
+ };
1670
+ /** Drop the memoized scene (a 'scene' or 'reflow' setting changed, or a view was restored). */
1671
+ const invalidate = () => {
1672
+ sceneCache = null;
1673
+ };
1674
+ /** The current scene's cache key, part of the visible-pages signature. */
1675
+ const cacheKey = () => sceneCache.key;
1676
+ const sceneRect = () => {
1677
+ const { width, height } = buildScene().size;
1678
+ return {
1679
+ x: 0,
1680
+ y: 0,
1681
+ width,
1682
+ height
1610
1683
  };
1611
- case "DPR": return {
1612
- ...state,
1613
- dpr: a.dpr
1684
+ };
1685
+ const itemRect = (item) => ({
1686
+ x: item.x,
1687
+ y: item.y,
1688
+ width: item.width,
1689
+ height: item.height
1690
+ });
1691
+ const pageRectOf = (item, pageIndex) => {
1692
+ const box = item.pages.find((page) => page.pageIndex === pageIndex) ?? item.pages[0];
1693
+ return {
1694
+ x: box.x,
1695
+ y: box.y,
1696
+ width: box.width,
1697
+ height: box.height
1614
1698
  };
1615
- case "CURSOR": return {
1616
- ...state,
1617
- cursor: a.cursor
1699
+ };
1700
+ /** The item shown for the cursor: the slice's only item (paged) or the full-scene item. */
1701
+ const cursorItem = () => {
1702
+ const scene = buildScene();
1703
+ return paged() ? scene.items[0] : scene.items[itemIndexOfPage(state().cursor)];
1704
+ };
1705
+ /**
1706
+ * A page-space rect on a page → a world rect: the same quarter-turn
1707
+ * matrix `pageRectToViewport` uses, minus the camera, so a positioned
1708
+ * reveal and the rendered overlay never disagree about where a rect is.
1709
+ */
1710
+ const worldRectForContent = (item, pageIndex, rect) => {
1711
+ const box = item.pages.find((page) => page.pageIndex === pageIndex) ?? item.pages[0];
1712
+ const content = (0, _embedpdf_core_geometry.displaySize)({
1713
+ width: box.width,
1714
+ height: box.height
1715
+ }, box.rotation);
1716
+ const world = (0, _embedpdf_core_geometry.applyRect)((0, _embedpdf_core_geometry.rotateScaleMatrix)(box.contentScale, content.width, content.height, box.rotation), rect);
1717
+ return {
1718
+ x: box.x + world.x,
1719
+ y: box.y + world.y,
1720
+ width: world.width,
1721
+ height: world.height
1618
1722
  };
1619
- case "MOTION_CAUSE": return {
1620
- ...state,
1621
- motionCause: a.cause
1723
+ };
1724
+ const fits = (rect, zoom) => {
1725
+ const size = viewport();
1726
+ const inset = padding();
1727
+ const tolerance = .5;
1728
+ return rect.width * zoom <= size.width - 2 * inset + tolerance && rect.height * zoom <= size.height - 2 * inset + tolerance;
1729
+ };
1730
+ const alignFraction = (value) => value === "start" ? 0 : value === "center" ? .5 : value === "end" ? 1 : Math.min(1, Math.max(0, value));
1731
+ const alignPoint = (alignment, size) => {
1732
+ const rtl = state().direction === "rtl";
1733
+ const alignX = rtl && alignment.x === "start" ? "end" : rtl && alignment.x === "end" ? "start" : alignment.x;
1734
+ const inset = padding();
1735
+ return {
1736
+ x: inset + (size.width - 2 * inset) * alignFraction(alignX),
1737
+ y: inset + (size.height - 2 * inset) * alignFraction(alignment.y)
1622
1738
  };
1623
- case "PATCH": return applyPatch(state, a.patch);
1624
- case "RESPONSIVE": return {
1625
- ...state,
1626
- activeRules: a.active
1739
+ };
1740
+ const anchorPoint = () => alignPoint(state().anchorAlign, viewport());
1741
+ /** The box the zoom intent fits: the whole scene (fit-all), the current
1742
+ * item (paged: per-page fit), or the document maximum (continuous: a
1743
+ * document-stable zoom). */
1744
+ const fitBox = (item) => {
1745
+ if (isFitAll()) return buildScene().size;
1746
+ return paged() ? {
1747
+ width: item.width,
1748
+ height: item.height
1749
+ } : buildScene().maxItemSize;
1750
+ };
1751
+ /** The clamp rect for a camera write targeting this item (the scene in continuous flow). */
1752
+ const boundsFor = (item) => paged() ? itemRect(item) : sceneRect();
1753
+ /** The clamp rect for a write that stays put (pan, zoom): the slice item (paged) or the scene. */
1754
+ const stayBounds = () => paged() ? itemRect(buildScene().items[0]) : sceneRect();
1755
+ const constraint = () => ({
1756
+ bounded: state().bounded,
1757
+ padding: padding(),
1758
+ fitAlign: state().fitAlign,
1759
+ direction: state().direction
1760
+ });
1761
+ const anchorAt = (at) => {
1762
+ const scene = buildScene();
1763
+ return scene.itemCount ? (0, _embedpdf_core_stage.anchorFromCamera)(camera(), scene, viewport(), at) : {
1764
+ pageIndex: state().cursor,
1765
+ fx: .5,
1766
+ fy: 0
1627
1767
  };
1628
- default: return state;
1629
- }
1768
+ };
1769
+ const currentAnchor = () => anchorAt(anchorPoint());
1770
+ const indexOfPage = (page) => ctx.getPage(page)?.index ?? -1;
1771
+ /**
1772
+ * A page argument (a `PageRef` or a display index) as a display index, or
1773
+ * null when it names no page: a ref not in the document, or a number that
1774
+ * isn't a whole number. An index past either end is kept; arrivals clamp it.
1775
+ */
1776
+ const indexOfTarget = (page) => {
1777
+ if (typeof page === "number") return Number.isInteger(page) ? page : null;
1778
+ const index = indexOfPage(page);
1779
+ return index >= 0 ? index : null;
1780
+ };
1781
+ return {
1782
+ camera,
1783
+ viewport,
1784
+ dpr,
1785
+ padding,
1786
+ paged,
1787
+ isFitAll,
1788
+ grouping,
1789
+ itemCountFull,
1790
+ itemIndexOfPage,
1791
+ worldPageFrame,
1792
+ buildScene,
1793
+ invalidate,
1794
+ cacheKey,
1795
+ sceneRect,
1796
+ itemRect,
1797
+ pageRectOf,
1798
+ cursorItem,
1799
+ worldRectForContent,
1800
+ fits,
1801
+ alignPoint,
1802
+ anchorPoint,
1803
+ fitBox,
1804
+ boundsFor,
1805
+ stayBounds,
1806
+ constraint,
1807
+ anchorAt,
1808
+ currentAnchor,
1809
+ indexOfPage,
1810
+ indexOfTarget
1811
+ };
1812
+ }
1813
+ //#endregion
1814
+ //#region src/services/index.ts
1815
+ function createServices(ctx) {
1816
+ return {
1817
+ events: createEvents(ctx),
1818
+ clock: ctx.clock,
1819
+ placement: createPlacementLatch(),
1820
+ scene: createScene(ctx)
1821
+ };
1822
+ }
1823
+ //#endregion
1824
+ //#region src/settings/settings.ts
1825
+ /**
1826
+ * The settings bag and its responsive resolution. The base (config plus
1827
+ * `updateSettings()` changes) merged with the matching rules' patches is the effective
1828
+ * settings the state holds. `applySettings` is the one reactive applier: the
1829
+ * strongest effect among the touched settings wins ('reflow' ⊃ 'scene'/'refit'
1830
+ * ⊃ 'reclamp' ⊃ 'none'), so a breakpoint crossing gets exactly the invariant a
1831
+ * hand-written update would.
1832
+ */
1833
+ const EFFECT_RANK = {
1834
+ none: 0,
1835
+ reclamp: 1,
1836
+ refit: 2,
1837
+ scene: 3,
1838
+ reflow: 4
1630
1839
  };
1840
+ function createSettings(ctx, { scene }, config, { writeCamera }, { cancelAnimation }, { applyAnchor, reapply, goToTarget }) {
1841
+ const { camera, viewport, alignPoint, anchorAt } = scene;
1842
+ const state = () => ctx.state.get();
1843
+ const snapshotSettings = (0, _embedpdf_core.memo)(() => SETTING_KEYS.map((key) => state()[key]), () => pickSettings(state()));
1844
+ /**
1845
+ * React to a settings change per the registry: the strongest effect among
1846
+ * the touched settings wins. Both the public `updateSettings()` and the
1847
+ * responsive driver land here.
1848
+ */
1849
+ const applySettings = (patch) => {
1850
+ cancelAnimation();
1851
+ const touched = (effect) => SETTING_KEYS.some((key) => patch[key] !== void 0 && SETTINGS_EFFECT[key] === effect);
1852
+ const align = touched("refit") && !touched("scene") && !touched("reflow") ? state().zoomAlign : state().anchorAlign;
1853
+ const anchor = anchorAt(alignPoint(align, viewport()));
1854
+ ctx.state.update(patchSettings, patch);
1855
+ if (touched("scene")) scene.invalidate();
1856
+ if (touched("reflow")) goToTarget(state().cursor, { behavior: "instant" });
1857
+ else if (touched("scene") || touched("refit")) reapply(anchor, align);
1858
+ else if (touched("reclamp")) writeCamera(camera());
1859
+ };
1860
+ let base = snapshotSettings();
1861
+ const initialBase = base;
1862
+ let rules = config.responsive ?? DEFAULT_RESPONSIVE;
1863
+ let lastEffective = base;
1864
+ const publishActive = (active) => {
1865
+ const current = state().activeRules;
1866
+ if (current.length !== active.length || active.some((name, index) => name !== current[index])) ctx.state.update(setActiveRules, active);
1867
+ };
1868
+ /**
1869
+ * Re-resolve and apply what changed since the last resolution. `react:
1870
+ * true` routes the patch through `applySettings` (the full
1871
+ * anchor-preserving update); `react: false` (inside `setViewportSize`,
1872
+ * whose caller runs its own reframe) only lands the patch and the cache
1873
+ * invalidation and reports the strongest effect, so the caller can
1874
+ * escalate a rule-driven flow flip.
1875
+ */
1876
+ const syncResponsive = (react) => {
1877
+ const { effective, active } = resolveResponsive(base, rules, boxOf(viewport()));
1878
+ const patch = {};
1879
+ for (const key of SETTING_KEYS) if (!eqSetting(effective[key], lastEffective[key])) Object.assign(patch, { [key]: effective[key] });
1880
+ lastEffective = effective;
1881
+ publishActive(active);
1882
+ const keys = Object.keys(patch);
1883
+ if (keys.length === 0) return "none";
1884
+ if (react) {
1885
+ applySettings(patch);
1886
+ return "none";
1887
+ }
1888
+ ctx.state.update(patchSettings, patch);
1889
+ let strongest = "none";
1890
+ for (const key of keys) if (EFFECT_RANK[SETTINGS_EFFECT[key]] > EFFECT_RANK[strongest]) strongest = SETTINGS_EFFECT[key];
1891
+ if (strongest === "scene" || strongest === "reflow") scene.invalidate();
1892
+ return strongest;
1893
+ };
1894
+ const updateSettings = (changes) => {
1895
+ base = mergeSettings(base, changes);
1896
+ syncResponsive(true);
1897
+ };
1898
+ const applyViewState = (view) => {
1899
+ cancelAnimation();
1900
+ base = mergeSettings(base, pickSettings(view));
1901
+ const { effective, active } = resolveResponsive(base, rules, boxOf(viewport()));
1902
+ lastEffective = effective;
1903
+ publishActive(active);
1904
+ ctx.state.update(patchSettings, effective);
1905
+ ctx.state.update(setCursor, view.cursor ?? 0);
1906
+ scene.invalidate();
1907
+ applyAnchor(view.anchor);
1908
+ };
1909
+ return {
1910
+ snapshotSettings,
1911
+ syncResponsive,
1912
+ updateSettings,
1913
+ applyViewState,
1914
+ api: {
1915
+ getSettings: snapshotSettings,
1916
+ resetSettings: () => {
1917
+ base = mergeSettings(initialBase, {});
1918
+ syncResponsive(true);
1919
+ },
1920
+ updateSettings,
1921
+ setResponsiveRules: (next) => {
1922
+ rules = next;
1923
+ syncResponsive(true);
1924
+ },
1925
+ matchesRule: (name) => state().activeRules.includes(name),
1926
+ listActiveRules: () => state().activeRules,
1927
+ applyViewState
1928
+ }
1929
+ };
1930
+ }
1931
+ //#endregion
1932
+ //#region src/view/lifecycle.ts
1933
+ function createViewLifecycle(ctx, { scene, placement }, { cancelAnimation }, arrive, settings) {
1934
+ const { dpr, currentAnchor } = scene;
1935
+ const { goToTarget, reapply } = arrive;
1936
+ const { snapshotSettings, syncResponsive } = settings;
1937
+ const state = () => ctx.state.get();
1938
+ const initialViewProviders = [];
1939
+ const placeInitial = () => {
1940
+ placement.started = true;
1941
+ const byPriority = [...initialViewProviders].sort((left, right) => right.priority - left.priority);
1942
+ let view = null;
1943
+ for (const provider of byPriority) {
1944
+ view = provider.provide();
1945
+ if (view) break;
1946
+ }
1947
+ if (view) settings.applyViewState(view);
1948
+ else arrive.resetView();
1949
+ const pending = placement.pending;
1950
+ placement.pending = null;
1951
+ pending?.();
1952
+ ctx.state.update(markPlaced);
1953
+ };
1954
+ return { api: {
1955
+ setViewportSize: (size) => {
1956
+ if (!placement.started) {
1957
+ ctx.state.update(setViewport, size);
1958
+ syncResponsive(false);
1959
+ if (size.width > 0 && size.height > 0 && (ctx.document()?.pageCount ?? 0) > 0) placeInitial();
1960
+ return;
1961
+ }
1962
+ if (size.width <= 0 || size.height <= 0) return;
1963
+ const current = state().viewport;
1964
+ if (size.width === current.width && size.height === current.height) return;
1965
+ cancelAnimation();
1966
+ const anchor = currentAnchor();
1967
+ ctx.state.update(setViewport, size);
1968
+ if (syncResponsive(false) === "reflow") goToTarget(state().cursor, { behavior: "instant" });
1969
+ else reapply(anchor);
1970
+ },
1971
+ setDevicePixelRatio: (ratio) => {
1972
+ if (ratio > 0 && ratio !== dpr()) ctx.state.update(setDpr, ratio);
1973
+ },
1974
+ refit: () => {
1975
+ if (!placement.started) return;
1976
+ cancelAnimation();
1977
+ reapply(currentAnchor());
1978
+ },
1979
+ getViewState: () => ({
1980
+ ...snapshotSettings(),
1981
+ cursor: state().cursor,
1982
+ anchor: currentAnchor()
1983
+ }),
1984
+ getLensId: () => ctx.id,
1985
+ provideInitialView: (priority, provide) => {
1986
+ const entry = {
1987
+ priority,
1988
+ provide
1989
+ };
1990
+ initialViewProviders.push(entry);
1991
+ return () => {
1992
+ const index = initialViewProviders.indexOf(entry);
1993
+ if (index >= 0) initialViewProviders.splice(index, 1);
1994
+ };
1995
+ },
1996
+ placeInitial
1997
+ } };
1998
+ }
1999
+ //#endregion
2000
+ //#region src/controller.ts
2001
+ /**
2002
+ * The stage controller: the composition root. It builds the plugin's services
2003
+ * once (the events, the timing seam, the scene model), wires each area with
2004
+ * the services it declares, and assembles the host capability from the areas'
2005
+ * API slices. stage-core is the pure spatial math; the areas are the impure
2006
+ * shell that writes state, caches and drives motion, and time enters only
2007
+ * through the host's clock (`ctx.clock`).
2008
+ *
2009
+ * The camera model: every camera move is defined by what it holds fixed.
2010
+ * Gestures (pan, pinch, wheel) hold the page-point under the pointer;
2011
+ * navigation lands its target at the arrivalAlign point; positioned reveals
2012
+ * and restored viewpoints hold what the call specifies; pointer-less zooms
2013
+ * hold the zoomAlign point; reframes (resize, rotation, layout changes) hold
2014
+ * the anchorAlign point; and an axis the camera cannot travel rests at fitAlign.
2015
+ */
2016
+ function createStageController(ctx, config = {}) {
2017
+ const services = createServices(ctx);
2018
+ const { events } = services;
2019
+ const reads = createPageReads(ctx, services);
2020
+ const write = createCameraWrite(ctx, services);
2021
+ const animation = createAnimation(services, write);
2022
+ const gestures = createGestures(ctx, services, write, animation, reads);
2023
+ const arrive = createArrival(ctx, services, write, animation);
2024
+ const reveal = createReveal(ctx, services, write, animation, arrive);
2025
+ const settings = createSettings(ctx, services, config, write, animation, arrive);
2026
+ const zoom = createZoom(ctx, services, write, animation, gestures.api, settings);
2027
+ const view = createViewLifecycle(ctx, services, animation, arrive, settings);
2028
+ const api = (0, _embedpdf_core.composeApi)("stage", [
2029
+ reads.api,
2030
+ animation.api,
2031
+ gestures.api,
2032
+ arrive.api,
2033
+ reveal.api,
2034
+ settings.api,
2035
+ zoom.api,
2036
+ view.api,
2037
+ {
2038
+ onPageChanged: events.pageChanged.on,
2039
+ onZoomChanged: events.zoomChanged.on,
2040
+ onCameraChanged: events.cameraChanged.on,
2041
+ onMotionEnded: events.motionEnded.on,
2042
+ onSettingsChanged: events.settingsChanged.on,
2043
+ onViewportChanged: events.viewportChanged.on
2044
+ }
2045
+ ]);
2046
+ return {
2047
+ api,
2048
+ connect: () => connectStage(ctx, api)
2049
+ };
2050
+ }
1631
2051
  //#endregion
1632
2052
  //#region src/stage.plugin.ts
1633
2053
  /**
1634
- * Wires the parts into a kernel plugin. This file is the "manifest": it says what
1635
- * the plugin IS (id, token, state, reducer, capability) — the how lives in the
1636
- * sibling files.
2054
+ * The stage plugin: one lens's camera, layout, zoom and navigation over a
2055
+ * document. Document-scoped. The actions plugin is optional: with it, the
2056
+ * main lens feeds page state to it and interprets GoTo and Named page
2057
+ * actions. Pointer input is opted into by the surface binding
2058
+ * (`<Stage interaction>` / `createStageSurface`), which both forwards pointer
2059
+ * samples and registers this lens's scroll handler.
1637
2060
  */
1638
2061
  const stagePlugin = (options = {}) => {
1639
- const { id = "stage", token = require_contract.StageToken, ...config } = options;
2062
+ const { id = DEFAULT_LENS_ID, token = require_contract.StageToken, ...config } = options;
1640
2063
  return (0, _embedpdf_core.definePlugin)({
1641
2064
  id,
1642
- token,
2065
+ token: (0, _embedpdf_core.createHostToken)(token),
1643
2066
  scope: "document",
1644
2067
  optional: [_embedpdf_plugin_actions_contract.ActionsToken],
1645
- initialState: () => initialStageState(config),
1646
- reduce: stageReducer,
1647
- capability: (ctx) => createStageCapability(ctx, config),
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
- }
2068
+ state: () => initialStageState(config),
2069
+ create: (ctx) => createStageController(ctx, config)
1701
2070
  });
1702
2071
  };
1703
2072
  //#endregion
2073
+ //#region src/state.ts
2074
+ /**
2075
+ * The Stage page's State table as code: what `useStageState()` returns, and
2076
+ * the same fields in every other framework. A second view registered with its
2077
+ * own token reads the same fields through its own capability.
2078
+ */
2079
+ const stageState = (0, _embedpdf_core.defineState)(require_contract.StageToken, {
2080
+ read: (stage) => ({
2081
+ zoomLevel: stage.getZoomLevel(),
2082
+ zoomMode: stage.getZoomMode(),
2083
+ currentPageIndex: stage.getCurrentPageIndex(),
2084
+ currentPage: stage.getCurrentPage(),
2085
+ pageCount: stage.getPageCount(),
2086
+ viewRotation: stage.getViewRotation(),
2087
+ activeRules: stage.listActiveRules()
2088
+ }),
2089
+ empty: {
2090
+ zoomLevel: 1,
2091
+ zoomMode: _embedpdf_core_stage.ZoomMode.Automatic,
2092
+ currentPageIndex: 0,
2093
+ currentPage: null,
2094
+ pageCount: 0,
2095
+ viewRotation: 0,
2096
+ activeRules: []
2097
+ }
2098
+ });
2099
+ //#endregion
1704
2100
  //#region src/scroll-handler.ts
1705
2101
  /**
1706
2102
  * 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).
2103
+ * (the `scroll` tag) and, with `panFallback`, under every tool, where it then
2104
+ * captures only page-gap drags (on-page downs belong to the active tool, which
2105
+ * claims them first at higher priority). It uses the sample's viewport delta,
2106
+ * so it pans over the whole viewport (pages and gaps).
1711
2107
  *
1712
- * Cursor feel: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
2108
+ * Cursors: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
1713
2109
  * hovering a gap under a non-pan tool. The pan tool's own resting `grab` comes
1714
2110
  * from its `Tool.cursor`, so hovering it needs no claim here.
1715
2111
  */
@@ -1719,24 +2115,24 @@ function createScrollHandler(stage, interaction, options = {}) {
1719
2115
  x: 0,
1720
2116
  y: 0
1721
2117
  };
1722
- const isPanTool = () => interaction.activeTool().enables.has("scroll");
2118
+ const isPanTool = () => interaction.getActiveTool().enables.has("scroll");
1723
2119
  return {
1724
2120
  id: "stage-scroll",
1725
2121
  priority: 10,
1726
2122
  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);
2123
+ onDown: (sample) => {
2124
+ if (!isPanTool() && sample.page) return false;
2125
+ last = sample.viewport;
2126
+ interaction.claimCursor("stage-grab", "grabbing", 40);
1731
2127
  return true;
1732
2128
  },
1733
- onMove: (s) => {
1734
- stage.panBy(s.viewport.x - last.x, s.viewport.y - last.y);
1735
- last = s.viewport;
2129
+ onMove: (sample) => {
2130
+ stage.panBy(sample.viewport.x - last.x, sample.viewport.y - last.y);
2131
+ last = sample.viewport;
1736
2132
  },
1737
- onUp: () => interaction.setCursor("stage-grab", null),
1738
- onHover: (s) => {
1739
- if (panFallback && !isPanTool()) interaction.setCursor("stage-pan-fallback", s.page ? null : "grab", 5);
2133
+ onUp: () => interaction.claimCursor("stage-grab", null),
2134
+ onHover: (sample) => {
2135
+ if (panFallback && !isPanTool()) interaction.claimCursor("stage-pan-fallback", sample.page ? null : "grab", 5);
1740
2136
  }
1741
2137
  };
1742
2138
  }
@@ -1752,10 +2148,10 @@ Object.defineProperty(exports, "ZoomMode", {
1752
2148
  });
1753
2149
  exports.boxOf = boxOf;
1754
2150
  exports.createScrollHandler = createScrollHandler;
1755
- exports.destinationToReveal = require_destination.destinationToReveal;
1756
2151
  exports.matchesQuery = matchesQuery;
1757
2152
  exports.resolveResponsive = resolveResponsive;
1758
2153
  exports.settingsEqual = settingsEqual;
1759
2154
  exports.stagePlugin = stagePlugin;
2155
+ exports.stageState = stageState;
1760
2156
 
1761
2157
  //# sourceMappingURL=index.cjs.map