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