Skip to content

Commit 02dd194

Browse files
committed
fix(plugin-scroll): horizontal layout extent, centering and programmatic scroll
BREAKING CHANGE: none - Use scroll-axis extent (width vs height) for visible range and end spacing so horizontal mode no longer treats item height as the scroll extent. - Disable horizontal centering (getCenteringOffsetX → 0) so pages stay in a plain row; vertical keeps centering via total content width. - During pageChangeState.isChanging, keep store currentPage from viewport- derived metrics and avoid duplicate pageChange$ until user-driven scroll resumes; startPageChange sets currentPage + emits pageChange$ optimistically. - In commitMetrics, dispatch before scroll$.emit so listeners that read state see metricsToCommit (single source of truth). - Set PageLayout.elevated: false when building virtual items (required field; selectors still override from interaction manager).
1 parent aa45d6e commit 02dd194

4 files changed

Lines changed: 79 additions & 29 deletions

File tree

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: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ export abstract class BaseScrollStrategy {
3333
abstract getTotalContentSize(virtualItems: VirtualItem[]): Size;
3434
protected abstract getScrollOffset(viewport: ViewportMetrics): number;
3535
protected abstract getClientSize(viewport: ViewportMetrics): number;
36+
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+
}
3652

3753
protected getVisibleRange(
3854
viewport: ViewportMetrics,
@@ -44,10 +60,14 @@ export abstract class BaseScrollStrategy {
4460
const viewportStart = scrollOffset;
4561
const viewportEnd = scrollOffset + clientSize;
4662

63+
// Use extent along scroll axis (height for vertical, width for horizontal)
4764
let startIndex = 0;
4865
while (
4966
startIndex < virtualItems.length &&
50-
(virtualItems[startIndex].offset + virtualItems[startIndex].height) * scale <= viewportStart
67+
(virtualItems[startIndex].offset +
68+
this.getItemSizeAlongScrollAxis(virtualItems[startIndex])) *
69+
scale <=
70+
viewportStart
5171
) {
5272
startIndex++;
5373
}
@@ -75,7 +95,7 @@ export abstract class BaseScrollStrategy {
7595
visibleItems,
7696
viewport,
7797
scale,
78-
totalContentSize.width,
98+
totalContentSize,
7999
);
80100
const visiblePages = pageVisibilityMetrics.map((m) => m.pageNumber);
81101
const renderedPageIndexes = virtualItems
@@ -85,11 +105,11 @@ export abstract class BaseScrollStrategy {
85105
const first = virtualItems[range.start];
86106
const last = virtualItems[range.end];
87107
const startSpacing = first ? first.offset * scale : 0;
108+
const lastItem = virtualItems[virtualItems.length - 1];
109+
// End spacing = distance from last rendered item to end of content (uses extent along scroll axis)
88110
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
111+
? (lastItem.offset + this.getItemSizeAlongScrollAxis(lastItem)) * scale -
112+
(last.offset + this.getItemSizeAlongScrollAxis(last)) * scale
93113
: 0;
94114

95115
return {
@@ -107,15 +127,12 @@ export abstract class BaseScrollStrategy {
107127
virtualItems: VirtualItem[],
108128
viewport: ViewportMetrics,
109129
scale: number,
110-
contentWidth?: number,
130+
totalContentSize?: Size,
111131
): ScrollMetrics['pageVisibilityMetrics'] {
112132
const visibilityMetrics: ScrollMetrics['pageVisibilityMetrics'] = [];
113-
// Calculate max width for centering if not provided
114-
const maxWidth = contentWidth ?? Math.max(...virtualItems.map((i) => i.width));
115133

116134
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;
135+
const centeringOffsetX = this.getCenteringOffsetX(item, totalContentSize);
119136

120137
item.pageLayouts.forEach((page) => {
121138
const itemX = (item.x + centeringOffsetX) * scale;
@@ -194,14 +211,7 @@ export abstract class BaseScrollStrategy {
194211
const pageLayout = item.pageLayouts.find((layout) => layout.pageNumber === pageNumber);
195212
if (!pageLayout) return null;
196213

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-
}
214+
const centeringOffsetX = this.getCenteringOffsetX(item, totalContentSize);
205215

206216
return {
207217
origin: {

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

Lines changed: 16 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,18 @@ 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+
}
6985
}

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)