@docmentis/udoc-viewer 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -15
- package/dist/package.json +1 -1
- package/dist/src/UDocViewer.d.ts.map +1 -1
- package/dist/src/UDocViewer.js +24 -5
- package/dist/src/UDocViewer.js.map +1 -1
- package/dist/src/fonts.d.ts +93 -0
- package/dist/src/fonts.d.ts.map +1 -0
- package/dist/src/fonts.js +211 -0
- package/dist/src/fonts.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/performance/PerformanceCounter.d.ts +1 -1
- package/dist/src/performance/PerformanceCounter.d.ts.map +1 -1
- package/dist/src/performance/PerformanceCounter.js.map +1 -1
- package/dist/src/ui/viewer/components/Viewport.d.ts.map +1 -1
- package/dist/src/ui/viewer/components/Viewport.js +322 -86
- package/dist/src/ui/viewer/components/Viewport.js.map +1 -1
- package/dist/src/ui/viewer/styles-inline.d.ts +1 -1
- package/dist/src/ui/viewer/styles-inline.d.ts.map +1 -1
- package/dist/src/ui/viewer/styles-inline.js +2 -27
- package/dist/src/ui/viewer/styles-inline.js.map +1 -1
- package/dist/src/ui/viewer/text/render.d.ts.map +1 -1
- package/dist/src/ui/viewer/text/render.js +5 -0
- package/dist/src/ui/viewer/text/render.js.map +1 -1
- package/dist/src/wasm/udoc.d.ts +105 -0
- package/dist/src/wasm/udoc.js +209 -0
- package/dist/src/wasm/udoc_bg.wasm +0 -0
- package/dist/src/wasm/udoc_bg.wasm.d.ts +5 -0
- package/dist/src/worker/WorkerClient.d.ts +51 -2
- package/dist/src/worker/WorkerClient.d.ts.map +1 -1
- package/dist/src/worker/WorkerClient.js +100 -0
- package/dist/src/worker/WorkerClient.js.map +1 -1
- package/dist/src/worker/index.d.ts +1 -1
- package/dist/src/worker/index.d.ts.map +1 -1
- package/dist/src/worker/worker.d.ts +70 -0
- package/dist/src/worker/worker.d.ts.map +1 -1
- package/dist/src/worker/worker.js +30 -0
- package/dist/src/worker/worker.js.map +1 -1
- package/package.json +1 -1
|
@@ -112,7 +112,21 @@ function getScrollbarSize() {
|
|
|
112
112
|
cachedScrollbarSize = { width, height };
|
|
113
113
|
return cachedScrollbarSize;
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Computes the zoom scale for fit modes.
|
|
117
|
+
*
|
|
118
|
+
* For fit-spread-width mode, we predict whether a vertical scrollbar will be needed
|
|
119
|
+
* and adjust the available width accordingly. This prevents the feedback loop where:
|
|
120
|
+
* 1. Scale is calculated based on viewport width
|
|
121
|
+
* 2. Content is taller than viewport, scrollbar appears
|
|
122
|
+
* 3. Scrollbar reduces viewport width, causing recalculation
|
|
123
|
+
*
|
|
124
|
+
* @param slice - Viewport state slice
|
|
125
|
+
* @param metrics - Current viewport metrics
|
|
126
|
+
* @param spreads - Array of spreads to layout
|
|
127
|
+
* @param scrollbarVisible - Whether vertical scrollbar is currently visible
|
|
128
|
+
*/
|
|
129
|
+
function computeScale(slice, metrics, spreads, scrollbarVisible) {
|
|
116
130
|
if (slice.zoomMode === "custom")
|
|
117
131
|
return slice.zoom;
|
|
118
132
|
if (metrics.innerWidth <= 0 || metrics.innerHeight <= 0)
|
|
@@ -132,7 +146,13 @@ function computeScale(slice, metrics, spreads) {
|
|
|
132
146
|
// Round to the nearest device pixel to avoid scrollbar feedback loops.
|
|
133
147
|
const snappedSpacing = snapToDevice(slice.spreadSpacing);
|
|
134
148
|
const snappedViewportHeight = snapToDevice(metrics.innerHeight);
|
|
135
|
-
const
|
|
149
|
+
const scrollbar = getScrollbarSize();
|
|
150
|
+
// Calculate the "base" viewport width (without scrollbar).
|
|
151
|
+
// If scrollbar is currently visible, metrics.innerWidth already excludes it,
|
|
152
|
+
// so we add it back to get the full available width.
|
|
153
|
+
const baseViewportWidth = scrollbarVisible
|
|
154
|
+
? snapToDevice(metrics.innerWidth + scrollbar.width)
|
|
155
|
+
: snapToDevice(metrics.innerWidth);
|
|
136
156
|
const verticalSpacing = snappedSpacing * 2;
|
|
137
157
|
// Add 1 pixel buffer to compensate for device pixel rounding in layout calculations.
|
|
138
158
|
// Without this, the spread may be slightly smaller than the viewport, allowing
|
|
@@ -140,22 +160,51 @@ function computeScale(slice, metrics, spreads) {
|
|
|
140
160
|
// The same buffer is used in spread mode for consistency when switching modes.
|
|
141
161
|
// Scrollbar overflow in spread mode is handled by applySingleLayout using viewport height.
|
|
142
162
|
const targetHeight = Math.max(0, snappedViewportHeight - verticalSpacing + 1);
|
|
143
|
-
const widthScale = snappedViewportWidth / maxWidth;
|
|
144
|
-
const heightScale = targetHeight / maxHeight;
|
|
145
163
|
switch (slice.zoomMode) {
|
|
146
|
-
case "fit-spread-width":
|
|
147
|
-
|
|
164
|
+
case "fit-spread-width": {
|
|
165
|
+
// Calculate scale assuming full viewport width (no scrollbar)
|
|
166
|
+
const fullWidthScale = baseViewportWidth / maxWidth;
|
|
167
|
+
// Predict if vertical scrollbar will be needed at this scale.
|
|
168
|
+
// For continuous mode: check if total content height exceeds viewport.
|
|
169
|
+
// For spread mode: check if tallest spread + spacing exceeds viewport.
|
|
170
|
+
let needsScrollbar;
|
|
171
|
+
if (slice.scrollMode === "continuous") {
|
|
172
|
+
// In continuous mode, calculate total content height
|
|
173
|
+
// Total height = sum of all spread heights + spacing between spreads
|
|
174
|
+
let totalHeight = 0;
|
|
175
|
+
for (const spread of spreads) {
|
|
176
|
+
const dims = getSpreadDimensions(spread, slice.pageInfos, fullWidthScale, slice.pageSpacing, slice.dpi, slice.pageRotation);
|
|
177
|
+
totalHeight += dims.height;
|
|
178
|
+
}
|
|
179
|
+
totalHeight += snappedSpacing * (spreads.length + 1); // spacing before first, between, and after last
|
|
180
|
+
needsScrollbar = totalHeight > snappedViewportHeight;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// In spread mode, check if the tallest spread + spacing exceeds viewport
|
|
184
|
+
const scaledMaxHeight = maxHeight * fullWidthScale;
|
|
185
|
+
needsScrollbar = scaledMaxHeight + verticalSpacing > snappedViewportHeight;
|
|
186
|
+
}
|
|
187
|
+
if (needsScrollbar && scrollbar.width > 0) {
|
|
188
|
+
// Scrollbar will be needed, calculate scale with reduced viewport width
|
|
189
|
+
const adjustedWidth = baseViewportWidth - scrollbar.width;
|
|
190
|
+
return adjustedWidth / maxWidth;
|
|
191
|
+
}
|
|
192
|
+
// No scrollbar needed, use full width
|
|
193
|
+
return fullWidthScale;
|
|
194
|
+
}
|
|
148
195
|
case "fit-spread-height":
|
|
149
|
-
|
|
196
|
+
// Content fits height by design, no vertical scrollbar needed
|
|
197
|
+
return targetHeight / maxHeight;
|
|
150
198
|
case "fit-spread":
|
|
151
|
-
|
|
199
|
+
// Content fits both dimensions by design, no scrollbar needed
|
|
200
|
+
return Math.min(baseViewportWidth / maxWidth, targetHeight / maxHeight);
|
|
152
201
|
default:
|
|
153
202
|
return slice.zoom;
|
|
154
203
|
}
|
|
155
204
|
}
|
|
156
|
-
function buildLayout(slice, metrics) {
|
|
205
|
+
function buildLayout(slice, metrics, scrollbarVisible) {
|
|
157
206
|
const spreads = calculateSpreads(slice.pageCount, slice.layoutMode);
|
|
158
|
-
const scale = computeScale(slice, metrics, spreads);
|
|
207
|
+
const scale = computeScale(slice, metrics, spreads, scrollbarVisible);
|
|
159
208
|
const layout = calculateSpreadLayouts(spreads, slice.pageInfos, scale, slice.pageSpacing, slice.spreadSpacing, slice.dpi, slice.pageRotation);
|
|
160
209
|
return {
|
|
161
210
|
spreads,
|
|
@@ -165,12 +214,13 @@ function buildLayout(slice, metrics) {
|
|
|
165
214
|
scale
|
|
166
215
|
};
|
|
167
216
|
}
|
|
168
|
-
function computeViewportUpdate(prevSlice, nextSlice, prevMetrics, nextMetrics
|
|
217
|
+
function computeViewportUpdate(prevSlice, nextSlice, prevMetrics, nextMetrics) {
|
|
169
218
|
const metricsChanged = !prevMetrics || !metricsEqual(prevMetrics, nextMetrics);
|
|
170
219
|
const layoutChanged = !prevSlice ||
|
|
171
220
|
nextSlice.docId !== prevSlice.docId ||
|
|
172
221
|
nextSlice.pageCount !== prevSlice.pageCount ||
|
|
173
222
|
nextSlice.pageInfos !== prevSlice.pageInfos ||
|
|
223
|
+
nextSlice.scrollMode !== prevSlice.scrollMode ||
|
|
174
224
|
nextSlice.layoutMode !== prevSlice.layoutMode ||
|
|
175
225
|
nextSlice.zoomMode !== prevSlice.zoomMode ||
|
|
176
226
|
nextSlice.zoom !== prevSlice.zoom ||
|
|
@@ -179,32 +229,23 @@ function computeViewportUpdate(prevSlice, nextSlice, prevMetrics, nextMetrics, l
|
|
|
179
229
|
nextSlice.pageSpacing !== prevSlice.pageSpacing ||
|
|
180
230
|
nextSlice.spreadSpacing !== prevSlice.spreadSpacing ||
|
|
181
231
|
metricsChanged;
|
|
182
|
-
const modeChanged = !prevSlice || nextSlice.scrollMode !== prevSlice.scrollMode;
|
|
183
232
|
const zoomModeChanged = !prevSlice || nextSlice.zoomMode !== prevSlice.zoomMode;
|
|
184
|
-
const fitZoomChanged = nextSlice.zoomMode !== "custom" && (!prevSlice || nextSlice.zoom !== prevSlice.zoom);
|
|
185
233
|
const spreadsChanged = !prevSlice ||
|
|
186
234
|
nextSlice.docId !== prevSlice.docId ||
|
|
187
235
|
nextSlice.pageCount !== prevSlice.pageCount ||
|
|
188
236
|
nextSlice.pageInfos !== prevSlice.pageInfos ||
|
|
189
237
|
nextSlice.layoutMode !== prevSlice.layoutMode;
|
|
190
238
|
const shouldClearSpreads = layoutChanged && spreadsChanged;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const shouldCenterOnModeChange = modeChanged && lastScrollMode !== null && lastScrollMode !== "continuous";
|
|
197
|
-
const shouldScrollToPage = spreadsChanged || zoomModeChanged || fitZoomChanged;
|
|
198
|
-
const shouldResetSingleScroll = (modeChanged && lastScrollMode === "continuous") ||
|
|
199
|
-
spreadsChanged ||
|
|
200
|
-
(layoutChanged && nextSlice.zoomMode !== "custom");
|
|
239
|
+
// Scroll to page on document change or zoom mode change (explicit user actions)
|
|
240
|
+
const shouldScrollToPage = spreadsChanged || zoomModeChanged;
|
|
241
|
+
// Restore tracked viewport position when layout changes but document structure is the same
|
|
242
|
+
// This maintains scroll position across mode switches, zoom changes, and viewport resizes
|
|
243
|
+
const shouldRestorePosition = layoutChanged && !spreadsChanged && !zoomModeChanged;
|
|
201
244
|
return {
|
|
202
245
|
layoutChanged,
|
|
203
246
|
shouldClearSpreads,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
shouldScrollToPage,
|
|
207
|
-
shouldResetSingleScroll
|
|
247
|
+
shouldRestorePosition,
|
|
248
|
+
shouldScrollToPage
|
|
208
249
|
};
|
|
209
250
|
}
|
|
210
251
|
export function createViewport() {
|
|
@@ -216,13 +257,118 @@ export function createViewport() {
|
|
|
216
257
|
const container = document.createElement("div");
|
|
217
258
|
container.className = "udoc-viewport__container";
|
|
218
259
|
scrollArea.appendChild(container);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
260
|
+
// Watermark with tamper protection - random class name on each instantiation
|
|
261
|
+
const wmClass = "_" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
|
|
262
|
+
const wmHref = "https://docmentis.com";
|
|
263
|
+
const wmText = "Powered by docMentis";
|
|
264
|
+
const wmAttrs = { target: "_blank", rel: "noopener" };
|
|
265
|
+
// Inject dynamic styles for the random class name
|
|
266
|
+
const wmStyle = document.createElement("style");
|
|
267
|
+
wmStyle.textContent = `
|
|
268
|
+
.${wmClass} {
|
|
269
|
+
position: absolute;
|
|
270
|
+
right: 18px;
|
|
271
|
+
bottom: 4px;
|
|
272
|
+
padding: 2px 6px;
|
|
273
|
+
font-size: 12px;
|
|
274
|
+
font-weight: 500;
|
|
275
|
+
color: rgba(0, 0, 0, 0.3);
|
|
276
|
+
text-decoration: none;
|
|
277
|
+
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
|
|
278
|
+
z-index: 10;
|
|
279
|
+
transition: color 0.15s ease;
|
|
280
|
+
}
|
|
281
|
+
.${wmClass}:hover {
|
|
282
|
+
color: rgba(0, 0, 0, 0.6);
|
|
283
|
+
}
|
|
284
|
+
@media (max-width: 768px) {
|
|
285
|
+
.${wmClass} {
|
|
286
|
+
bottom: 48px;
|
|
287
|
+
right: 10px;
|
|
288
|
+
font-size: 11px;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
`;
|
|
292
|
+
el.appendChild(wmStyle);
|
|
293
|
+
function createWatermark() {
|
|
294
|
+
const wm = document.createElement("a");
|
|
295
|
+
wm.className = wmClass;
|
|
296
|
+
wm.href = wmHref;
|
|
297
|
+
wm.target = wmAttrs.target;
|
|
298
|
+
wm.rel = wmAttrs.rel;
|
|
299
|
+
wm.textContent = wmText;
|
|
300
|
+
return wm;
|
|
301
|
+
}
|
|
302
|
+
let watermark = createWatermark();
|
|
225
303
|
el.appendChild(watermark);
|
|
304
|
+
// Protect watermark against removal and modification
|
|
305
|
+
const wmObserver = new MutationObserver((mutations) => {
|
|
306
|
+
let needsRestore = false;
|
|
307
|
+
// Check if watermark was removed from DOM
|
|
308
|
+
if (!el.contains(watermark)) {
|
|
309
|
+
needsRestore = true;
|
|
310
|
+
}
|
|
311
|
+
// Check if style element was removed
|
|
312
|
+
if (!el.contains(wmStyle)) {
|
|
313
|
+
el.appendChild(wmStyle);
|
|
314
|
+
}
|
|
315
|
+
// Check for attribute tampering on the watermark itself
|
|
316
|
+
for (const mutation of mutations) {
|
|
317
|
+
if (mutation.target === watermark) {
|
|
318
|
+
if (mutation.type === "attributes") {
|
|
319
|
+
needsRestore = true;
|
|
320
|
+
}
|
|
321
|
+
else if (mutation.type === "characterData" || mutation.type === "childList") {
|
|
322
|
+
needsRestore = true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Check if watermark's text content was changed
|
|
326
|
+
if (mutation.target.parentNode === watermark && mutation.type === "characterData") {
|
|
327
|
+
needsRestore = true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (needsRestore) {
|
|
331
|
+
// Remove old watermark if still in DOM but corrupted
|
|
332
|
+
if (el.contains(watermark)) {
|
|
333
|
+
watermark.remove();
|
|
334
|
+
}
|
|
335
|
+
// Create fresh watermark
|
|
336
|
+
watermark = createWatermark();
|
|
337
|
+
el.appendChild(watermark);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// Observe the parent for child removal and the watermark for attribute/content changes
|
|
341
|
+
wmObserver.observe(el, { childList: true, subtree: false });
|
|
342
|
+
wmObserver.observe(watermark, {
|
|
343
|
+
attributes: true,
|
|
344
|
+
childList: true,
|
|
345
|
+
characterData: true,
|
|
346
|
+
subtree: true
|
|
347
|
+
});
|
|
348
|
+
// Periodic integrity check (catches CSS-based hiding)
|
|
349
|
+
const wmIntegrityCheck = setInterval(() => {
|
|
350
|
+
// Restore style element if removed
|
|
351
|
+
if (!el.contains(wmStyle)) {
|
|
352
|
+
el.appendChild(wmStyle);
|
|
353
|
+
}
|
|
354
|
+
// Restore watermark if removed
|
|
355
|
+
if (!el.contains(watermark)) {
|
|
356
|
+
watermark = createWatermark();
|
|
357
|
+
el.appendChild(watermark);
|
|
358
|
+
wmObserver.observe(watermark, {
|
|
359
|
+
attributes: true,
|
|
360
|
+
childList: true,
|
|
361
|
+
characterData: true,
|
|
362
|
+
subtree: true
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
// Reset any inline style tampering
|
|
366
|
+
watermark.style.cssText = "";
|
|
367
|
+
watermark.removeAttribute("hidden");
|
|
368
|
+
if (watermark.className !== wmClass) {
|
|
369
|
+
watermark.className = wmClass;
|
|
370
|
+
}
|
|
371
|
+
}, 1000);
|
|
226
372
|
const floatingToolbar = createFloatingToolbar();
|
|
227
373
|
let workerClient = null;
|
|
228
374
|
let storeRef = null;
|
|
@@ -238,13 +384,15 @@ export function createViewport() {
|
|
|
238
384
|
let layoutDirty = false;
|
|
239
385
|
let lastVisibleRange = { start: 0, end: -1 };
|
|
240
386
|
let containerSize = { width: 0, height: 0 };
|
|
241
|
-
let lastScrollMode = null;
|
|
242
387
|
let lastOverflowX = null;
|
|
243
388
|
let lastOverflowY = null;
|
|
389
|
+
// Track the document position at viewport top edge for consistent positioning across mode switches
|
|
390
|
+
let viewportTopPosition = null;
|
|
244
391
|
let updateRaf = 0;
|
|
245
392
|
let scrollRaf = 0;
|
|
246
393
|
let renderDebounceTimer = 0;
|
|
247
394
|
let rendersPaused = false;
|
|
395
|
+
let resumeRenderAfterResize = false;
|
|
248
396
|
const RENDER_DEBOUNCE_MS = 50;
|
|
249
397
|
const unsubEvents = [];
|
|
250
398
|
function mount(parent, store, wc) {
|
|
@@ -261,7 +409,7 @@ export function createViewport() {
|
|
|
261
409
|
equality: viewportSliceEqual
|
|
262
410
|
});
|
|
263
411
|
unsubScroll = on(scrollArea, "scroll", () => {
|
|
264
|
-
if (!currentSlice ||
|
|
412
|
+
if (!currentSlice || !layoutState)
|
|
265
413
|
return;
|
|
266
414
|
if (scrollRaf)
|
|
267
415
|
return;
|
|
@@ -269,14 +417,22 @@ export function createViewport() {
|
|
|
269
417
|
scrollRaf = 0;
|
|
270
418
|
if (!currentSlice || !layoutState || !lastMetrics)
|
|
271
419
|
return;
|
|
272
|
-
|
|
420
|
+
if (currentSlice.scrollMode === "continuous") {
|
|
421
|
+
updateVisibleSpreads(currentSlice, lastMetrics, layoutState);
|
|
422
|
+
}
|
|
423
|
+
// Keep viewport position tracking up to date on scroll (both modes)
|
|
424
|
+
updateViewportTopPosition(currentSlice, layoutState);
|
|
273
425
|
});
|
|
274
426
|
});
|
|
275
|
-
// Resize handler: layout updates immediately, but renders are
|
|
276
|
-
//
|
|
427
|
+
// Resize handler: layout updates immediately, but renders are only paused
|
|
428
|
+
// when resize events keep firing (e.g. dragging panel width). This avoids
|
|
429
|
+
// a one-off "flash" when panels are simply toggled.
|
|
277
430
|
const handleResize = () => {
|
|
278
|
-
|
|
279
|
-
|
|
431
|
+
const isOngoingResize = renderDebounceTimer !== 0;
|
|
432
|
+
if (isOngoingResize) {
|
|
433
|
+
rendersPaused = true;
|
|
434
|
+
resumeRenderAfterResize = true;
|
|
435
|
+
}
|
|
280
436
|
// Clear any pending render timer
|
|
281
437
|
if (renderDebounceTimer) {
|
|
282
438
|
clearTimeout(renderDebounceTimer);
|
|
@@ -284,9 +440,12 @@ export function createViewport() {
|
|
|
284
440
|
// Schedule render after resize settles
|
|
285
441
|
renderDebounceTimer = window.setTimeout(() => {
|
|
286
442
|
renderDebounceTimer = 0;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
443
|
+
if (resumeRenderAfterResize) {
|
|
444
|
+
resumeRenderAfterResize = false;
|
|
445
|
+
rendersPaused = false;
|
|
446
|
+
// Trigger re-render of visible spreads
|
|
447
|
+
scheduleUpdate();
|
|
448
|
+
}
|
|
290
449
|
}, RENDER_DEBOUNCE_MS);
|
|
291
450
|
// Layout updates immediately
|
|
292
451
|
scheduleUpdate();
|
|
@@ -420,57 +579,71 @@ export function createViewport() {
|
|
|
420
579
|
scrollArea.style.overflowY = "hidden";
|
|
421
580
|
layoutState = null;
|
|
422
581
|
lastVisibleRange = { start: 0, end: -1 };
|
|
582
|
+
viewportTopPosition = null;
|
|
423
583
|
syncEffectiveZoom(slice, null);
|
|
424
584
|
lastSlice = slice;
|
|
425
585
|
lastMetrics = metrics;
|
|
426
|
-
lastScrollMode = slice.scrollMode;
|
|
427
586
|
return;
|
|
428
587
|
}
|
|
429
|
-
const plan = computeViewportUpdate(lastSlice, slice, lastMetrics, metrics
|
|
588
|
+
const plan = computeViewportUpdate(lastSlice, slice, lastMetrics, metrics);
|
|
430
589
|
if (plan.shouldClearSpreads) {
|
|
431
590
|
clearSpreads();
|
|
432
591
|
lastVisibleRange = { start: 0, end: -1 };
|
|
592
|
+
viewportTopPosition = null;
|
|
433
593
|
}
|
|
434
|
-
const anchor = (plan.shouldCaptureAnchor && layoutState)
|
|
435
|
-
? captureScrollAnchor(metrics, layoutState)
|
|
436
|
-
: null;
|
|
437
594
|
if (plan.layoutChanged || !layoutState) {
|
|
438
|
-
layoutState = buildLayout(slice, metrics);
|
|
595
|
+
layoutState = buildLayout(slice, metrics, lastOverflowY ?? false);
|
|
439
596
|
layoutDirty = true;
|
|
440
|
-
// Only update lastMetrics when layout rebuilds, so small changes accumulate
|
|
441
|
-
// until they exceed the epsilon threshold (important for fit-page modes)
|
|
442
597
|
lastMetrics = metrics;
|
|
443
598
|
}
|
|
444
599
|
syncEffectiveZoom(slice, layoutState);
|
|
445
600
|
if (slice.scrollMode === "continuous") {
|
|
446
601
|
applyContinuousLayout(metrics, layoutState);
|
|
447
|
-
if (plan.
|
|
448
|
-
// When switching from single to continuous, center the page to maintain position
|
|
449
|
-
scrollToPage(slice.page, metrics, true);
|
|
450
|
-
}
|
|
451
|
-
else if (plan.shouldScrollToPage) {
|
|
602
|
+
if (plan.shouldScrollToPage) {
|
|
452
603
|
scrollToPage(slice.page, metrics);
|
|
453
604
|
}
|
|
454
|
-
else if (
|
|
455
|
-
|
|
605
|
+
else if (plan.shouldRestorePosition && viewportTopPosition) {
|
|
606
|
+
restoreViewportPosition(viewportTopPosition, metrics, layoutState);
|
|
456
607
|
}
|
|
457
608
|
updateVisibleSpreads(slice, metrics, layoutState);
|
|
458
609
|
}
|
|
459
610
|
else {
|
|
460
611
|
applySingleLayout(slice, metrics, layoutState);
|
|
461
|
-
if (plan.
|
|
612
|
+
if (plan.shouldScrollToPage) {
|
|
462
613
|
scrollArea.scrollTop = 0;
|
|
463
614
|
scrollArea.scrollLeft = 0;
|
|
464
|
-
|
|
465
|
-
|
|
615
|
+
lastOverflowX = null;
|
|
616
|
+
lastOverflowY = null;
|
|
617
|
+
}
|
|
618
|
+
else if (plan.shouldRestorePosition && viewportTopPosition) {
|
|
619
|
+
// In spread mode, restore position by showing the correct page
|
|
620
|
+
const targetPage = viewportTopPosition.page;
|
|
621
|
+
if (slice.page !== targetPage && storeRef) {
|
|
622
|
+
storeRef.dispatch({ type: "SET_PAGE", page: targetPage });
|
|
623
|
+
}
|
|
624
|
+
// Reset scroll and try to restore position within the spread if it's scrollable
|
|
625
|
+
scrollArea.scrollLeft = 0;
|
|
626
|
+
const spreadIndex = findSpreadForPage(layoutState.spreads, targetPage);
|
|
627
|
+
const layout = layoutState.layouts[spreadIndex];
|
|
628
|
+
if (layout && layout.height > metrics.innerHeight && !viewportTopPosition.inSpacing) {
|
|
629
|
+
// Spread is larger than viewport and we have a content offset
|
|
630
|
+
// Calculate scroll position to show the same relative position
|
|
631
|
+
const spreadTopInContainer = getCenteredOffset(containerSize.height, layout.height);
|
|
632
|
+
const offsetPixels = viewportTopPosition.offset * layout.height;
|
|
633
|
+
scrollArea.scrollTop = Math.max(0, spreadTopInContainer + offsetPixels);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
scrollArea.scrollTop = 0;
|
|
637
|
+
}
|
|
466
638
|
lastOverflowX = null;
|
|
467
639
|
lastOverflowY = null;
|
|
468
640
|
}
|
|
469
641
|
showSingleSpread(slice, metrics, layoutState);
|
|
470
642
|
}
|
|
471
643
|
updateOverflow(slice, metrics);
|
|
644
|
+
// Update tracked viewport position after layout is applied
|
|
645
|
+
updateViewportTopPosition(slice, layoutState);
|
|
472
646
|
lastSlice = slice;
|
|
473
|
-
lastScrollMode = slice.scrollMode;
|
|
474
647
|
}
|
|
475
648
|
function updateOverflow(slice, metrics) {
|
|
476
649
|
const epsilon = 1;
|
|
@@ -528,33 +701,89 @@ export function createViewport() {
|
|
|
528
701
|
return;
|
|
529
702
|
storeRef.dispatch({ type: "SET_EFFECTIVE_ZOOM", zoom: nextZoom });
|
|
530
703
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
return null;
|
|
539
|
-
const spreadIndex = findSpreadForPage(state.spreads, focusPage);
|
|
540
|
-
const layout = state.layouts[spreadIndex];
|
|
541
|
-
if (!layout)
|
|
542
|
-
return null;
|
|
543
|
-
const spreadCenter = layout.top + layout.height / 2;
|
|
544
|
-
return {
|
|
545
|
-
page: focusPage,
|
|
546
|
-
offset: spreadCenter - scrollTop
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
function restoreScrollAnchor(anchor, metrics) {
|
|
550
|
-
if (!layoutState)
|
|
704
|
+
/**
|
|
705
|
+
* Updates the tracked viewport top position based on current scroll state.
|
|
706
|
+
* This captures which page is at the viewport top and how far into it we are.
|
|
707
|
+
*/
|
|
708
|
+
function updateViewportTopPosition(slice, state) {
|
|
709
|
+
if (state.layouts.length === 0 || state.spreads.length === 0) {
|
|
710
|
+
viewportTopPosition = null;
|
|
551
711
|
return;
|
|
552
|
-
|
|
553
|
-
|
|
712
|
+
}
|
|
713
|
+
let viewportTopInLayout;
|
|
714
|
+
if (slice.scrollMode === "continuous") {
|
|
715
|
+
// In continuous mode, viewport top position is directly from scrollTop
|
|
716
|
+
viewportTopInLayout = scrollArea.scrollTop;
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
// In spread mode, the spread is centered in the viewport
|
|
720
|
+
// Calculate where the viewport top would be in layout coordinates
|
|
721
|
+
const spreadIndex = findSpreadForPage(state.spreads, slice.page);
|
|
722
|
+
const layout = state.layouts[spreadIndex];
|
|
723
|
+
if (!layout) {
|
|
724
|
+
viewportTopPosition = null;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// The spread is positioned at getCenteredOffset from container top
|
|
728
|
+
const spreadTopInContainer = getCenteredOffset(containerSize.height, layout.height);
|
|
729
|
+
// Account for any scroll within spread mode (for large spreads)
|
|
730
|
+
const viewportTopInContainer = scrollArea.scrollTop;
|
|
731
|
+
// Map to layout coordinates: layout.top is where this spread is in continuous layout
|
|
732
|
+
viewportTopInLayout = layout.top - spreadTopInContainer + viewportTopInContainer;
|
|
733
|
+
}
|
|
734
|
+
// Find which spread the viewport top is associated with
|
|
735
|
+
for (let i = 0; i < state.layouts.length; i++) {
|
|
736
|
+
const layout = state.layouts[i];
|
|
737
|
+
const spread = state.spreads[i];
|
|
738
|
+
if (!spread)
|
|
739
|
+
continue;
|
|
740
|
+
const spreadTop = layout.top;
|
|
741
|
+
const spreadBottom = layout.top + layout.height;
|
|
742
|
+
const offsetFromSpreadTop = viewportTopInLayout - spreadTop;
|
|
743
|
+
// Check if viewport is in the spacing area before this spread
|
|
744
|
+
if (viewportTopInLayout < spreadTop) {
|
|
745
|
+
// We're in the spacing above this spread
|
|
746
|
+
// Store the absolute pixel offset (negative) since spacing doesn't scale
|
|
747
|
+
viewportTopPosition = {
|
|
748
|
+
page: getSpreadPrimaryPage(spread),
|
|
749
|
+
offset: offsetFromSpreadTop, // negative value
|
|
750
|
+
inSpacing: true
|
|
751
|
+
};
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// Check if viewport is within this spread's content
|
|
755
|
+
if (viewportTopInLayout < spreadBottom || i === state.layouts.length - 1) {
|
|
756
|
+
// We're within the spread content
|
|
757
|
+
// Store as ratio of spread height so it scales correctly
|
|
758
|
+
const offsetRatio = layout.height > 0 ? offsetFromSpreadTop / layout.height : 0;
|
|
759
|
+
viewportTopPosition = {
|
|
760
|
+
page: getSpreadPrimaryPage(spread),
|
|
761
|
+
offset: offsetRatio,
|
|
762
|
+
inSpacing: false
|
|
763
|
+
};
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
viewportTopPosition = null;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Restores the viewport to a previously tracked position.
|
|
771
|
+
*/
|
|
772
|
+
function restoreViewportPosition(position, metrics, state) {
|
|
773
|
+
const spreadIndex = findSpreadForPage(state.spreads, position.page);
|
|
774
|
+
const layout = state.layouts[spreadIndex];
|
|
554
775
|
if (!layout)
|
|
555
776
|
return;
|
|
556
|
-
|
|
557
|
-
|
|
777
|
+
let targetScrollTop;
|
|
778
|
+
if (position.inSpacing) {
|
|
779
|
+
// In spacing area: offset is absolute pixels (negative), doesn't scale
|
|
780
|
+
targetScrollTop = layout.top + position.offset;
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
// In spread content: offset is ratio of spread height, scales with zoom
|
|
784
|
+
const offsetPixels = position.offset * layout.height;
|
|
785
|
+
targetScrollTop = layout.top + offsetPixels;
|
|
786
|
+
}
|
|
558
787
|
const maxScrollTop = Math.max(0, containerSize.height - metrics.innerHeight);
|
|
559
788
|
scrollArea.scrollTop = clamp(targetScrollTop, 0, maxScrollTop);
|
|
560
789
|
}
|
|
@@ -680,6 +909,11 @@ export function createViewport() {
|
|
|
680
909
|
scale: state.scale,
|
|
681
910
|
dpi: slice.dpi
|
|
682
911
|
});
|
|
912
|
+
// Prerender adjacent pages for smooth page flipping
|
|
913
|
+
const dpr = getDevicePixelRatio();
|
|
914
|
+
const pointsToPixels = getPointsToPixels(slice.dpi);
|
|
915
|
+
const renderScale = pointsToPixels * state.scale * dpr;
|
|
916
|
+
workerClient.prerenderAdjacentPages(slice.docId, slice.page, renderScale, slice.pageInfos.length);
|
|
683
917
|
}
|
|
684
918
|
lastVisibleRange = { start: spreadIndex, end: spreadIndex };
|
|
685
919
|
// Only clear layoutDirty if renders actually happened
|
|
@@ -806,6 +1040,8 @@ export function createViewport() {
|
|
|
806
1040
|
cancelAnimationFrame(scrollRaf);
|
|
807
1041
|
if (renderDebounceTimer)
|
|
808
1042
|
clearTimeout(renderDebounceTimer);
|
|
1043
|
+
wmObserver.disconnect();
|
|
1044
|
+
clearInterval(wmIntegrityCheck);
|
|
809
1045
|
floatingToolbar.destroy();
|
|
810
1046
|
clearSpreads();
|
|
811
1047
|
workerClient = null;
|