Skip to content

Commit 352cc5f

Browse files
authored
Merge pull request #541 from luissardon/fix/plugin-scroll-horizontal-smooth-scroll
fix(plugin-scroll): horizontal layout extent, centering and programmatic scroll
2 parents 90a6553 + d093772 commit 352cc5f

5 files changed

Lines changed: 108 additions & 31 deletions

File tree

.changeset/bright-birds-tie.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@embedpdf/plugin-scroll': patch
3+
---
4+
5+
Fix horizontal scrolling layout calculations and page navigation state updates in `@embedpdf/plugin-scroll`.
6+
7+
This corrects horizontal visible-range and end-spacing math, preserves the optimistic `currentPage` during smooth next/previous navigation, and fixes page-coordinate targeting for mixed-height pages in horizontal mode by matching the scroller's vertical centering.

packages/plugin-scroll/src/lib/scroll-plugin.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,18 @@ export class ScrollPlugin extends BasePlugin<
435435
startTime: Date.now(),
436436
};
437437

438-
this.dispatch(updateDocumentScrollState(documentId, { pageChangeState }));
438+
// Optimistic update: set currentPage immediately so UI reflects target during smooth scroll
439+
this.dispatch(
440+
updateDocumentScrollState(documentId, {
441+
pageChangeState,
442+
currentPage: targetPage,
443+
}),
444+
);
445+
this.pageChange$.emit({
446+
documentId,
447+
pageNumber: targetPage,
448+
totalPages: docState.totalPages,
449+
});
439450

440451
if (behavior === 'instant') {
441452
this.completePageChange(documentId);
@@ -494,14 +505,22 @@ export class ScrollPlugin extends BasePlugin<
494505
const docState = this.getDocumentState(documentId);
495506
if (!docState) return;
496507

497-
// Update state
498-
this.dispatch(updateDocumentScrollState(documentId, metrics));
499-
500-
// Emit scroll event
501-
this.scroll$.emit({ documentId, metrics });
502-
503-
// Emit page change if current page changed
504-
if (metrics.currentPage !== docState.currentPage) {
508+
// During smooth scroll (Next/Previous), viewport fires before scroll completes.
509+
// Metrics would derive currentPage from old viewport position and revert the optimistic update.
510+
const isProgrammaticScroll = docState.pageChangeState.isChanging;
511+
// Preserve optimistic currentPage during programmatic scroll; use viewport-derived value otherwise
512+
const metricsToCommit =
513+
isProgrammaticScroll && metrics.currentPage !== docState.currentPage
514+
? { ...metrics, currentPage: docState.currentPage }
515+
: metrics;
516+
517+
// Update state before emitting scroll event,
518+
// so any scroll$ listener that reads state sees metricsToCommit; then notify
519+
this.dispatch(updateDocumentScrollState(documentId, metricsToCommit));
520+
this.scroll$.emit({ documentId, metrics: metricsToCommit });
521+
522+
// Only emit pageChange$ for user-driven scroll; programmatic scroll already emitted in startPageChange
523+
if (!isProgrammaticScroll && metrics.currentPage !== docState.currentPage) {
505524
this.pageChange$.emit({
506525
documentId,
507526
pageNumber: metrics.currentPage,

packages/plugin-scroll/src/lib/strategies/base-strategy.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ export abstract class BaseScrollStrategy {
3434
protected abstract getScrollOffset(viewport: ViewportMetrics): number;
3535
protected abstract getClientSize(viewport: ViewportMetrics): number;
3636

37+
/**
38+
* Returns the item's extent along the scroll axis. Vertical layout uses height;
39+
* horizontal layout uses width. Used for visible range and end spacing calculations.
40+
*/
41+
protected abstract getItemSizeAlongScrollAxis(item: VirtualItem): number;
42+
43+
/**
44+
* Horizontal centering offset for items narrower than the content max width.
45+
* Vertical layout centers spreads within the row; horizontal layout overrides to 0
46+
* since items stay in a simple row without centering.
47+
*/
48+
protected getCenteringOffsetX(_item: VirtualItem, totalContentSize: Size | undefined): number {
49+
if (!totalContentSize || _item.width >= totalContentSize.width) return 0;
50+
return (totalContentSize.width - _item.width) / 2;
51+
}
52+
53+
/**
54+
* Vertical centering offset for items shorter than the content max height.
55+
* Horizontal layout centers items within the row height; vertical layout keeps
56+
* items top-aligned and uses the default of 0.
57+
*/
58+
protected getCenteringOffsetY(_item: VirtualItem, _totalContentSize: Size | undefined): number {
59+
return 0;
60+
}
61+
3762
protected getVisibleRange(
3863
viewport: ViewportMetrics,
3964
virtualItems: VirtualItem[],
@@ -44,10 +69,14 @@ export abstract class BaseScrollStrategy {
4469
const viewportStart = scrollOffset;
4570
const viewportEnd = scrollOffset + clientSize;
4671

72+
// Use extent along scroll axis (height for vertical, width for horizontal)
4773
let startIndex = 0;
4874
while (
4975
startIndex < virtualItems.length &&
50-
(virtualItems[startIndex].offset + virtualItems[startIndex].height) * scale <= viewportStart
76+
(virtualItems[startIndex].offset +
77+
this.getItemSizeAlongScrollAxis(virtualItems[startIndex])) *
78+
scale <=
79+
viewportStart
5180
) {
5281
startIndex++;
5382
}
@@ -75,7 +104,7 @@ export abstract class BaseScrollStrategy {
75104
visibleItems,
76105
viewport,
77106
scale,
78-
totalContentSize.width,
107+
totalContentSize,
79108
);
80109
const visiblePages = pageVisibilityMetrics.map((m) => m.pageNumber);
81110
const renderedPageIndexes = virtualItems
@@ -85,11 +114,11 @@ export abstract class BaseScrollStrategy {
85114
const first = virtualItems[range.start];
86115
const last = virtualItems[range.end];
87116
const startSpacing = first ? first.offset * scale : 0;
117+
const lastItem = virtualItems[virtualItems.length - 1];
118+
// End spacing = distance from last rendered item to end of content (uses extent along scroll axis)
88119
const endSpacing = last
89-
? (virtualItems[virtualItems.length - 1].offset + // end of content
90-
virtualItems[virtualItems.length - 1].height) *
91-
scale - // minus
92-
(last.offset + last.height) * scale // end of last rendered
120+
? (lastItem.offset + this.getItemSizeAlongScrollAxis(lastItem)) * scale -
121+
(last.offset + this.getItemSizeAlongScrollAxis(last)) * scale
93122
: 0;
94123

95124
return {
@@ -107,19 +136,17 @@ export abstract class BaseScrollStrategy {
107136
virtualItems: VirtualItem[],
108137
viewport: ViewportMetrics,
109138
scale: number,
110-
contentWidth?: number,
139+
totalContentSize?: Size,
111140
): ScrollMetrics['pageVisibilityMetrics'] {
112141
const visibilityMetrics: ScrollMetrics['pageVisibilityMetrics'] = [];
113-
// Calculate max width for centering if not provided
114-
const maxWidth = contentWidth ?? Math.max(...virtualItems.map((i) => i.width));
115142

116143
virtualItems.forEach((item) => {
117-
// Calculate horizontal centering offset for items narrower than max width
118-
const centeringOffsetX = item.width < maxWidth ? (maxWidth - item.width) / 2 : 0;
144+
const centeringOffsetX = this.getCenteringOffsetX(item, totalContentSize);
145+
const centeringOffsetY = this.getCenteringOffsetY(item, totalContentSize);
119146

120147
item.pageLayouts.forEach((page) => {
121148
const itemX = (item.x + centeringOffsetX) * scale;
122-
const itemY = item.y * scale;
149+
const itemY = (item.y + centeringOffsetY) * scale;
123150
const pageX = itemX + page.x * scale;
124151
const pageY = itemY + page.y * scale;
125152
const pageWidth = page.rotatedWidth * scale;
@@ -194,19 +221,13 @@ export abstract class BaseScrollStrategy {
194221
const pageLayout = item.pageLayouts.find((layout) => layout.pageNumber === pageNumber);
195222
if (!pageLayout) return null;
196223

197-
// Calculate centering offset for items that are narrower than the maximum width
198-
let centeringOffsetX = 0;
199-
if (totalContentSize) {
200-
const maxWidth = totalContentSize.width;
201-
if (item.width < maxWidth) {
202-
centeringOffsetX = (maxWidth - item.width) / 2;
203-
}
204-
}
224+
const centeringOffsetX = this.getCenteringOffsetX(item, totalContentSize);
225+
const centeringOffsetY = this.getCenteringOffsetY(item, totalContentSize);
205226

206227
return {
207228
origin: {
208229
x: item.x + pageLayout.x + centeringOffsetX,
209-
y: item.y + pageLayout.y,
230+
y: item.y + pageLayout.y + centeringOffsetY,
210231
},
211232
size: {
212233
width: pageLayout.width,

packages/plugin-scroll/src/lib/strategies/horizontal-strategy.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PdfPageObjectWithRotatedSize } from '@embedpdf/models';
2+
import { Size } from '@embedpdf/models';
23
import { ViewportMetrics } from '@embedpdf/plugin-viewport';
34
import { BaseScrollStrategy, ScrollStrategyConfig } from './base-strategy';
45
import { VirtualItem, PageLayout } from '../types/virtual-item';
@@ -22,6 +23,7 @@ export class HorizontalScrollStrategy extends BaseScrollStrategy {
2223
height: page.size.height,
2324
rotatedWidth: page.rotatedSize.width,
2425
rotatedHeight: page.rotatedSize.height,
26+
elevated: false,
2527
};
2628
pageX += page.rotatedSize.width + this.pageGap;
2729
return layout;
@@ -66,4 +68,27 @@ export class HorizontalScrollStrategy extends BaseScrollStrategy {
6668
protected getClientSize(viewport: ViewportMetrics): number {
6769
return viewport.clientWidth;
6870
}
71+
72+
/** Horizontal scroll: extent along scroll axis is width. */
73+
protected getItemSizeAlongScrollAxis(item: VirtualItem): number {
74+
return item.width;
75+
}
76+
77+
/**
78+
* No centering for horizontal layout. Items are laid out in a simple row;
79+
* using total content width would produce incorrect offsets and shift pages.
80+
*/
81+
/* eslint-disable no-unused-vars -- override intentionally ignores params */
82+
protected getCenteringOffsetX(_item: VirtualItem, _totalContentSize: Size | undefined): number {
83+
return 0;
84+
}
85+
86+
/**
87+
* Horizontal rows visually center shorter items within the tallest row height.
88+
* Match that DOM layout so page-coordinate targeting lands on the rendered page.
89+
*/
90+
protected getCenteringOffsetY(item: VirtualItem, totalContentSize: Size | undefined): number {
91+
if (!totalContentSize || item.height >= totalContentSize.height) return 0;
92+
return (totalContentSize.height - item.height) / 2;
93+
}
6994
}

packages/plugin-scroll/src/lib/strategies/vertical-strategy.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { PdfPageObjectWithRotatedSize } from '@embedpdf/models';
22
import { ViewportMetrics } from '@embedpdf/plugin-viewport';
33
import { BaseScrollStrategy, ScrollStrategyConfig } from './base-strategy';
44
import { VirtualItem, PageLayout } from '../types/virtual-item';
5-
import { ScrollMetrics } from '../types';
65

76
export class VerticalScrollStrategy extends BaseScrollStrategy {
87
constructor(config: ScrollStrategyConfig) {
@@ -23,6 +22,7 @@ export class VerticalScrollStrategy extends BaseScrollStrategy {
2322
height: page.size.height,
2423
rotatedWidth: page.rotatedSize.width,
2524
rotatedHeight: page.rotatedSize.height,
25+
elevated: false,
2626
};
2727
pageX += page.rotatedSize.width + this.pageGap;
2828
return layout;
@@ -67,4 +67,9 @@ export class VerticalScrollStrategy extends BaseScrollStrategy {
6767
protected getClientSize(viewport: ViewportMetrics): number {
6868
return viewport.clientHeight;
6969
}
70+
71+
/** Vertical scroll: extent along scroll axis is height. */
72+
protected getItemSizeAlongScrollAxis(item: VirtualItem): number {
73+
return item.height;
74+
}
7075
}

0 commit comments

Comments
 (0)