@embedpdf/plugin-stage 3.0.0-next.13 → 3.0.0-next.15

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