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