@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.
Files changed (41) hide show
  1. package/README.md +5 -15
  2. package/dist/package.json +1 -1
  3. package/dist/src/UDocViewer.d.ts.map +1 -1
  4. package/dist/src/UDocViewer.js +24 -5
  5. package/dist/src/UDocViewer.js.map +1 -1
  6. package/dist/src/fonts.d.ts +93 -0
  7. package/dist/src/fonts.d.ts.map +1 -0
  8. package/dist/src/fonts.js +211 -0
  9. package/dist/src/fonts.js.map +1 -0
  10. package/dist/src/index.d.ts +2 -0
  11. package/dist/src/index.d.ts.map +1 -1
  12. package/dist/src/index.js +2 -0
  13. package/dist/src/index.js.map +1 -1
  14. package/dist/src/performance/PerformanceCounter.d.ts +1 -1
  15. package/dist/src/performance/PerformanceCounter.d.ts.map +1 -1
  16. package/dist/src/performance/PerformanceCounter.js.map +1 -1
  17. package/dist/src/ui/viewer/components/Viewport.d.ts.map +1 -1
  18. package/dist/src/ui/viewer/components/Viewport.js +322 -86
  19. package/dist/src/ui/viewer/components/Viewport.js.map +1 -1
  20. package/dist/src/ui/viewer/styles-inline.d.ts +1 -1
  21. package/dist/src/ui/viewer/styles-inline.d.ts.map +1 -1
  22. package/dist/src/ui/viewer/styles-inline.js +2 -27
  23. package/dist/src/ui/viewer/styles-inline.js.map +1 -1
  24. package/dist/src/ui/viewer/text/render.d.ts.map +1 -1
  25. package/dist/src/ui/viewer/text/render.js +5 -0
  26. package/dist/src/ui/viewer/text/render.js.map +1 -1
  27. package/dist/src/wasm/udoc.d.ts +105 -0
  28. package/dist/src/wasm/udoc.js +209 -0
  29. package/dist/src/wasm/udoc_bg.wasm +0 -0
  30. package/dist/src/wasm/udoc_bg.wasm.d.ts +5 -0
  31. package/dist/src/worker/WorkerClient.d.ts +51 -2
  32. package/dist/src/worker/WorkerClient.d.ts.map +1 -1
  33. package/dist/src/worker/WorkerClient.js +100 -0
  34. package/dist/src/worker/WorkerClient.js.map +1 -1
  35. package/dist/src/worker/index.d.ts +1 -1
  36. package/dist/src/worker/index.d.ts.map +1 -1
  37. package/dist/src/worker/worker.d.ts +70 -0
  38. package/dist/src/worker/worker.d.ts.map +1 -1
  39. package/dist/src/worker/worker.js +30 -0
  40. package/dist/src/worker/worker.js.map +1 -1
  41. package/package.json +1 -1
@@ -112,7 +112,21 @@ function getScrollbarSize() {
112
112
  cachedScrollbarSize = { width, height };
113
113
  return cachedScrollbarSize;
114
114
  }
115
- function computeScale(slice, metrics, spreads) {
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 snappedViewportWidth = snapToDevice(metrics.innerWidth);
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
- return widthScale;
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
- return heightScale;
196
+ // Content fits height by design, no vertical scrollbar needed
197
+ return targetHeight / maxHeight;
150
198
  case "fit-spread":
151
- return Math.min(widthScale, heightScale);
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, lastScrollMode) {
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
- const shouldCaptureAnchor = layoutChanged &&
192
- !spreadsChanged &&
193
- nextSlice.scrollMode === "continuous" &&
194
- !fitZoomChanged &&
195
- !zoomModeChanged;
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
- shouldCaptureAnchor,
205
- shouldCenterOnModeChange,
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
- const watermark = document.createElement("a");
220
- watermark.className = "udoc-viewport__watermark";
221
- watermark.href = "https://docmentis.com";
222
- watermark.target = "_blank";
223
- watermark.rel = "noopener";
224
- watermark.textContent = "Powered by docMentis";
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 || currentSlice.scrollMode !== "continuous")
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
- updateVisibleSpreads(currentSlice, lastMetrics, layoutState);
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 debounced
276
- // to avoid excessive WASM calls during panel animations
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
- // Pause renders during resize
279
- rendersPaused = true;
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
- rendersPaused = false;
288
- // Trigger re-render of visible spreads
289
- scheduleUpdate();
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, lastScrollMode);
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.shouldCenterOnModeChange) {
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 (anchor) {
455
- restoreScrollAnchor(anchor, metrics);
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.shouldResetSingleScroll) {
612
+ if (plan.shouldScrollToPage) {
462
613
  scrollArea.scrollTop = 0;
463
614
  scrollArea.scrollLeft = 0;
464
- // Reset overflow state when switching to spread mode to avoid
465
- // hysteresis from continuous mode keeping scrollbar visible
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
- function captureScrollAnchor(metrics, state) {
532
- if (state.layouts.length === 0 || state.spreads.length === 0)
533
- return null;
534
- const scrollTop = scrollArea.scrollTop;
535
- const viewportCenter = scrollTop + metrics.innerHeight / 2;
536
- const focusPage = findFocusPage(viewportCenter, state);
537
- if (focusPage === null)
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
- const spreadIndex = findSpreadForPage(layoutState.spreads, anchor.page);
553
- const layout = layoutState.layouts[spreadIndex];
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
- const spreadCenter = layout.top + layout.height / 2;
557
- const targetScrollTop = spreadCenter - anchor.offset;
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;