@embedpdf/plugin-stage 3.0.0-next.0 → 3.0.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,10 +22,153 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
  }) : target, mod));
23
23
  //#endregion
24
24
  let _embedpdf_core = require("@embedpdf/core");
25
- let _embedpdf_plugin_interaction = require("@embedpdf/plugin-interaction");
26
25
  let _embedpdf_core_stage = require("@embedpdf/core-stage");
27
26
  _embedpdf_core_stage = __toESM(_embedpdf_core_stage, 1);
28
27
  let _embedpdf_core_geometry = require("@embedpdf/core-geometry");
28
+ //#region src/motion.ts
29
+ /**
30
+ * The camera's MOTION vocabulary — every formula, pure.
31
+ *
32
+ * The capability owns the impure drivers (scheduler frames, dispatch, gesture
33
+ * state); the LAWS those drivers integrate live here, parameterized by dt/t
34
+ * with no state, no scheduler, no dispatch — unit-testable in isolation, and
35
+ * with zero plugin dependencies, liftable into stage-core if the native port
36
+ * ever wants them.
37
+ *
38
+ * Two families:
39
+ * • ballistic — one per-axis law for every free motion: `glideStep` (the
40
+ * touch fling, decaying on UIScrollView's curve) and `springStep` (a
41
+ * critically-damped return to the clamp). A glide that reaches a content
42
+ * edge converts to a spring with its velocity carried in, so the bounce
43
+ * grows out of physics rather than a scripted overshoot.
44
+ * • resistance — the iOS rubber-band curve `rubberOut` and its exact
45
+ * inverse `rubberIn`: dragging `d` px past an edge displays an
46
+ * asymptotically-bounded stretch, and re-entering a gesture over a
47
+ * stretched camera (the mid-bounce catch) reconstructs the
48
+ * finger-integrated position the curve came from.
49
+ * Plus the tween interpolators: `easeOutCubic` for coordinate tweens and
50
+ * `zoomLerp` — GEOMETRIC zoom interpolation (zoom is multiplicative; equal
51
+ * ratios per unit time, not equal deltas — the linear lerp's visible "dip").
52
+ */
53
+ /** Resistance coefficient of the rubber-band curve (the iOS feel). */
54
+ const RUBBER = .55;
55
+ /** Glide decay per ms — UIScrollView's normal deceleration rate. */
56
+ const FLING_DECAY = .998;
57
+ /** rad/ms — critically damped, ~400ms to settle. */
58
+ const SPRING_OMEGA = .015;
59
+ /**
60
+ * Overshoot distance → displayed stretch: asymptotic to the viewport
61
+ * dimension `dim`, so the content never leaves the screen however far the
62
+ * finger travels. `rubberOut(0) === 0` — inside the bounds the curve is
63
+ * invisible.
64
+ */
65
+ const rubberOut = (d, dim) => (1 - 1 / (d * RUBBER / dim + 1)) * dim;
66
+ /** Exact inverse of `rubberOut` (defined for `out < dim`). */
67
+ const rubberIn = (out, dim) => out * dim / (RUBBER * (dim - out));
68
+ /**
69
+ * One frame of free glide: exponential velocity decay, position advanced by
70
+ * the midpoint integral of the decay over `dt` (exact for the exponential —
71
+ * frame-rate independent).
72
+ */
73
+ const glideStep = (p, v, dt) => {
74
+ const k = Math.pow(FLING_DECAY, dt);
75
+ const np = p + v * (dt * (1 + k) / 2);
76
+ const nv = v * k;
77
+ return Math.abs(nv) < .02 ? {
78
+ p: np,
79
+ v: 0,
80
+ done: true
81
+ } : {
82
+ p: np,
83
+ v: nv,
84
+ done: false
85
+ };
86
+ };
87
+ /**
88
+ * One frame of critically-damped spring toward `edge` — semi-implicit Euler
89
+ * on x'' = −ω²(x−e) − 2ωx'. Lands exactly ON the edge (position snapped,
90
+ * velocity zeroed) once inside half a pixel at negligible speed.
91
+ */
92
+ const springStep = (p, v, edge, dt) => {
93
+ const nv = v + (-.015 * SPRING_OMEGA * (p - edge) - 2 * SPRING_OMEGA * v) * dt;
94
+ const np = p + nv * dt;
95
+ if (Math.abs(np - edge) < .5 && Math.abs(nv) < .01) return {
96
+ p: edge,
97
+ v: 0,
98
+ done: true
99
+ };
100
+ return {
101
+ p: np,
102
+ v: nv,
103
+ done: false
104
+ };
105
+ };
106
+ const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
107
+ /**
108
+ * Geometric zoom interpolation: `z0·(z1/z0)^k`. Zoom is multiplicative, so a
109
+ * constant-RATE zoom moves equal ratios per unit time; interpolating the
110
+ * level linearly instead makes the start rush and the end crawl (and, under
111
+ * a focal anchor, swings the anchored point along a curved path).
112
+ */
113
+ const zoomLerp = (z0, z1, k) => z0 * Math.pow(z1 / z0, k);
114
+ //#endregion
115
+ //#region src/responsive.ts
116
+ /**
117
+ * Container queries for the settings bag — the pure half. The capability owns
118
+ * the driver (when to re-resolve, how to react); the LAWS live here: what a
119
+ * box is, when a query matches, and what the effective settings are. No state,
120
+ * no dispatch — property-testable in isolation (the `wheel.ts` convention).
121
+ *
122
+ * The query vocabulary is deliberately just the box: a headless, DOM-free
123
+ * stage knows exactly one environmental fact — the viewport it was told.
124
+ * Anything else (pointer coarseness, platform, app state) is either per-event
125
+ * (`PointerSample.pointerType`) or the app's own business via `update()`.
126
+ */
127
+ /** The reported viewport as a queryable box. A square box is 'portrait' (CSS). */
128
+ const boxOf = (vp) => ({
129
+ width: vp.width,
130
+ height: vp.height,
131
+ orientation: vp.height >= vp.width ? "portrait" : "landscape"
132
+ });
133
+ /** All bounds inclusive, all fields optional, conditions AND-ed (CSS ranges). */
134
+ const matchesQuery = (q, box) => (q.minWidth === void 0 || box.width >= q.minWidth) && (q.maxWidth === void 0 || box.width <= q.maxWidth) && (q.minHeight === void 0 || box.height >= q.minHeight) && (q.maxHeight === void 0 || box.height <= q.maxHeight) && (q.orientation === void 0 || box.orientation === q.orientation);
135
+ const ruleMatches = (rule, box) => typeof rule.when === "function" ? !!rule.when(box) : matchesQuery(rule.when, box);
136
+ /** Merge a patch over settings, skipping undefined values (Partial semantics). */
137
+ const mergeSettings = (into, patch) => {
138
+ const out = { ...into };
139
+ let key;
140
+ for (key in patch) {
141
+ const value = patch[key];
142
+ if (value !== void 0) Object.assign(out, { [key]: value });
143
+ }
144
+ return out;
145
+ };
146
+ /**
147
+ * Setting-value equality: primitives by identity, the flat objects the
148
+ * settings vocabulary uses (`{ px }`, `{ x, y }`, page frames, zoom specs) by
149
+ * one level of own-key comparison. Settings values are never nested deeper.
150
+ */
151
+ const eqSetting = (a, b) => {
152
+ if (a === b) return true;
153
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
154
+ const ka = Object.keys(a);
155
+ const kb = Object.keys(b);
156
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
157
+ };
158
+ const resolveResponsive = (base, rules, box) => {
159
+ let effective = base;
160
+ const active = [];
161
+ for (const rule of rules) {
162
+ if (!ruleMatches(rule, box)) continue;
163
+ if (rule.name !== void 0) active.push(rule.name);
164
+ if (rule.settings) effective = mergeSettings(effective, rule.settings);
165
+ }
166
+ return {
167
+ effective,
168
+ active
169
+ };
170
+ };
171
+ //#endregion
29
172
  //#region src/settings.ts
30
173
  /**
31
174
  * Out-of-the-box defaults — a sensible document-reading feel. They are JUST
@@ -71,6 +214,18 @@ const DEFAULT_SETTINGS = {
71
214
  scrollBehavior: "smooth",
72
215
  viewUnitsPerPoint: 96 / 72
73
216
  };
217
+ /**
218
+ * Out-of-the-box responsive rules — the platform feel with zero configuration:
219
+ * a compact container gets the thin gutter phones use (space, not device — an
220
+ * embedded 500px pane on a desktop is compact too). Override the whole list
221
+ * with `stagePlugin({ responsive: [...] })`; `responsive: []` opts out. The
222
+ * 'compact' name is queryable app-side via `stage.matches('compact')`.
223
+ */
224
+ const DEFAULT_RESPONSIVE = [{
225
+ name: "compact",
226
+ when: { maxWidth: 600 },
227
+ settings: { padding: 4 }
228
+ }];
74
229
  const SETTINGS_EFFECT = {
75
230
  flow: "reflow",
76
231
  layout: "scene",
@@ -109,8 +264,9 @@ const settingsEqual = (a, b) => SETTING_KEYS.every((k) => valueEq(a[k], b[k]));
109
264
  * Layering, stated honestly:
110
265
  * • stage-core — PURE spatial math (no DOM, no time). Ports to Rust later.
111
266
  * • 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.
267
+ * the camera tween. It is NOT pure; its one host dependency (frame timing)
268
+ * enters through the Scheduler seam — injectable, defaulting to the host's
269
+ * own frame clock when one exists (see the seam below).
114
270
  *
115
271
  * The model: EVERY camera move is defined by what it holds fixed.
116
272
  * • gestures (pan/pinch/wheel) — the content under the pointer. Physics, no setting.
@@ -396,12 +552,100 @@ function createStageCapability(ctx, config = {}) {
396
552
  fitAlign: ctx.getState().fitAlign,
397
553
  direction: ctx.getState().direction
398
554
  });
555
+ let placementStarted = false;
556
+ let gestureDepth = 0;
557
+ let gestureZoomed = false;
558
+ let gestureElastic = false;
559
+ let gestureRaw = null;
560
+ const REST_MS = 150;
561
+ let restRaf = 0;
562
+ const armRest = () => {
563
+ if (!canAnimate || !placementStarted) return;
564
+ if (ctx.getState().cameraResting) ctx.dispatch({
565
+ type: "CAMERA_REST",
566
+ resting: false
567
+ });
568
+ if (restRaf) scheduler.caf(restRaf);
569
+ let t0 = 0;
570
+ const tick = (ts) => {
571
+ restRaf = 0;
572
+ if (!t0) t0 = ts;
573
+ if (ts - t0 >= REST_MS) {
574
+ ctx.dispatch({
575
+ type: "CAMERA_REST",
576
+ resting: true
577
+ });
578
+ return;
579
+ }
580
+ restRaf = scheduler.raf(tick);
581
+ };
582
+ restRaf = scheduler.raf(tick);
583
+ };
399
584
  const setCam = (next, bounds = stayBounds()) => {
585
+ const clamped = _embedpdf_core_stage.clampCamera(next, bounds, vp(), constraint());
586
+ if (clamped.zoom !== cam().zoom) if (gestureDepth > 0) {
587
+ gestureZoomed = true;
588
+ if (ctx.getState().cameraResting) ctx.dispatch({
589
+ type: "CAMERA_REST",
590
+ resting: false
591
+ });
592
+ if (restRaf) {
593
+ scheduler.caf(restRaf);
594
+ restRaf = 0;
595
+ }
596
+ } else armRest();
400
597
  ctx.dispatch({
401
598
  type: "CAMERA",
402
- camera: _embedpdf_core_stage.clampCamera(next, bounds, vp(), constraint())
599
+ camera: clamped
403
600
  });
404
601
  };
602
+ const axisTravels = (origin, content, view, zoom) => !_embedpdf_core_stage.travelRange(origin, content, view, zoom, constraint().padding).fits;
603
+ /** Unclamped finger-integrated camera → the DISPLAYED camera: clamp, then
604
+ * re-apply the overshoot through the resistance curve, per travelling axis.
605
+ * Inside the bounds this is exactly the clamp (rubber of zero is zero);
606
+ * on a fitting axis it is exactly the clamp ALWAYS. */
607
+ const rubberize = (raw) => {
608
+ const clamped = _embedpdf_core_stage.clampCamera(raw, stayBounds(), vp(), constraint());
609
+ const v = vp();
610
+ const b = stayBounds();
611
+ const axis = (rawA, clampedA, dim, travels) => {
612
+ if (!travels) return clampedA;
613
+ const dWorld = rawA - clampedA;
614
+ if (dWorld === 0) return clampedA;
615
+ const out = rubberOut(Math.abs(dWorld) * raw.zoom, Math.max(1, dim));
616
+ return clampedA + Math.sign(dWorld) * out / raw.zoom;
617
+ };
618
+ return {
619
+ zoom: raw.zoom,
620
+ x: axis(raw.x, clamped.x, v.width, axisTravels(b.x, b.width, v.width, raw.zoom)),
621
+ y: axis(raw.y, clamped.y, v.height, axisTravels(b.y, b.height, v.height, raw.zoom))
622
+ };
623
+ };
624
+ /** Displayed camera → the raw position `rubberize` would have produced it
625
+ * from. Overshoot is capped just under the asymptote so the inverse stays
626
+ * finite whatever state a catch finds the camera in. */
627
+ const unrubberize = (displayed) => {
628
+ const clamped = _embedpdf_core_stage.clampCamera(displayed, stayBounds(), vp(), constraint());
629
+ const v = vp();
630
+ const b = stayBounds();
631
+ const axis = (dispA, clampedA, dim, travels) => {
632
+ if (!travels) return clampedA;
633
+ const dWorld = dispA - clampedA;
634
+ if (dWorld === 0) return clampedA;
635
+ const d = Math.max(1, dim);
636
+ const out = Math.min(Math.abs(dWorld) * displayed.zoom, d - 1);
637
+ return clampedA + Math.sign(dWorld) * rubberIn(out, d) / displayed.zoom;
638
+ };
639
+ return {
640
+ zoom: displayed.zoom,
641
+ x: axis(displayed.x, clamped.x, v.width, axisTravels(b.x, b.width, v.width, displayed.zoom)),
642
+ y: axis(displayed.y, clamped.y, v.height, axisTravels(b.y, b.height, v.height, displayed.zoom))
643
+ };
644
+ };
645
+ const setCamRaw = (c) => ctx.dispatch({
646
+ type: "CAMERA",
647
+ camera: c
648
+ });
405
649
  /**
406
650
  * Cursor reconciliation, one direction per interaction:
407
651
  * navigation → cursor is INTENT, set explicitly; the camera honors it as far
@@ -435,7 +679,6 @@ function createStageCapability(ctx, config = {}) {
435
679
  }
436
680
  cancelAnim();
437
681
  const from = cam();
438
- const ease = (t) => 1 - Math.pow(1 - t, 3);
439
682
  let started = false;
440
683
  let t0 = 0;
441
684
  const tick = (now) => {
@@ -443,7 +686,7 @@ function createStageCapability(ctx, config = {}) {
443
686
  started = true;
444
687
  t0 = now;
445
688
  }
446
- const k = ease(Math.min(1, (now - t0) / ms));
689
+ const k = easeOutCubic(Math.min(1, (now - t0) / ms));
447
690
  setCam({
448
691
  x: lerp(from.x, target.x, k),
449
692
  y: lerp(from.y, target.y, k),
@@ -457,6 +700,116 @@ function createStageCapability(ctx, config = {}) {
457
700
  };
458
701
  raf = scheduler.raf(tick);
459
702
  };
703
+ /**
704
+ * Zoom-anchored tween — animates THE INVARIANT, not the coordinates. A
705
+ * camera move is defined by what it holds fixed; for an anchored zoom that
706
+ * is "the focal page-point stays at `pt` while scale changes". Lerping
707
+ * x/y/zoom independently (the plain tween) breaks that mid-flight: holding
708
+ * a screen point requires `x(t) = focal.x − pt.x / zoom(t)` — hyperbolic in
709
+ * the zoom — so linear coordinates swing the tapped point along a curved
710
+ * path (the visible "dip"). Here the zoom interpolates GEOMETRICALLY
711
+ * (constant rate — zoom is multiplicative) and each frame's camera derives
712
+ * from the anchor at that zoom, then clamps: the focal point is stationary
713
+ * by construction, and a zoom-out rails into its fit progressively as the
714
+ * shrinking travel range lets the clamp take over.
715
+ */
716
+ const animateZoomAnchored = (focal, pt, targetZoom, ms = 240, then) => {
717
+ if (!canAnimate) {
718
+ const sc = buildScene();
719
+ if (sc.itemCount) setCam(_embedpdf_core_stage.cameraForAnchorAtScreen(focal, sc, pt, targetZoom));
720
+ then?.();
721
+ return;
722
+ }
723
+ cancelAnim();
724
+ const z0 = cam().zoom;
725
+ let started = false;
726
+ let t0 = 0;
727
+ const tick = (now) => {
728
+ if (!started) {
729
+ started = true;
730
+ t0 = now;
731
+ }
732
+ const k = easeOutCubic(Math.min(1, (now - t0) / ms));
733
+ const z = zoomLerp(z0, targetZoom, k);
734
+ const sc = buildScene();
735
+ if (sc.itemCount) setCam(_embedpdf_core_stage.cameraForAnchorAtScreen(focal, sc, pt, z));
736
+ if (k < 1) raf = scheduler.raf(tick);
737
+ else {
738
+ raf = 0;
739
+ then?.();
740
+ }
741
+ };
742
+ raf = scheduler.raf(tick);
743
+ };
744
+ const startCameraPhysics = (vxFinger, vyFinger) => {
745
+ if (!canAnimate) {
746
+ setCam(cam());
747
+ return;
748
+ }
749
+ cancelAnim();
750
+ const zoom = cam().zoom;
751
+ const c0 = cam();
752
+ const cl0 = _embedpdf_core_stage.clampCamera(c0, stayBounds(), vp(), constraint());
753
+ const b0 = stayBounds();
754
+ const v0 = vp();
755
+ const mk = (camA, clampedA, fingerV) => ({
756
+ p: camA * zoom,
757
+ v: -fingerV / 1e3,
758
+ springing: camA !== clampedA,
759
+ done: false
760
+ });
761
+ const ax = mk(c0.x, cl0.x, axisTravels(b0.x, b0.width, v0.width, zoom) ? vxFinger : 0);
762
+ const ay = mk(c0.y, cl0.y, axisTravels(b0.y, b0.height, v0.height, zoom) ? vyFinger : 0);
763
+ if (!ax.springing && !ay.springing && Math.hypot(ax.v, ay.v) < .02) return;
764
+ let started = false;
765
+ let last = 0;
766
+ const tick = (now) => {
767
+ raf = 0;
768
+ const dt = started ? Math.min(64, Math.max(1, now - last)) : 16;
769
+ started = true;
770
+ last = now;
771
+ const cl = _embedpdf_core_stage.clampCamera({
772
+ x: ax.p / zoom,
773
+ y: ay.p / zoom,
774
+ zoom
775
+ }, stayBounds(), vp(), constraint());
776
+ const step = (a, edgeWorld) => {
777
+ if (a.done) return;
778
+ const r = a.springing ? springStep(a.p, a.v, edgeWorld * zoom, dt) : glideStep(a.p, a.v, dt);
779
+ a.p = r.p;
780
+ a.v = r.v;
781
+ a.done = r.done;
782
+ };
783
+ step(ax, cl.x);
784
+ step(ay, cl.y);
785
+ const ncl = _embedpdf_core_stage.clampCamera({
786
+ x: ax.p / zoom,
787
+ y: ay.p / zoom,
788
+ zoom
789
+ }, stayBounds(), vp(), constraint());
790
+ const convert = (a, clampedA) => {
791
+ if (!a.springing && Math.abs(clampedA * zoom - a.p) > 1e-6) {
792
+ a.p = clampedA * zoom;
793
+ a.springing = true;
794
+ a.done = false;
795
+ }
796
+ };
797
+ convert(ax, ncl.x);
798
+ convert(ay, ncl.y);
799
+ setCamRaw({
800
+ x: ax.p / zoom,
801
+ y: ay.p / zoom,
802
+ zoom
803
+ });
804
+ syncCursorFromCamera();
805
+ if (ax.done && ay.done) {
806
+ setCam(cam());
807
+ return;
808
+ }
809
+ raf = scheduler.raf(tick);
810
+ };
811
+ raf = scheduler.raf(tick);
812
+ };
460
813
  const anchorAt = (at) => {
461
814
  const sc = buildScene();
462
815
  return sc.itemCount ? _embedpdf_core_stage.anchorFromCamera(cam(), sc, vp(), at) : {
@@ -584,8 +937,11 @@ function createStageCapability(ctx, config = {}) {
584
937
  } : unrotatedPoints(box);
585
938
  const c = cam();
586
939
  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);
940
+ const rawX = (box.x - c.x) * c.zoom;
941
+ const rawY = (box.y - c.y) * c.zoom;
942
+ const resting = ctx.getState().cameraResting;
943
+ const screenX = resting ? (0, _embedpdf_core_geometry.snapToDevice)(rawX, ratio) : rawX;
944
+ const screenY = resting ? (0, _embedpdf_core_geometry.snapToDevice)(rawY, ratio) : rawY;
589
945
  const transform = (0, _embedpdf_core_geometry.pageTransform)({
590
946
  pageSize,
591
947
  rotation: box.rotation,
@@ -605,7 +961,7 @@ function createStageCapability(ctx, config = {}) {
605
961
  width: transform.viewWidth,
606
962
  height: transform.viewHeight
607
963
  });
608
- const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToPageRect(onScreen) : {
964
+ const visibleRect = onScreen.width > 0 && onScreen.height > 0 ? transform.viewToContentRect(onScreen) : {
609
965
  x: 0,
610
966
  y: 0,
611
967
  width: 0,
@@ -620,8 +976,19 @@ function createStageCapability(ctx, config = {}) {
620
976
  visibleRect
621
977
  };
622
978
  };
979
+ const EMPTY_SCROLL_METRICS = {
980
+ scrollLeft: 0,
981
+ scrollTop: 0,
982
+ scrollWidth: 0,
983
+ scrollHeight: 0,
984
+ clientWidth: 0,
985
+ clientHeight: 0,
986
+ scrollableX: false,
987
+ scrollableY: false
988
+ };
623
989
  let scrollMemo = null;
624
990
  const scrollMetricsNow = () => {
991
+ if (!ctx.getState().placed) return EMPTY_SCROLL_METRICS;
625
992
  const sc = buildScene();
626
993
  const m = _embedpdf_core_stage.scrollMetrics(cam(), sc.itemCount ? stayBounds() : {
627
994
  x: 0,
@@ -634,14 +1001,16 @@ function createStageCapability(ctx, config = {}) {
634
1001
  scrollMemo = m;
635
1002
  return m;
636
1003
  };
1004
+ const EMPTY_VISIBLE_PAGES = [];
637
1005
  let visSig = "";
638
1006
  let vis = [];
639
1007
  const visiblePages = () => {
1008
+ if (!ctx.getState().placed) return EMPTY_VISIBLE_PAGES;
640
1009
  const c = cam();
641
1010
  const v = vp();
642
1011
  const sc = buildScene();
643
1012
  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()}`;
1013
+ const sig = `${sceneCache.key}|${ctx.getState().flow}|${c.x},${c.y},${c.zoom}|${v.width}x${v.height}|${dpr()}|r${ctx.getState().cameraResting ? 1 : 0}`;
645
1014
  if (sig === visSig) return vis;
646
1015
  visSig = sig;
647
1016
  vis = items.flatMap((it) => it.pages).map(withTransform);
@@ -653,8 +1022,73 @@ function createStageCapability(ctx, config = {}) {
653
1022
  return out;
654
1023
  };
655
1024
  const snapshotSettings = () => pickSettings(ctx.getState());
1025
+ /**
1026
+ * Settings change, reacted per the registry — the strongest effect among the
1027
+ * touched settings wins: 'reflow' ⊃ 'scene'/'refit' ⊃ 'reclamp' ⊃ 'none'.
1028
+ * The ONE reactive applier: the public `update()` and the responsive driver
1029
+ * both land here, so a breakpoint crossing gets exactly the invariant a
1030
+ * hand-written update would.
1031
+ */
1032
+ const applySettings = (patch) => {
1033
+ cancelAnim();
1034
+ const touched = (effect) => SETTING_KEYS.some((k) => patch[k] !== void 0 && SETTINGS_EFFECT[k] === effect);
1035
+ const align = touched("refit") && !touched("scene") && !touched("reflow") ? ctx.getState().zoomAlign : ctx.getState().anchorAlign;
1036
+ const anchor = anchorAt(alignPoint(align, vp()));
1037
+ ctx.dispatch({
1038
+ type: "PATCH",
1039
+ patch
1040
+ });
1041
+ if (touched("scene")) sceneCache = null;
1042
+ if (touched("reflow")) goToTarget(ctx.getState().cursor, { behavior: "instant" });
1043
+ else if (touched("scene") || touched("refit")) reapply(anchor, align);
1044
+ else if (touched("reclamp")) setCam(cam());
1045
+ };
1046
+ let base = snapshotSettings();
1047
+ let rules = config.responsive ?? DEFAULT_RESPONSIVE;
1048
+ let lastEffective = base;
1049
+ const publishActive = (active) => {
1050
+ const prev = ctx.getState().activeRules;
1051
+ if (prev.length !== active.length || active.some((n, i) => n !== prev[i])) ctx.dispatch({
1052
+ type: "RESPONSIVE",
1053
+ active
1054
+ });
1055
+ };
1056
+ /**
1057
+ * Re-resolve and apply what CHANGED since the last resolution. `react: true`
1058
+ * routes the patch through `applySettings` (full anchor-preserving update);
1059
+ * `react: false` (inside `setViewport`, whose caller runs its own reframe)
1060
+ * just lands the patch + cache invalidation and reports the strongest
1061
+ * effect so the caller can escalate a rule-driven flow flip.
1062
+ */
1063
+ const syncResponsive = (react) => {
1064
+ const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1065
+ const patch = {};
1066
+ for (const k of SETTING_KEYS) if (!eqSetting(effective[k], lastEffective[k])) Object.assign(patch, { [k]: effective[k] });
1067
+ lastEffective = effective;
1068
+ publishActive(active);
1069
+ const keys = Object.keys(patch);
1070
+ if (keys.length === 0) return "none";
1071
+ if (react) {
1072
+ applySettings(patch);
1073
+ return "none";
1074
+ }
1075
+ ctx.dispatch({
1076
+ type: "PATCH",
1077
+ patch
1078
+ });
1079
+ const rank = {
1080
+ none: 0,
1081
+ reclamp: 1,
1082
+ refit: 2,
1083
+ scene: 3,
1084
+ reflow: 4
1085
+ };
1086
+ let strongest = "none";
1087
+ for (const k of keys) if (rank[SETTINGS_EFFECT[k]] > rank[strongest]) strongest = SETTINGS_EFFECT[k];
1088
+ if (strongest === "scene" || strongest === "reflow") sceneCache = null;
1089
+ return strongest;
1090
+ };
656
1091
  const initialViewProviders = [];
657
- let hasPlaced = false;
658
1092
  const api = {
659
1093
  camera: cam,
660
1094
  viewport: vp,
@@ -672,6 +1106,7 @@ function createStageCapability(ctx, config = {}) {
672
1106
  label: p.label ?? null
673
1107
  })),
674
1108
  pageRect: (pon) => {
1109
+ if (!ctx.getState().placed) return null;
675
1110
  const meta = ctx.document();
676
1111
  const index = meta ? meta.pages.findIndex((p) => p.pageObjectNumber === pon) : -1;
677
1112
  if (index < 0) return null;
@@ -686,7 +1121,7 @@ function createStageCapability(ctx, config = {}) {
686
1121
  const ly = screen.y - p.screenY;
687
1122
  if (lx >= 0 && ly >= 0 && lx <= p.transform.viewWidth && ly <= p.transform.viewHeight) return {
688
1123
  pon: p.pon,
689
- point: p.transform.viewToPage({
1124
+ point: p.transform.viewToContent({
690
1125
  x: lx,
691
1126
  y: ly
692
1127
  }),
@@ -700,7 +1135,7 @@ function createStageCapability(ctx, config = {}) {
700
1135
  pointOnPage: (pon, screen) => {
701
1136
  const p = visiblePages().find((v) => v.pon === pon);
702
1137
  if (!p) return null;
703
- return p.transform.viewToPage({
1138
+ return p.transform.viewToContent({
704
1139
  x: screen.x - p.screenX,
705
1140
  y: screen.y - p.screenY
706
1141
  });
@@ -772,11 +1207,12 @@ function createStageCapability(ctx, config = {}) {
772
1207
  anchor: currentAnchor()
773
1208
  }),
774
1209
  setViewport: (v) => {
775
- if (!hasPlaced) {
1210
+ if (!placementStarted) {
776
1211
  ctx.dispatch({
777
1212
  type: "VP",
778
1213
  vp: v
779
1214
  });
1215
+ syncResponsive(false);
780
1216
  if (v.width > 0 && v.height > 0 && (ctx.document()?.pageCount ?? 0) > 0) api.placeInitial();
781
1217
  return;
782
1218
  }
@@ -786,7 +1222,8 @@ function createStageCapability(ctx, config = {}) {
786
1222
  type: "VP",
787
1223
  vp: v
788
1224
  });
789
- reapply(anchor);
1225
+ if (syncResponsive(false) === "reflow") goToTarget(ctx.getState().cursor, { behavior: "instant" });
1226
+ else reapply(anchor);
790
1227
  },
791
1228
  setDevicePixelRatio: (ratio) => {
792
1229
  if (ratio > 0 && ratio !== dpr()) ctx.dispatch({
@@ -801,7 +1238,10 @@ function createStageCapability(ctx, config = {}) {
801
1238
  },
802
1239
  panBy: (dx, dy) => {
803
1240
  cancelAnim();
804
- setCam(_embedpdf_core_stage.panByScreen(cam(), dx, dy));
1241
+ if (gestureDepth > 0 && gestureElastic) {
1242
+ gestureRaw = _embedpdf_core_stage.panByScreen(gestureRaw ?? unrubberize(cam()), dx, dy);
1243
+ setCamRaw(rubberize(gestureRaw));
1244
+ } else setCam(_embedpdf_core_stage.panByScreen(cam(), dx, dy));
805
1245
  syncCursorFromCamera();
806
1246
  },
807
1247
  scrollTo: (opts) => {
@@ -827,14 +1267,72 @@ function createStageCapability(ctx, config = {}) {
827
1267
  const before = buildScene();
828
1268
  const focal = before.itemCount ? _embedpdf_core_stage.anchorAtPoint(before, _embedpdf_core_stage.toWorld(cam(), pt)) : null;
829
1269
  setCam(_embedpdf_core_stage.zoomAround(cam(), pt, factor));
830
- ctx.dispatch({
1270
+ if (gestureDepth === 0) ctx.dispatch({
831
1271
  type: "PATCH",
832
1272
  patch: { zoom: { level: cam().zoom } }
833
1273
  });
834
1274
  const after = buildScene();
835
1275
  if (after !== before && focal && after.itemCount) setCam(_embedpdf_core_stage.cameraForAnchorAtScreen(focal, after, pt, cam().zoom));
1276
+ if (gestureDepth > 0 && gestureElastic) gestureRaw = unrubberize(cam());
836
1277
  syncCursorFromCamera();
837
1278
  },
1279
+ beginGesture: (options) => {
1280
+ gestureDepth++;
1281
+ if (gestureDepth === 1) {
1282
+ cancelAnim();
1283
+ gestureZoomed = false;
1284
+ gestureElastic = options?.elastic === true;
1285
+ gestureRaw = null;
1286
+ }
1287
+ },
1288
+ endGesture: () => {
1289
+ if (gestureDepth === 0) return;
1290
+ gestureDepth--;
1291
+ if (gestureDepth > 0) return;
1292
+ gestureRaw = null;
1293
+ const wasElastic = gestureElastic;
1294
+ gestureElastic = false;
1295
+ if (gestureZoomed) {
1296
+ gestureZoomed = false;
1297
+ ctx.dispatch({
1298
+ type: "PATCH",
1299
+ patch: { zoom: { level: cam().zoom } }
1300
+ });
1301
+ armRest();
1302
+ }
1303
+ syncCursorFromCamera();
1304
+ if (wasElastic) {
1305
+ const c = cam();
1306
+ const cl = _embedpdf_core_stage.clampCamera(c, stayBounds(), vp(), constraint());
1307
+ if (cl.x !== c.x || cl.y !== c.y) startCameraPhysics(0, 0);
1308
+ }
1309
+ },
1310
+ fling: (vx, vy) => startCameraPhysics(vx, vy),
1311
+ cameraInMotion: () => raf !== 0,
1312
+ doubleTapZoom: (pt) => {
1313
+ cancelAnim();
1314
+ const sc0 = buildScene();
1315
+ if (!sc0.itemCount) return;
1316
+ const item = paged() ? sc0.items[0] : sc0.items[itemIndexOfPage(ctx.getState().cursor)];
1317
+ const fit = fitBox(item);
1318
+ const auto = _embedpdf_core_stage.resolveZoom({ mode: _embedpdf_core_stage.ZoomMode.Automatic }, fit, vp(), pad());
1319
+ const stops = [
1320
+ auto,
1321
+ _embedpdf_core_stage.resolveZoom({ mode: _embedpdf_core_stage.ZoomMode.FitWidth }, fit, vp(), pad()),
1322
+ Math.min(auto * 2.5, _embedpdf_core_stage.ZOOM_MAX)
1323
+ ].sort((a, b) => a - b).filter((z, i, all) => i === 0 || z > all[i - 1] * 1.1);
1324
+ const near = (z, stop) => z > stop / 1.1 && z < stop * 1.1;
1325
+ const onRung = stops.findIndex((s) => near(cam().zoom, s));
1326
+ const target = onRung >= 0 ? stops[(onRung + 1) % stops.length] : stops[0];
1327
+ ctx.dispatch({
1328
+ type: "PATCH",
1329
+ patch: { zoom: { level: target } }
1330
+ });
1331
+ const sc = buildScene();
1332
+ if (!sc.itemCount) return;
1333
+ const focal = _embedpdf_core_stage.anchorAtPoint(sc, _embedpdf_core_stage.toWorld(cam(), pt));
1334
+ animateZoomAnchored(focal, pt, target, 240, syncCursorFromCamera);
1335
+ },
838
1336
  zoomIn: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1.2),
839
1337
  zoomOut: () => api.zoomAround(alignPoint(ctx.getState().zoomAlign, vp()), 1 / 1.2),
840
1338
  zoomTo: (spec) => api.update({ zoom: spec }),
@@ -843,7 +1341,7 @@ function createStageCapability(ctx, config = {}) {
843
1341
  fitAll: () => api.update({ zoom: { mode: _embedpdf_core_stage.ZoomMode.FitAll } }),
844
1342
  automatic: () => api.update({ zoom: { mode: _embedpdf_core_stage.ZoomMode.Automatic } }),
845
1343
  refit: () => {
846
- if (!hasPlaced) return;
1344
+ if (!placementStarted) return;
847
1345
  cancelAnim();
848
1346
  reapply(currentAnchor());
849
1347
  },
@@ -852,7 +1350,7 @@ function createStageCapability(ctx, config = {}) {
852
1350
  const doc = ctx.document();
853
1351
  if (!doc || doc.pageCount === 0) return;
854
1352
  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"))) {
1353
+ if (!(!!opts && (opts.rect != null || opts.anchor !== void 0 || opts.zoom !== void 0 && opts.zoom !== "keep"))) {
856
1354
  if (paged()) {
857
1355
  goToTarget(target, opts);
858
1356
  return;
@@ -918,20 +1416,17 @@ function createStageCapability(ctx, config = {}) {
918
1416
  },
919
1417
  next: (opts) => step(1, opts),
920
1418
  prev: (opts) => step(-1, opts),
1419
+ lensId: () => ctx.id,
921
1420
  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());
1421
+ base = mergeSettings(base, patch);
1422
+ syncResponsive(true);
1423
+ },
1424
+ setResponsive: (next) => {
1425
+ rules = next;
1426
+ syncResponsive(true);
934
1427
  },
1428
+ matches: (name) => ctx.getState().activeRules.includes(name),
1429
+ activeRules: () => ctx.getState().activeRules,
935
1430
  setFlow: (flow) => api.update({ flow }),
936
1431
  setLayout: (layout) => api.update({ layout }),
937
1432
  setSpread: (spread) => api.update({ spread }),
@@ -951,9 +1446,13 @@ function createStageCapability(ctx, config = {}) {
951
1446
  setScrollBehavior: (behavior) => api.update({ scrollBehavior: behavior }),
952
1447
  applyViewState: (view) => {
953
1448
  cancelAnim();
1449
+ base = mergeSettings(base, pickSettings(view));
1450
+ const { effective, active } = resolveResponsive(base, rules, boxOf(vp()));
1451
+ lastEffective = effective;
1452
+ publishActive(active);
954
1453
  ctx.dispatch({
955
1454
  type: "PATCH",
956
- patch: pickSettings(view)
1455
+ patch: effective
957
1456
  });
958
1457
  ctx.dispatch({
959
1458
  type: "CURSOR",
@@ -969,62 +1468,24 @@ function createStageCapability(ctx, config = {}) {
969
1468
  });
970
1469
  },
971
1470
  placeInitial: () => {
972
- hasPlaced = true;
1471
+ placementStarted = true;
973
1472
  const sorted = [...initialViewProviders].sort((a, b) => b.priority - a.priority);
974
1473
  for (const p of sorted) {
975
1474
  const view = p.fn();
976
1475
  if (view) {
977
1476
  api.applyViewState(view);
1477
+ ctx.dispatch({ type: "PLACED" });
978
1478
  return;
979
1479
  }
980
1480
  }
981
1481
  api.resetView();
1482
+ ctx.dispatch({ type: "PLACED" });
982
1483
  },
983
1484
  resetView: () => goToTarget(0, { behavior: "instant" })
984
1485
  };
985
1486
  return api;
986
1487
  }
987
1488
  //#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
1489
  //#region src/effects.ts
1029
1490
  /**
1030
1491
  * The one effect a stage lens needs: when the document's page registry changes
@@ -1047,19 +1508,22 @@ function registerStageEffects(ctx, token) {
1047
1508
  //#endregion
1048
1509
  //#region src/reducer.ts
1049
1510
  const initialStageState = (config) => {
1050
- const { scheduler: _scheduler, ...overrides } = config;
1511
+ const { scheduler: _scheduler, responsive: _responsive, ...overrides } = config;
1051
1512
  return {
1052
1513
  camera: {
1053
1514
  x: 0,
1054
1515
  y: 0,
1055
1516
  zoom: 1
1056
1517
  },
1518
+ placed: false,
1519
+ cameraResting: true,
1057
1520
  vp: {
1058
1521
  width: 0,
1059
1522
  height: 0
1060
1523
  },
1061
1524
  dpr: 1,
1062
1525
  cursor: 0,
1526
+ activeRules: [],
1063
1527
  ...DEFAULT_SETTINGS,
1064
1528
  ...overrides
1065
1529
  };
@@ -1085,6 +1549,14 @@ const stageReducer = (state, a) => {
1085
1549
  ...state,
1086
1550
  camera: a.camera
1087
1551
  };
1552
+ case "CAMERA_REST": return state.cameraResting === a.resting ? state : {
1553
+ ...state,
1554
+ cameraResting: a.resting
1555
+ };
1556
+ case "PLACED": return state.placed ? state : {
1557
+ ...state,
1558
+ placed: true
1559
+ };
1088
1560
  case "VP": return {
1089
1561
  ...state,
1090
1562
  vp: a.vp
@@ -1098,6 +1570,10 @@ const stageReducer = (state, a) => {
1098
1570
  cursor: a.cursor
1099
1571
  };
1100
1572
  case "PATCH": return applyPatch(state, a.patch);
1573
+ case "RESPONSIVE": return {
1574
+ ...state,
1575
+ activeRules: a.active
1576
+ };
1101
1577
  default: return state;
1102
1578
  }
1103
1579
  };
@@ -1112,19 +1588,14 @@ const StageToken = (0, _embedpdf_core.createCapabilityToken)("stage");
1112
1588
  * sibling files.
1113
1589
  */
1114
1590
  const stagePlugin = (options = {}) => {
1115
- const { id = "stage", token = StageToken, interaction = false, panFallback = true, ...config } = options;
1591
+ const { id = "stage", token = StageToken, ...config } = options;
1116
1592
  return (0, _embedpdf_core.definePlugin)({
1117
1593
  id,
1118
1594
  token,
1119
1595
  scope: "document",
1120
- optional: interaction ? [_embedpdf_plugin_interaction.InteractionToken] : void 0,
1121
1596
  initialState: () => initialStageState(config),
1122
1597
  reduce: stageReducer,
1123
1598
  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
1599
  effects: (ctx) => registerStageEffects(ctx, token)
1129
1600
  });
1130
1601
  };
@@ -1271,19 +1742,47 @@ function destinationToReveal(dest, layout, bbox) {
1271
1742
  };
1272
1743
  }
1273
1744
  //#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);
1745
+ //#region src/scroll-handler.ts
1746
+ /**
1747
+ * Pan-the-camera as an interaction handler. Live under the built-in `pan` tool
1748
+ * (the `scroll` tag) and — with `panFallback` — under every tool, where it then
1749
+ * captures ONLY page-gap drags (on-page downs belong to the active tool, which
1750
+ * claims them first at higher priority). It uses the sample's viewport delta, so
1751
+ * it pans over the whole viewport (pages and gaps).
1752
+ *
1753
+ * Cursor feel: a closed hand (`grabbing`) while dragging; an open hand (`grab`)
1754
+ * hovering a gap under a non-pan tool. The pan tool's own resting `grab` comes
1755
+ * from its `Tool.cursor`, so hovering it needs no claim here.
1756
+ */
1757
+ function createScrollHandler(stage, interaction, options = {}) {
1758
+ const panFallback = options.panFallback ?? true;
1759
+ let last = {
1760
+ x: 0,
1761
+ y: 0
1762
+ };
1763
+ const isPanTool = () => interaction.activeTool().enables.has("scroll");
1764
+ return {
1765
+ id: "stage-scroll",
1766
+ priority: 10,
1767
+ enabledFor: (tool) => tool.enables.has("scroll") || panFallback,
1768
+ onDown: (s) => {
1769
+ if (!isPanTool() && s.page) return false;
1770
+ last = s.viewport;
1771
+ interaction.setCursor("stage-grab", "grabbing", 40);
1772
+ return true;
1773
+ },
1774
+ onMove: (s) => {
1775
+ stage.panBy(s.viewport.x - last.x, s.viewport.y - last.y);
1776
+ last = s.viewport;
1777
+ },
1778
+ onUp: () => interaction.setCursor("stage-grab", null),
1779
+ onHover: (s) => {
1780
+ if (panFallback && !isPanTool()) interaction.setCursor("stage-pan-fallback", s.page ? null : "grab", 5);
1781
+ }
1782
+ };
1285
1783
  }
1286
1784
  //#endregion
1785
+ exports.DEFAULT_RESPONSIVE = DEFAULT_RESPONSIVE;
1287
1786
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
1288
1787
  exports.StageToken = StageToken;
1289
1788
  Object.defineProperty(exports, "ZoomMode", {
@@ -1292,9 +1791,12 @@ Object.defineProperty(exports, "ZoomMode", {
1292
1791
  return _embedpdf_core_stage.ZoomMode;
1293
1792
  }
1294
1793
  });
1794
+ exports.boxOf = boxOf;
1795
+ exports.createScrollHandler = createScrollHandler;
1295
1796
  exports.destinationToReveal = destinationToReveal;
1797
+ exports.matchesQuery = matchesQuery;
1798
+ exports.resolveResponsive = resolveResponsive;
1296
1799
  exports.settingsEqual = settingsEqual;
1297
1800
  exports.stagePlugin = stagePlugin;
1298
- exports.wheelZoomFactor = wheelZoomFactor;
1299
1801
 
1300
1802
  //# sourceMappingURL=index.cjs.map