@embedpdf/plugin-stage 3.0.0-next.0

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 ADDED
@@ -0,0 +1,1266 @@
1
+ import { createCapabilityToken, definePlugin } from "@embedpdf/core";
2
+ import { InteractionToken } from "@embedpdf/plugin-interaction";
3
+ import * as S from "@embedpdf/core-stage";
4
+ import { ZoomMode, ZoomMode as ZoomMode$1 } from "@embedpdf/core-stage";
5
+ import { addRotations, applyPoint, applyRect, displaySize, intersectRects, pageGeometry, pageTransform, rotateScaleMatrix, snapToDevice } from "@embedpdf/core-geometry";
6
+ //#region src/settings.ts
7
+ /**
8
+ * Out-of-the-box defaults — a sensible document-reading feel. They are JUST
9
+ * defaults: every field is overridable in `stagePlugin(config)` and at runtime via
10
+ * the setters / `update()`. The plugin ships NO named presets ("document",
11
+ * "canvas", …) — a preset is simply an object the app keeps and passes to
12
+ * `update()`, so that taxonomy stays a customer concern.
13
+ */
14
+ const DEFAULT_SETTINGS = {
15
+ flow: "continuous",
16
+ layout: "vertical",
17
+ spread: "none",
18
+ sizing: "intrinsic",
19
+ columns: "square",
20
+ bounded: true,
21
+ padding: 24,
22
+ gap: 16,
23
+ pageFrame: {
24
+ top: 0,
25
+ right: 0,
26
+ bottom: 0,
27
+ left: 0
28
+ },
29
+ direction: "ltr",
30
+ fitAlign: {
31
+ x: "center",
32
+ y: "center"
33
+ },
34
+ arrivalAlign: {
35
+ x: "start",
36
+ y: "start"
37
+ },
38
+ zoomAlign: {
39
+ x: "center",
40
+ y: "center"
41
+ },
42
+ anchorAlign: {
43
+ x: "start",
44
+ y: "start"
45
+ },
46
+ viewRotation: 0,
47
+ zoom: { mode: ZoomMode$1.Automatic },
48
+ scrollBehavior: "smooth",
49
+ viewUnitsPerPoint: 96 / 72
50
+ };
51
+ const SETTINGS_EFFECT = {
52
+ flow: "reflow",
53
+ layout: "scene",
54
+ spread: "scene",
55
+ sizing: "scene",
56
+ columns: "scene",
57
+ bounded: "reclamp",
58
+ padding: "reclamp",
59
+ gap: "scene",
60
+ pageFrame: "scene",
61
+ direction: "scene",
62
+ fitAlign: "reclamp",
63
+ arrivalAlign: "none",
64
+ zoomAlign: "none",
65
+ anchorAlign: "none",
66
+ viewRotation: "scene",
67
+ zoom: "refit",
68
+ scrollBehavior: "none",
69
+ viewUnitsPerPoint: "scene"
70
+ };
71
+ const SETTING_KEYS = Object.keys(SETTINGS_EFFECT);
72
+ const valueEq = (a, b) => {
73
+ if (a === b) return true;
74
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
75
+ const ka = Object.keys(a);
76
+ return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
77
+ };
78
+ /** Field-by-field settings equality, derived from the registry — a new setting is
79
+ * covered automatically. (The React `useStageSettings` selector equality.) */
80
+ const settingsEqual = (a, b) => SETTING_KEYS.every((k) => valueEq(a[k], b[k]));
81
+ //#endregion
82
+ //#region src/capability.ts
83
+ /**
84
+ * The Stage capability — selectors (pure reads) + intents (the only writers).
85
+ *
86
+ * Layering, stated honestly:
87
+ * • stage-core — PURE spatial math (no DOM, no time). Ports to Rust later.
88
+ * • this capability — the IMPURE platform shell. It dispatches, caches, and owns
89
+ * the camera tween. It is NOT pure; the one host dependency it has (frame
90
+ * timing) enters through an injected Scheduler, never a hidden global.
91
+ *
92
+ * The model: EVERY camera move is defined by what it holds fixed.
93
+ * • gestures (pan/pinch/wheel) — the content under the pointer. Physics, no setting.
94
+ * • focal zoom (zoomIn/zoomTo/fit-mode switches) — the zoomAlign viewport point.
95
+ * • reframes (resize/rotate/re-layout) — the page-point at the anchorAlign
96
+ * viewport point: captured there before the change, restored there after.
97
+ * • arrivals (goToPage/next/prev/reset) — nothing prior: a fresh landing at
98
+ * arrivalAlign, THE SAME at every zoom (landing is policy, never a zoom side effect).
99
+ * • explicit (reveal/destination/viewpoint) — whatever the call specifies.
100
+ * fitAlign stands apart: the clamp's standing rest constraint wherever an axis has no
101
+ * freedom against the TRUE bounds — it shapes every move above, and is why a fitting
102
+ * axis settles identically whatever arrivalAlign says.
103
+ *
104
+ * "Does it fit the viewport?" still decides SIZE and SUBJECT — never alignment:
105
+ * • step size: next/prev step by ITEM (spread) when the item fits, by PAGE when
106
+ * zoomed in past it.
107
+ * • subject: you arrive AT the unit that fits (item, page — or the whole scene
108
+ * under fit-all).
109
+ *
110
+ * The `cursor` is THE current page in both flows: navigation sets it; continuous
111
+ * scrolling syncs it from the camera; paged panning never moves it.
112
+ */
113
+ function createStageCapability(ctx, config = {}) {
114
+ const host = globalThis;
115
+ const canAnimate = !!config.scheduler || typeof host.requestAnimationFrame === "function";
116
+ const scheduler = config.scheduler ?? (typeof host.requestAnimationFrame === "function" ? {
117
+ raf: (cb) => host.requestAnimationFrame(cb),
118
+ caf: (h) => host.cancelAnimationFrame(h)
119
+ } : {
120
+ raf: () => 0,
121
+ caf: () => {}
122
+ });
123
+ const cam = () => ctx.getState().camera;
124
+ const vp = () => ctx.getState().vp;
125
+ const dpr = () => ctx.getState().dpr;
126
+ const pad = () => ctx.getState().padding;
127
+ const paged = () => ctx.getState().flow === "paged";
128
+ const isFitAll = () => {
129
+ const z = ctx.getState().zoom;
130
+ return "mode" in z && z.mode === S.ZoomMode.FitAll;
131
+ };
132
+ let groupingCache = null;
133
+ const grouping = () => {
134
+ const doc = ctx.document();
135
+ const st = ctx.getState();
136
+ const key = `${doc ? doc.pageCount : 0}|${st.spread}`;
137
+ if (groupingCache && groupingCache.key === key) return groupingCache;
138
+ const g = S.groupPages(doc ? doc.pageCount : 0, st.spread);
139
+ groupingCache = {
140
+ key,
141
+ grouping: g,
142
+ firstPages: g.map((item) => item[0])
143
+ };
144
+ return groupingCache;
145
+ };
146
+ const itemIndexOfPage = (pageIndex) => {
147
+ const fp = grouping().firstPages;
148
+ let lo = 0;
149
+ let hi = fp.length;
150
+ while (lo < hi) {
151
+ const mid = lo + hi >> 1;
152
+ if (fp[mid] <= pageIndex) lo = mid + 1;
153
+ else hi = mid;
154
+ }
155
+ return Math.max(0, lo - 1);
156
+ };
157
+ const effectiveZoom = () => {
158
+ const z = ctx.getState().zoom;
159
+ return "level" in z ? z.level : Math.max(ctx.getState().camera.zoom, 1e-4);
160
+ };
161
+ const wrapLineWidth = () => Math.max(1, (vp().width - 2 * pad()) / effectiveZoom());
162
+ const worldPageFrame = () => {
163
+ const m = ctx.getState().pageFrame;
164
+ if (!m.top && !m.right && !m.bottom && !m.left) return m;
165
+ const ez = effectiveZoom();
166
+ return {
167
+ top: m.top / ez,
168
+ right: m.right / ez,
169
+ bottom: m.bottom / ez,
170
+ left: m.left / ez
171
+ };
172
+ };
173
+ const frameKey = () => {
174
+ const m = ctx.getState().pageFrame;
175
+ if (!m.top && !m.right && !m.bottom && !m.left) return "-";
176
+ const w = worldPageFrame();
177
+ return `${Math.round(w.top)},${Math.round(w.right)},${Math.round(w.bottom)},${Math.round(w.left)}`;
178
+ };
179
+ const worldGap = () => {
180
+ const g = ctx.getState().gap;
181
+ return typeof g === "number" ? g : g.px ? g.px / effectiveZoom() : 0;
182
+ };
183
+ const gapKey = () => {
184
+ const g = ctx.getState().gap;
185
+ return typeof g === "number" ? String(g) : `px:${Math.round(worldGap())}`;
186
+ };
187
+ const layoutFor = (groups) => {
188
+ const st = ctx.getState();
189
+ const raw = ctx.document()?.pages ?? [];
190
+ const vr = st.viewRotation;
191
+ const pages = vr === 0 ? raw : raw.map((p) => ({
192
+ ...p,
193
+ rotation: addRotations(p.rotation, vr)
194
+ }));
195
+ const pageFrame = worldPageFrame();
196
+ const gap = worldGap();
197
+ const vupp = st.viewUnitsPerPoint;
198
+ if (st.layout === "grid") return S.gridLayout(pages, groups, {
199
+ gap,
200
+ sizing: st.sizing,
201
+ direction: st.direction,
202
+ pageFrame,
203
+ viewUnitsPerPoint: vupp,
204
+ columns: typeof st.columns === "number" ? st.columns : void 0,
205
+ lineWidth: st.columns === "auto" ? wrapLineWidth() : void 0
206
+ });
207
+ return st.layout === "horizontal" ? S.linearLayout(pages, groups, {
208
+ axis: "x",
209
+ gap,
210
+ sizing: st.sizing,
211
+ direction: st.direction,
212
+ pageFrame,
213
+ viewUnitsPerPoint: vupp
214
+ }) : S.linearLayout(pages, groups, {
215
+ axis: "y",
216
+ gap,
217
+ sizing: st.sizing,
218
+ direction: st.direction,
219
+ pageFrame,
220
+ viewUnitsPerPoint: vupp
221
+ });
222
+ };
223
+ const columnsKey = () => {
224
+ const st = ctx.getState();
225
+ if (st.layout !== "grid") return "-";
226
+ return st.columns === "auto" ? `auto:${Math.round(wrapLineWidth())}` : String(st.columns);
227
+ };
228
+ const SCENE_KEY_FNS = {
229
+ columns: columnsKey,
230
+ gap: gapKey,
231
+ pageFrame: frameKey
232
+ };
233
+ const SCENE_KEYS = SETTING_KEYS.filter((k) => SETTINGS_EFFECT[k] === "scene");
234
+ const settingsKey = () => SCENE_KEYS.map((k) => {
235
+ const fn = SCENE_KEY_FNS[k];
236
+ if (fn) return fn();
237
+ const v = ctx.getState()[k];
238
+ return typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
239
+ }).join("|");
240
+ let sceneCache = null;
241
+ const docKey = () => {
242
+ const doc = ctx.document();
243
+ return doc ? `${doc.pageCount}.${doc.revision}` : "0.0";
244
+ };
245
+ const buildScene = () => {
246
+ const st = ctx.getState();
247
+ const { grouping: g } = grouping();
248
+ if (st.flow === "paged") {
249
+ const idx = g.length ? Math.min(itemIndexOfPage(st.cursor), g.length - 1) : 0;
250
+ const key = `paged|${docKey()}|${settingsKey()}|${idx}`;
251
+ if (sceneCache && sceneCache.key === key) return sceneCache.scene;
252
+ const scene = layoutFor(g.length ? [g[idx]] : []);
253
+ sceneCache = {
254
+ key,
255
+ scene
256
+ };
257
+ return scene;
258
+ }
259
+ const key = `cont|${docKey()}|${settingsKey()}`;
260
+ if (sceneCache && sceneCache.key === key) return sceneCache.scene;
261
+ const scene = layoutFor(g);
262
+ sceneCache = {
263
+ key,
264
+ scene
265
+ };
266
+ return scene;
267
+ };
268
+ const sceneRect = () => {
269
+ const { width, height } = buildScene().size;
270
+ return {
271
+ x: 0,
272
+ y: 0,
273
+ width,
274
+ height
275
+ };
276
+ };
277
+ const itemRect = (it) => ({
278
+ x: it.x,
279
+ y: it.y,
280
+ width: it.width,
281
+ height: it.height
282
+ });
283
+ const pageRectOf = (it, pageIndex) => {
284
+ const box = it.pages.find((p) => p.pageIndex === pageIndex) ?? it.pages[0];
285
+ return {
286
+ x: box.x,
287
+ y: box.y,
288
+ width: box.width,
289
+ height: box.height
290
+ };
291
+ };
292
+ /** The item shown for the cursor: the slice's only item (paged) / the full-scene item. */
293
+ const cursorItem = () => {
294
+ const sc = buildScene();
295
+ return paged() ? sc.items[0] : sc.items[itemIndexOfPage(ctx.getState().cursor)];
296
+ };
297
+ /**
298
+ * CONTENT-space rect on a page → WORLD rect: the same quarter-turn matrix
299
+ * `pageRectToScreen` uses, minus the camera — so a positioned reveal and
300
+ * the rendered overlay can never disagree about where a rect is.
301
+ */
302
+ const worldRectForContent = (it, pageIndex, rect) => {
303
+ const box = it.pages.find((p) => p.pageIndex === pageIndex) ?? it.pages[0];
304
+ const content = displaySize({
305
+ width: box.width,
306
+ height: box.height
307
+ }, box.rotation);
308
+ const wr = applyRect(rotateScaleMatrix(box.contentScale, content.width, content.height, box.rotation), rect);
309
+ return {
310
+ x: box.x + wr.x,
311
+ y: box.y + wr.y,
312
+ width: wr.width,
313
+ height: wr.height
314
+ };
315
+ };
316
+ /**
317
+ * One axis of a positioned-reveal camera. `undefined` = 'nearest' (only
318
+ * move if the target is outside the padded view) — unless the zoom just
319
+ * changed, where "don't move" is meaningless and the spec's slack-axis
320
+ * rule (center) applies. 'keep' never moves the axis (PDF /XYZ null).
321
+ */
322
+ const revealAxis = (a, camPos, rectPos, rectExtent, vpExtent, zoom, zoomChanged) => {
323
+ if (a === "keep") return camPos;
324
+ const p = pad();
325
+ if (a === void 0) {
326
+ if (!zoomChanged) {
327
+ const lo = camPos + p / zoom;
328
+ const hi = camPos + (vpExtent - p) / zoom;
329
+ if (rectPos >= lo && rectPos + rectExtent <= hi) return camPos;
330
+ if (rectExtent > hi - lo || rectPos < lo) return rectPos - p / zoom;
331
+ return rectPos + rectExtent - (vpExtent - p) / zoom;
332
+ }
333
+ a = "center";
334
+ }
335
+ if (a === "start") return rectPos - p / zoom;
336
+ if (a === "end") return rectPos + rectExtent - (vpExtent - p) / zoom;
337
+ const f = a === "center" ? .5 : Math.min(1, Math.max(0, a));
338
+ return rectPos + rectExtent / 2 - vpExtent * f / zoom;
339
+ };
340
+ const fits = (rect, zoom) => {
341
+ const v = vp();
342
+ const p = pad();
343
+ const eps = .5;
344
+ return rect.width * zoom <= v.width - 2 * p + eps && rect.height * zoom <= v.height - 2 * p + eps;
345
+ };
346
+ const alignFraction = (a) => a === "start" ? 0 : a === "center" ? .5 : a === "end" ? 1 : Math.min(1, Math.max(0, a));
347
+ const alignPoint = (al, v) => {
348
+ const rtl = ctx.getState().direction === "rtl";
349
+ const ax = rtl && al.x === "start" ? "end" : rtl && al.x === "end" ? "start" : al.x;
350
+ const p = pad();
351
+ return {
352
+ x: p + (v.width - 2 * p) * alignFraction(ax),
353
+ y: p + (v.height - 2 * p) * alignFraction(al.y)
354
+ };
355
+ };
356
+ const anchorPoint = () => alignPoint(ctx.getState().anchorAlign, vp());
357
+ /** Fit-box for resolving the zoom intent: whole scene (fit-all), the current item
358
+ * (paged — per-page fit), or the document max (continuous — doc-stable zoom). */
359
+ const fitBox = (item) => {
360
+ if (isFitAll()) return buildScene().size;
361
+ return paged() ? {
362
+ width: item.width,
363
+ height: item.height
364
+ } : buildScene().maxItemSize;
365
+ };
366
+ /** Clamp rect for a camera write targeting this item (scene-wide in continuous). */
367
+ const boundsFor = (it) => paged() ? itemRect(it) : sceneRect();
368
+ /** Clamp rect for a "stay" write (pan/zoom): the slice item (paged) / the scene. */
369
+ const stayBounds = () => paged() ? itemRect(buildScene().items[0]) : sceneRect();
370
+ const constraint = () => ({
371
+ bounded: ctx.getState().bounded,
372
+ padding: pad(),
373
+ fitAlign: ctx.getState().fitAlign,
374
+ direction: ctx.getState().direction
375
+ });
376
+ const setCam = (next, bounds = stayBounds()) => {
377
+ ctx.dispatch({
378
+ type: "CAMERA",
379
+ camera: S.clampCamera(next, bounds, vp(), constraint())
380
+ });
381
+ };
382
+ /**
383
+ * Cursor reconciliation, one direction per interaction:
384
+ * navigation → cursor is INTENT, set explicitly; the camera honors it as far
385
+ * as the clamp allows — and a clamped camera never revokes it.
386
+ * manipulation → (pan / drag / pinch) the camera moves freely; the cursor is
387
+ * DERIVED from it. Only those verbs call this. Paged never syncs.
388
+ */
389
+ const syncCursorFromCamera = () => {
390
+ if (paged()) return;
391
+ const sc = buildScene();
392
+ if (!sc.itemCount) return;
393
+ const page = S.anchorFromCamera(cam(), sc, vp()).pageIndex;
394
+ if (page !== ctx.getState().cursor) ctx.dispatch({
395
+ type: "CURSOR",
396
+ cursor: page
397
+ });
398
+ };
399
+ let raf = 0;
400
+ const cancelAnim = () => {
401
+ if (raf) {
402
+ scheduler.caf(raf);
403
+ raf = 0;
404
+ }
405
+ };
406
+ const lerp = (a, b, k) => a + (b - a) * k;
407
+ const animateTo = (target, bounds, ms = 240, then) => {
408
+ if (!canAnimate) {
409
+ setCam(target, bounds);
410
+ then?.();
411
+ return;
412
+ }
413
+ cancelAnim();
414
+ const from = cam();
415
+ const ease = (t) => 1 - Math.pow(1 - t, 3);
416
+ let started = false;
417
+ let t0 = 0;
418
+ const tick = (now) => {
419
+ if (!started) {
420
+ started = true;
421
+ t0 = now;
422
+ }
423
+ const k = ease(Math.min(1, (now - t0) / ms));
424
+ setCam({
425
+ x: lerp(from.x, target.x, k),
426
+ y: lerp(from.y, target.y, k),
427
+ zoom: lerp(from.zoom, target.zoom, k)
428
+ }, bounds);
429
+ if (k < 1) raf = scheduler.raf(tick);
430
+ else {
431
+ raf = 0;
432
+ then?.();
433
+ }
434
+ };
435
+ raf = scheduler.raf(tick);
436
+ };
437
+ const anchorAt = (at) => {
438
+ const sc = buildScene();
439
+ return sc.itemCount ? S.anchorFromCamera(cam(), sc, vp(), at) : {
440
+ pageIndex: ctx.getState().cursor,
441
+ fx: .5,
442
+ fy: 0
443
+ };
444
+ };
445
+ const currentAnchor = () => anchorAt(anchorPoint());
446
+ /**
447
+ * Run a placement whose RESOLVED zoom may change the scene (wrapped mode: zoom is
448
+ * a layout input). Re-run it until the scene it placed against is the scene that
449
+ * results — every non-wrapped mode is stable after the first pass by construction,
450
+ * and wrapped fit-modes converge on the second (the fit-box is wrap-independent).
451
+ * Capped at 3 passes for the one genuinely circular case (fit-all + wrapped).
452
+ */
453
+ const stabilized = (place) => {
454
+ for (let attempt = 0; attempt < 3; attempt++) {
455
+ const scene = buildScene();
456
+ place();
457
+ if (buildScene() === scene) return;
458
+ }
459
+ };
460
+ const applyAnchor = (anchor, align) => {
461
+ stabilized(() => {
462
+ const scene = buildScene();
463
+ if (!scene.itemCount) return;
464
+ const item = scene.items[scene.itemOfPage(anchor.pageIndex)];
465
+ const zoom = S.resolveZoom(ctx.getState().zoom, fitBox(item), vp(), pad());
466
+ const at = alignPoint(align ?? ctx.getState().anchorAlign, vp());
467
+ setCam(S.cameraFromAnchor(anchor, scene, vp(), zoom, at), boundsFor(item));
468
+ });
469
+ };
470
+ /**
471
+ * Re-apply the view after a structural/zoom/viewport change. Normally anchor-
472
+ * preserving (keep looking at the same spot, held at the invariant point).
473
+ * Under fit-all the subject is the WHOLE scene, so "keep my anchor" is
474
+ * meaningless — re-place instead (this is what centers the scene even when
475
+ * unbounded).
476
+ */
477
+ const reapply = (anchor, align) => {
478
+ if (isFitAll()) goToTarget(ctx.getState().cursor, { behavior: "instant" });
479
+ else applyAnchor(anchor, align);
480
+ };
481
+ const goToTarget = (pageIndex, opts) => {
482
+ cancelAnim();
483
+ const doc = ctx.document();
484
+ if (!doc || doc.pageCount === 0) return;
485
+ const target = Math.max(0, Math.min(pageIndex, doc.pageCount - 1));
486
+ if (target !== ctx.getState().cursor) ctx.dispatch({
487
+ type: "CURSOR",
488
+ cursor: target
489
+ });
490
+ if (opts?.viewpoint) {
491
+ ctx.dispatch({
492
+ type: "PATCH",
493
+ patch: { zoom: opts.viewpoint.zoom }
494
+ });
495
+ applyAnchor(opts.viewpoint.anchor);
496
+ return;
497
+ }
498
+ const placement = () => {
499
+ const sc = buildScene();
500
+ if (!sc.itemCount) return null;
501
+ const item = paged() ? sc.items[0] : sc.items[itemIndexOfPage(target)];
502
+ const zoom = S.resolveZoom(ctx.getState().zoom, fitBox(item), vp(), pad());
503
+ const subject = isFitAll() ? sceneRect() : fits(itemRect(item), zoom) ? itemRect(item) : pageRectOf(item, target);
504
+ const want = {
505
+ ...ctx.getState().arrivalAlign,
506
+ ...opts?.arrivalAlign
507
+ };
508
+ const placed = S.placeCamera(subject, vp(), zoom, pad(), {
509
+ x: want.x === "keep" ? "start" : want.x,
510
+ y: want.y === "keep" ? "start" : want.y
511
+ }, ctx.getState().direction);
512
+ const bounds = boundsFor(item);
513
+ const settled = S.clampCamera(placed, bounds, vp(), {
514
+ ...constraint(),
515
+ bounded: true
516
+ });
517
+ return {
518
+ camera: {
519
+ zoom,
520
+ x: want.x === "keep" ? cam().x : settled.x,
521
+ y: want.y === "keep" ? cam().y : settled.y
522
+ },
523
+ bounds
524
+ };
525
+ };
526
+ if ((opts?.behavior ?? ctx.getState().scrollBehavior) === "smooth") {
527
+ const p = placement();
528
+ if (p) animateTo(p.camera, p.bounds);
529
+ } else stabilized(() => {
530
+ const p = placement();
531
+ if (p) setCam(p.camera, p.bounds);
532
+ });
533
+ };
534
+ /** Step by the navigation unit: the ITEM when it fits the viewport, else the PAGE. */
535
+ const step = (direction, opts) => {
536
+ const st = ctx.getState();
537
+ const item = cursorItem();
538
+ if (!item) return;
539
+ const { grouping: g } = grouping();
540
+ if (isFitAll() || fits(itemRect(item), cam().zoom)) {
541
+ const idx = Math.max(0, Math.min(itemIndexOfPage(st.cursor) + direction, g.length - 1));
542
+ goToTarget(g[idx][0], opts);
543
+ } else goToTarget(st.cursor + direction, opts);
544
+ };
545
+ const ponForIndex = (index) => ctx.document()?.pages[index]?.pageObjectNumber ?? index + 1;
546
+ const unrotatedPoints = (box) => {
547
+ const content = displaySize({
548
+ width: box.width,
549
+ height: box.height
550
+ }, box.rotation);
551
+ return {
552
+ width: content.width / box.contentScale,
553
+ height: content.height / box.contentScale
554
+ };
555
+ };
556
+ const withTransform = (box) => {
557
+ const reg = ctx.document()?.pages[box.pageIndex];
558
+ const pageSize = reg ? {
559
+ width: reg.size.width,
560
+ height: reg.size.height
561
+ } : unrotatedPoints(box);
562
+ const c = cam();
563
+ const ratio = dpr();
564
+ const screenX = snapToDevice((box.x - c.x) * c.zoom, ratio);
565
+ const screenY = snapToDevice((box.y - c.y) * c.zoom, ratio);
566
+ const transform = pageTransform({
567
+ pageSize,
568
+ rotation: box.rotation,
569
+ scale: box.contentScale * c.zoom,
570
+ baseScale: ctx.getState().viewUnitsPerPoint * (reg?.userUnit ?? 1),
571
+ dpr: ratio
572
+ });
573
+ const v = vp();
574
+ const onScreen = intersectRects({
575
+ x: -screenX,
576
+ y: -screenY,
577
+ width: v.width,
578
+ height: v.height
579
+ }, {
580
+ x: 0,
581
+ y: 0,
582
+ width: transform.viewWidth,
583
+ height: transform.viewHeight
584
+ });
585
+ const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToPageRect(onScreen) : {
586
+ x: 0,
587
+ y: 0,
588
+ width: 0,
589
+ height: 0
590
+ };
591
+ return {
592
+ ...box,
593
+ pon: ponForIndex(box.pageIndex),
594
+ screenX,
595
+ screenY,
596
+ transform,
597
+ visibleRect
598
+ };
599
+ };
600
+ let scrollMemo = null;
601
+ const scrollMetricsNow = () => {
602
+ const sc = buildScene();
603
+ const m = S.scrollMetrics(cam(), sc.itemCount ? stayBounds() : {
604
+ x: 0,
605
+ y: 0,
606
+ width: 0,
607
+ height: 0
608
+ }, vp(), pad());
609
+ const p = scrollMemo;
610
+ 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;
611
+ scrollMemo = m;
612
+ return m;
613
+ };
614
+ let visSig = "";
615
+ let vis = [];
616
+ const visiblePages = () => {
617
+ const c = cam();
618
+ const v = vp();
619
+ const sc = buildScene();
620
+ const items = paged() ? sc.items.slice(0, 1) : sc.query(S.cameraWorldRect(c, v));
621
+ const sig = `${sceneCache.key}|${ctx.getState().flow}|${c.x},${c.y},${c.zoom}|${v.width}x${v.height}|${dpr()}`;
622
+ if (sig === visSig) return vis;
623
+ visSig = sig;
624
+ vis = items.flatMap((it) => it.pages).map(withTransform);
625
+ return vis;
626
+ };
627
+ const pickSettings = (src) => {
628
+ const out = {};
629
+ for (const k of SETTING_KEYS) out[k] = src[k];
630
+ return out;
631
+ };
632
+ const snapshotSettings = () => pickSettings(ctx.getState());
633
+ const initialViewProviders = [];
634
+ let hasPlaced = false;
635
+ const api = {
636
+ camera: cam,
637
+ viewport: vp,
638
+ scrollMetrics: scrollMetricsNow,
639
+ pageCount: () => ctx.document()?.pageCount ?? 0,
640
+ visiblePages,
641
+ currentPage: () => ctx.getState().cursor,
642
+ currentItemPages: () => {
643
+ const item = cursorItem();
644
+ return item ? [...item.pageIndexes] : [];
645
+ },
646
+ pages: () => (ctx.document()?.pages ?? []).map((p) => ({
647
+ index: p.index,
648
+ pon: p.pageObjectNumber,
649
+ label: p.label ?? null
650
+ })),
651
+ pageRect: (pon) => {
652
+ const meta = ctx.document();
653
+ const index = meta ? meta.pages.findIndex((p) => p.pageObjectNumber === pon) : -1;
654
+ if (index < 0) return null;
655
+ const sc = buildScene();
656
+ if (!sc.itemCount) return null;
657
+ const box = sc.items[sc.itemOfPage(index)].pages.find((p) => p.pageIndex === index);
658
+ return box ? withTransform(box) : null;
659
+ },
660
+ pageAt: (screen) => {
661
+ for (const p of visiblePages()) {
662
+ const lx = screen.x - p.screenX;
663
+ const ly = screen.y - p.screenY;
664
+ if (lx >= 0 && ly >= 0 && lx <= p.transform.viewWidth && ly <= p.transform.viewHeight) return {
665
+ pon: p.pon,
666
+ point: p.transform.viewToPage({
667
+ x: lx,
668
+ y: ly
669
+ }),
670
+ scale: p.transform.viewScale,
671
+ rotation: p.rotation,
672
+ zoom: p.transform.zoom
673
+ };
674
+ }
675
+ return null;
676
+ },
677
+ pointOnPage: (pon, screen) => {
678
+ const p = visiblePages().find((v) => v.pon === pon);
679
+ if (!p) return null;
680
+ return p.transform.viewToPage({
681
+ x: screen.x - p.screenX,
682
+ y: screen.y - p.screenY
683
+ });
684
+ },
685
+ pageToWorld: (pon, pt) => {
686
+ const pr = api.pageRect(pon);
687
+ if (!pr) return null;
688
+ const content = displaySize({
689
+ width: pr.width,
690
+ height: pr.height
691
+ }, pr.rotation);
692
+ const offset = applyPoint(rotateScaleMatrix(pr.contentScale, content.width, content.height, pr.rotation), pt);
693
+ return {
694
+ x: pr.x + offset.x,
695
+ y: pr.y + offset.y
696
+ };
697
+ },
698
+ pageRectToScreen: (pon, rect) => {
699
+ const pr = api.pageRect(pon);
700
+ if (!pr) return null;
701
+ const content = displaySize({
702
+ width: pr.width,
703
+ height: pr.height
704
+ }, pr.rotation);
705
+ const wr = applyRect(rotateScaleMatrix(pr.contentScale, content.width, content.height, pr.rotation), rect);
706
+ const c = cam();
707
+ const tl = S.toScreen(c, {
708
+ x: pr.x + wr.x,
709
+ y: pr.y + wr.y
710
+ });
711
+ return {
712
+ x: tl.x,
713
+ y: tl.y,
714
+ width: wr.width * c.zoom,
715
+ height: wr.height * c.zoom
716
+ };
717
+ },
718
+ toScreen: (w) => S.toScreen(cam(), w),
719
+ toWorld: (s) => S.toWorld(cam(), s),
720
+ flow: () => ctx.getState().flow,
721
+ layout: () => ctx.getState().layout,
722
+ spread: () => ctx.getState().spread,
723
+ sizing: () => ctx.getState().sizing,
724
+ columns: () => ctx.getState().columns,
725
+ bounded: () => ctx.getState().bounded,
726
+ padding: () => ctx.getState().padding,
727
+ gap: () => ctx.getState().gap,
728
+ pageFrame: () => ctx.getState().pageFrame,
729
+ fitAlign: () => ctx.getState().fitAlign,
730
+ arrivalAlign: () => ctx.getState().arrivalAlign,
731
+ zoomAlign: () => ctx.getState().zoomAlign,
732
+ anchorAlign: () => ctx.getState().anchorAlign,
733
+ direction: () => ctx.getState().direction,
734
+ scrollBehavior: () => ctx.getState().scrollBehavior,
735
+ viewRotation: () => ctx.getState().viewRotation,
736
+ zoomLevel: () => cam().zoom,
737
+ zoomMode: () => {
738
+ const z = ctx.getState().zoom;
739
+ return "mode" in z ? z.mode : "custom";
740
+ },
741
+ viewpoint: () => ({
742
+ anchor: currentAnchor(),
743
+ zoom: ctx.getState().zoom
744
+ }),
745
+ settings: snapshotSettings,
746
+ viewState: () => ({
747
+ ...snapshotSettings(),
748
+ cursor: ctx.getState().cursor,
749
+ anchor: currentAnchor()
750
+ }),
751
+ setViewport: (v) => {
752
+ if (!hasPlaced) {
753
+ ctx.dispatch({
754
+ type: "VP",
755
+ vp: v
756
+ });
757
+ if (v.width > 0 && v.height > 0 && (ctx.document()?.pageCount ?? 0) > 0) api.placeInitial();
758
+ return;
759
+ }
760
+ cancelAnim();
761
+ const anchor = currentAnchor();
762
+ ctx.dispatch({
763
+ type: "VP",
764
+ vp: v
765
+ });
766
+ reapply(anchor);
767
+ },
768
+ setDevicePixelRatio: (ratio) => {
769
+ if (ratio > 0 && ratio !== dpr()) ctx.dispatch({
770
+ type: "DPR",
771
+ dpr: ratio
772
+ });
773
+ },
774
+ setCamera: (c) => {
775
+ cancelAnim();
776
+ setCam(c);
777
+ syncCursorFromCamera();
778
+ },
779
+ panBy: (dx, dy) => {
780
+ cancelAnim();
781
+ setCam(S.panByScreen(cam(), dx, dy));
782
+ syncCursorFromCamera();
783
+ },
784
+ scrollTo: (opts) => {
785
+ cancelAnim();
786
+ if (!buildScene().itemCount) return;
787
+ const next = S.cameraFromScroll(cam(), stayBounds(), vp(), pad(), opts);
788
+ if (opts.behavior === "smooth") animateTo(next, stayBounds(), 240, syncCursorFromCamera);
789
+ else {
790
+ setCam(next);
791
+ syncCursorFromCamera();
792
+ }
793
+ },
794
+ scrollBy: ({ left, top, behavior }) => {
795
+ const m = scrollMetricsNow();
796
+ api.scrollTo({
797
+ left: left === void 0 ? void 0 : m.scrollLeft + left,
798
+ top: top === void 0 ? void 0 : m.scrollTop + top,
799
+ behavior
800
+ });
801
+ },
802
+ zoomAround: (pt, factor) => {
803
+ cancelAnim();
804
+ const before = buildScene();
805
+ const focal = before.itemCount ? S.anchorAtPoint(before, S.toWorld(cam(), pt)) : null;
806
+ setCam(S.zoomAround(cam(), pt, factor));
807
+ ctx.dispatch({
808
+ type: "PATCH",
809
+ patch: { zoom: { level: cam().zoom } }
810
+ });
811
+ const after = buildScene();
812
+ if (after !== before && focal && after.itemCount) setCam(S.cameraForAnchorAtScreen(focal, after, pt, cam().zoom));
813
+ syncCursorFromCamera();
814
+ },
815
+ zoomIn: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1.2),
816
+ zoomOut: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1 / 1.2),
817
+ zoomTo: (spec) => api.update({ zoom: spec }),
818
+ fitWidth: () => api.update({ zoom: { mode: S.ZoomMode.FitWidth } }),
819
+ fitPage: () => api.update({ zoom: { mode: S.ZoomMode.FitPage } }),
820
+ fitAll: () => api.update({ zoom: { mode: S.ZoomMode.FitAll } }),
821
+ automatic: () => api.update({ zoom: { mode: S.ZoomMode.Automatic } }),
822
+ refit: () => {
823
+ if (!hasPlaced) return;
824
+ cancelAnim();
825
+ reapply(currentAnchor());
826
+ },
827
+ goToPage: (pageIndex, opts) => goToTarget(pageIndex, opts),
828
+ reveal: (pageIndex, opts) => {
829
+ const doc = ctx.document();
830
+ if (!doc || doc.pageCount === 0) return;
831
+ const target = Math.max(0, Math.min(pageIndex, doc.pageCount - 1));
832
+ if (!(!!opts && (opts.rect !== void 0 || opts.anchor !== void 0 || opts.zoom !== void 0 && opts.zoom !== "keep"))) {
833
+ if (paged()) {
834
+ goToTarget(target, opts);
835
+ return;
836
+ }
837
+ const sc = buildScene();
838
+ if (!sc.itemCount) return;
839
+ const page = pageRectOf(sc.items[itemIndexOfPage(target)], target);
840
+ const m = worldPageFrame();
841
+ const box = {
842
+ x: page.x - m.left,
843
+ y: page.y - m.top,
844
+ width: page.width + m.left + m.right,
845
+ height: page.height + m.top + m.bottom
846
+ };
847
+ const camera = S.revealCamera(cam(), box, vp(), pad());
848
+ const current = cam();
849
+ if (camera.x === current.x && camera.y === current.y) return;
850
+ cancelAnim();
851
+ if ((opts?.behavior ?? ctx.getState().scrollBehavior) === "smooth") animateTo(camera, sceneRect());
852
+ else setCam(camera, sceneRect());
853
+ return;
854
+ }
855
+ cancelAnim();
856
+ if (target !== ctx.getState().cursor) ctx.dispatch({
857
+ type: "CURSOR",
858
+ cursor: target
859
+ });
860
+ const place = () => {
861
+ const sc = buildScene();
862
+ if (!sc.itemCount) return null;
863
+ const item = paged() ? sc.items[0] : sc.items[itemIndexOfPage(target)];
864
+ const world = opts.rect ? worldRectForContent(item, target, opts.rect) : pageRectOf(item, target);
865
+ const zd = opts.zoom ?? "keep";
866
+ const availW = Math.max(1, vp().width - 2 * pad());
867
+ const availH = Math.max(1, vp().height - 2 * pad());
868
+ 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;
869
+ if (!Number.isFinite(zoom) || zoom <= 0) zoom = cam().zoom;
870
+ const zoomChanged = zd !== "keep";
871
+ const a = opts.anchor ?? {};
872
+ return {
873
+ camera: {
874
+ x: revealAxis(a.x, cam().x, world.x, world.width, vp().width, zoom, zoomChanged),
875
+ y: revealAxis(a.y, cam().y, world.y, world.height, vp().height, zoom, zoomChanged),
876
+ zoom
877
+ },
878
+ bounds: boundsFor(item),
879
+ zoomChanged
880
+ };
881
+ };
882
+ const first = place();
883
+ if (!first) return;
884
+ if (first.zoomChanged) ctx.dispatch({
885
+ type: "PATCH",
886
+ patch: { zoom: { level: first.camera.zoom } }
887
+ });
888
+ if ((opts.behavior ?? ctx.getState().scrollBehavior) === "smooth") {
889
+ const p = place() ?? first;
890
+ animateTo(p.camera, p.bounds);
891
+ } else stabilized(() => {
892
+ const p = place();
893
+ if (p) setCam(p.camera, p.bounds);
894
+ });
895
+ },
896
+ next: (opts) => step(1, opts),
897
+ prev: (opts) => step(-1, opts),
898
+ update: (patch) => {
899
+ cancelAnim();
900
+ const touched = (effect) => SETTING_KEYS.some((k) => patch[k] !== void 0 && SETTINGS_EFFECT[k] === effect);
901
+ const align = touched("refit") && !touched("scene") && !touched("reflow") ? ctx.getState().zoomAlign : ctx.getState().anchorAlign;
902
+ const anchor = anchorAt(alignPoint(align, vp()));
903
+ ctx.dispatch({
904
+ type: "PATCH",
905
+ patch
906
+ });
907
+ if (touched("scene")) sceneCache = null;
908
+ if (touched("reflow")) goToTarget(ctx.getState().cursor, { behavior: "instant" });
909
+ else if (touched("scene") || touched("refit")) reapply(anchor, align);
910
+ else if (touched("reclamp")) setCam(cam());
911
+ },
912
+ setFlow: (flow) => api.update({ flow }),
913
+ setLayout: (layout) => api.update({ layout }),
914
+ setSpread: (spread) => api.update({ spread }),
915
+ setSizing: (sizing) => api.update({ sizing }),
916
+ setColumns: (columns) => api.update({ columns }),
917
+ setBounded: (bounded) => api.update({ bounded }),
918
+ setPadding: (padding) => api.update({ padding }),
919
+ setGap: (gap) => api.update({ gap }),
920
+ setPageFrame: (pageFrame) => api.update({ pageFrame }),
921
+ setFitAlign: (fitAlign) => api.update({ fitAlign }),
922
+ setArrivalAlign: (arrivalAlign) => api.update({ arrivalAlign }),
923
+ setZoomAlign: (zoomAlign) => api.update({ zoomAlign }),
924
+ setAnchorAlign: (anchorAlign) => api.update({ anchorAlign }),
925
+ setDirection: (direction) => api.update({ direction }),
926
+ setViewRotation: (viewRotation) => api.update({ viewRotation }),
927
+ rotateView: (delta) => api.setViewRotation(addRotations(ctx.getState().viewRotation, delta === 90 ? 90 : 270)),
928
+ setScrollBehavior: (behavior) => api.update({ scrollBehavior: behavior }),
929
+ applyViewState: (view) => {
930
+ cancelAnim();
931
+ ctx.dispatch({
932
+ type: "PATCH",
933
+ patch: pickSettings(view)
934
+ });
935
+ ctx.dispatch({
936
+ type: "CURSOR",
937
+ cursor: view.cursor ?? 0
938
+ });
939
+ sceneCache = null;
940
+ applyAnchor(view.anchor);
941
+ },
942
+ provideInitialView: (priority, fn) => {
943
+ initialViewProviders.push({
944
+ priority,
945
+ fn
946
+ });
947
+ },
948
+ placeInitial: () => {
949
+ hasPlaced = true;
950
+ const sorted = [...initialViewProviders].sort((a, b) => b.priority - a.priority);
951
+ for (const p of sorted) {
952
+ const view = p.fn();
953
+ if (view) {
954
+ api.applyViewState(view);
955
+ return;
956
+ }
957
+ }
958
+ api.resetView();
959
+ },
960
+ resetView: () => goToTarget(0, { behavior: "instant" })
961
+ };
962
+ return api;
963
+ }
964
+ //#endregion
965
+ //#region src/scroll-handler.ts
966
+ /**
967
+ * Pan-the-camera as an interaction handler. Live under the built-in `pan` tool
968
+ * (the `scroll` tag) and — with `panFallback` — under every tool, where it then
969
+ * captures ONLY page-gap drags (on-page downs belong to the active tool, which
970
+ * claims them first at higher priority). It uses the sample's viewport delta, so
971
+ * it pans over the whole viewport (pages and gaps).
972
+ *
973
+ * Cursor feel: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
974
+ * hovering a gap under a non-pan tool. The pan tool's own resting `grab` comes
975
+ * from its `Tool.cursor`, so hovering it needs no claim here.
976
+ */
977
+ function createScrollHandler(stage, interaction, options = {}) {
978
+ const panFallback = options.panFallback ?? true;
979
+ let last = {
980
+ x: 0,
981
+ y: 0
982
+ };
983
+ const isPanTool = () => interaction.activeTool().enables.has("scroll");
984
+ return {
985
+ id: "stage-scroll",
986
+ priority: 10,
987
+ enabledFor: (tool) => tool.enables.has("scroll") || panFallback,
988
+ onDown: (s) => {
989
+ if (!isPanTool() && s.page) return false;
990
+ last = s.viewport;
991
+ interaction.setCursor("stage-grab", "grabbing", 40);
992
+ return true;
993
+ },
994
+ onMove: (s) => {
995
+ stage.panBy(s.viewport.x - last.x, s.viewport.y - last.y);
996
+ last = s.viewport;
997
+ },
998
+ onUp: () => interaction.setCursor("stage-grab", null),
999
+ onHover: (s) => {
1000
+ if (panFallback && !isPanTool()) interaction.setCursor("stage-pan-fallback", s.page ? null : "grab", 5);
1001
+ }
1002
+ };
1003
+ }
1004
+ //#endregion
1005
+ //#region src/effects.ts
1006
+ /**
1007
+ * The one effect a stage lens needs: when the document's page registry changes
1008
+ * (rotate/move/delete bump `DocumentMeta.revision`), the page geometry under
1009
+ * this lens just changed, so re-fit — re-resolve the active zoom intent and
1010
+ * re-place against the new footprint.
1011
+ *
1012
+ * Without it, fit/pixel zoom modes stay anchored to the pre-mutation dimensions:
1013
+ * `pageWidth: 110` renders wider after a 90° rotation (width↔height swap), and
1014
+ * `fitPage` over- or under-fills. The scene already re-keys on the revision, so
1015
+ * only the resolved `cam.zoom` was stale; `refit()` recomputes it.
1016
+ *
1017
+ * Steady-state only: this reacts to revision CHANGES, never the initial value,
1018
+ * so it can't race the capability's level-triggered initial placement (the
1019
+ * reason the rest of the plugin is effect-free).
1020
+ */
1021
+ function registerStageEffects(ctx, token) {
1022
+ ctx.watch(() => ctx.document()?.revision ?? 0, () => ctx.get(token).refit());
1023
+ }
1024
+ //#endregion
1025
+ //#region src/reducer.ts
1026
+ const initialStageState = (config) => {
1027
+ const { scheduler: _scheduler, ...overrides } = config;
1028
+ return {
1029
+ camera: {
1030
+ x: 0,
1031
+ y: 0,
1032
+ zoom: 1
1033
+ },
1034
+ vp: {
1035
+ width: 0,
1036
+ height: 0
1037
+ },
1038
+ dpr: 1,
1039
+ cursor: 0,
1040
+ ...DEFAULT_SETTINGS,
1041
+ ...overrides
1042
+ };
1043
+ };
1044
+ /** Merge a settings patch, ignoring undefined values (safe for partial restores). */
1045
+ const applyPatch = (state, patch) => {
1046
+ const next = { ...state };
1047
+ let key;
1048
+ for (key in patch) {
1049
+ const value = patch[key];
1050
+ if (value !== void 0) Object.assign(next, { [key]: value });
1051
+ }
1052
+ return next;
1053
+ };
1054
+ /**
1055
+ * Pure. Every transition is here; nothing else mutates Stage state. Settings are a
1056
+ * flat bag of primitives — one PATCH action sets any subset (the capability decides
1057
+ * what camera follow-up, if any, each change needs).
1058
+ */
1059
+ const stageReducer = (state, a) => {
1060
+ switch (a.type) {
1061
+ case "CAMERA": return {
1062
+ ...state,
1063
+ camera: a.camera
1064
+ };
1065
+ case "VP": return {
1066
+ ...state,
1067
+ vp: a.vp
1068
+ };
1069
+ case "DPR": return {
1070
+ ...state,
1071
+ dpr: a.dpr
1072
+ };
1073
+ case "CURSOR": return {
1074
+ ...state,
1075
+ cursor: a.cursor
1076
+ };
1077
+ case "PATCH": return applyPatch(state, a.patch);
1078
+ default: return state;
1079
+ }
1080
+ };
1081
+ //#endregion
1082
+ //#region src/types.ts
1083
+ const StageToken = createCapabilityToken("stage");
1084
+ //#endregion
1085
+ //#region src/stage.plugin.ts
1086
+ /**
1087
+ * Wires the parts into a kernel plugin. This file is the "manifest": it says what
1088
+ * the plugin IS (id, token, state, reducer, capability) — the how lives in the
1089
+ * sibling files.
1090
+ */
1091
+ const stagePlugin = (options = {}) => {
1092
+ const { id = "stage", token = StageToken, interaction = false, panFallback = true, ...config } = options;
1093
+ return definePlugin({
1094
+ id,
1095
+ token,
1096
+ scope: "document",
1097
+ optional: interaction ? [InteractionToken] : void 0,
1098
+ initialState: () => initialStageState(config),
1099
+ reduce: stageReducer,
1100
+ capability: (ctx) => createStageCapability(ctx, config),
1101
+ init: interaction ? (ctx) => {
1102
+ const ix = ctx.tryGet(InteractionToken);
1103
+ if (ix) ix.registerHandler(createScrollHandler(ctx.get(token), ix, { panFallback }));
1104
+ } : void 0,
1105
+ effects: (ctx) => registerStageEffects(ctx, token)
1106
+ });
1107
+ };
1108
+ //#endregion
1109
+ //#region src/destination.ts
1110
+ /**
1111
+ * PDF destination → reveal: the pure translator between the PDF protocol's
1112
+ * navigation vocabulary (ISO 32000-1 §12.3.2.2) and the Stage's arrival
1113
+ * primitive. Outline clicks, link annotations, and `/OpenAction` all
1114
+ * resolve (engine-side) to an explicit `PdfDestination`; this maps it onto
1115
+ * ONE `reveal(pageIndex, options)` call — no destination-specific camera
1116
+ * code exists anywhere else.
1117
+ *
1118
+ * The whole protocol collapses onto three knobs:
1119
+ * rect — what to look at (a point for /XYZ, a rect for /FitR, the
1120
+ * page for /Fit, the content bounding box for /FitB*)
1121
+ * zoom — 'keep' | {level} | 'fit' | 'fit-width' | 'fit-height'
1122
+ * anchor — where it lands; 'keep' encodes the spec's null-means-retain
1123
+ *
1124
+ * Coordinates convert PDF user space (y-up, absolute) → content space
1125
+ * (y-down, crop-relative) through the SAME `pdfToContent` matrix selection
1126
+ * and search use, so a destination and an overlay can never disagree.
1127
+ */
1128
+ /**
1129
+ * Translate one explicit destination against its target page's layout.
1130
+ * `bbox` is the page's content BOUNDING box in CONTENT space, for the
1131
+ * `/FitB*` kinds — pass it when known; the crop box is the spec-tolerated
1132
+ * fallback until the engine exposes it.
1133
+ */
1134
+ function destinationToReveal(dest, layout, bbox) {
1135
+ const geo = pageGeometry({
1136
+ crop: layout.boxes.crop,
1137
+ rotation: layout.rotation,
1138
+ userUnit: layout.userUnit
1139
+ }, 1);
1140
+ const crop = layout.boxes.crop;
1141
+ const toContent = (x, y) => applyPoint(geo.pdfToContent, {
1142
+ x,
1143
+ y
1144
+ });
1145
+ const page = {
1146
+ x: 0,
1147
+ y: 0,
1148
+ width: layout.size.width,
1149
+ height: layout.size.height
1150
+ };
1151
+ const box = bbox ?? page;
1152
+ const options = (() => {
1153
+ switch (dest.kind) {
1154
+ case "xyz": {
1155
+ const hasLeft = dest.left != null;
1156
+ const hasTop = dest.top != null;
1157
+ const p = toContent(dest.left ?? crop.left, dest.top ?? crop.top);
1158
+ return {
1159
+ rect: {
1160
+ x: p.x,
1161
+ y: p.y,
1162
+ width: 0,
1163
+ height: 0
1164
+ },
1165
+ anchor: {
1166
+ x: hasLeft ? "start" : "keep",
1167
+ y: hasTop ? "start" : "keep"
1168
+ },
1169
+ zoom: dest.zoom != null && dest.zoom !== 0 ? { level: dest.zoom } : "keep"
1170
+ };
1171
+ }
1172
+ case "fit": return { zoom: "fit" };
1173
+ case "fitH": {
1174
+ const hasTop = dest.top != null;
1175
+ return {
1176
+ rect: {
1177
+ x: 0,
1178
+ y: hasTop ? toContent(crop.left, dest.top).y : 0,
1179
+ width: page.width,
1180
+ height: 0
1181
+ },
1182
+ zoom: "fit-width",
1183
+ anchor: { y: hasTop ? "start" : "keep" }
1184
+ };
1185
+ }
1186
+ case "fitV": {
1187
+ const hasLeft = dest.left != null;
1188
+ return {
1189
+ rect: {
1190
+ x: hasLeft ? toContent(dest.left, crop.top).x : 0,
1191
+ y: 0,
1192
+ width: 0,
1193
+ height: page.height
1194
+ },
1195
+ zoom: "fit-height",
1196
+ anchor: { x: hasLeft ? "start" : "keep" }
1197
+ };
1198
+ }
1199
+ case "fitR": {
1200
+ const tl = toContent(dest.left, dest.top);
1201
+ const br = toContent(dest.right, dest.bottom);
1202
+ return {
1203
+ rect: {
1204
+ x: Math.min(tl.x, br.x),
1205
+ y: Math.min(tl.y, br.y),
1206
+ width: Math.abs(br.x - tl.x),
1207
+ height: Math.abs(br.y - tl.y)
1208
+ },
1209
+ zoom: "fit"
1210
+ };
1211
+ }
1212
+ case "fitB": return {
1213
+ rect: box,
1214
+ zoom: "fit"
1215
+ };
1216
+ case "fitBH": {
1217
+ const hasTop = dest.top != null;
1218
+ const y = hasTop ? toContent(crop.left, dest.top).y : box.y;
1219
+ return {
1220
+ rect: {
1221
+ x: box.x,
1222
+ y,
1223
+ width: box.width,
1224
+ height: 0
1225
+ },
1226
+ zoom: "fit-width",
1227
+ anchor: { y: hasTop ? "start" : "keep" }
1228
+ };
1229
+ }
1230
+ case "fitBV": {
1231
+ const hasLeft = dest.left != null;
1232
+ return {
1233
+ rect: {
1234
+ x: hasLeft ? toContent(dest.left, crop.top).x : box.x,
1235
+ y: box.y,
1236
+ width: 0,
1237
+ height: box.height
1238
+ },
1239
+ zoom: "fit-height",
1240
+ anchor: { x: hasLeft ? "start" : "keep" }
1241
+ };
1242
+ }
1243
+ }
1244
+ })();
1245
+ return {
1246
+ pageIndex: layout.index,
1247
+ options
1248
+ };
1249
+ }
1250
+ //#endregion
1251
+ //#region src/wheel.ts
1252
+ /** The per-notch step for discrete wheels — the zoom buttons' ratio. */
1253
+ const NOTCH = 1.2;
1254
+ /** Zoom factor for a ctrl/cmd wheel event (the caller decides zoom vs pan). */
1255
+ function wheelZoomFactor(e) {
1256
+ if (e.deltaMode !== 0) return Math.pow(NOTCH, -Math.sign(e.deltaY));
1257
+ if (e.ctrlKey) {
1258
+ if (Math.abs(e.deltaY) >= 100) return Math.pow(NOTCH, -Math.sign(e.deltaY));
1259
+ return Math.exp(-e.deltaY / 100);
1260
+ }
1261
+ return Math.exp(-e.deltaY * .0015);
1262
+ }
1263
+ //#endregion
1264
+ export { DEFAULT_SETTINGS, StageToken, ZoomMode, destinationToReveal, settingsEqual, stagePlugin, wheelZoomFactor };
1265
+
1266
+ //# sourceMappingURL=index.js.map