Skip to content

Commit 8648dea

Browse files
committed
Updated UI to fix sidebar resize stuttering
1 parent 8d3b84f commit 8648dea

5 files changed

Lines changed: 72 additions & 20 deletions

File tree

scribe-ui/basic-viewer/pdf-viewer.js

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,12 @@ class ScribePDFViewer {
236236
onExtract: (pageIndices) => this.newDocumentFromPages(pageIndices),
237237
onInsertFromFile: (index) => this._pickFilesToInsert(index),
238238
// The panel's width (or hiding it) changed, so re-inset the document into the area beside it.
239-
onResize: () => { if (this.scribe.scrollContainer) this._relayout(); },
239+
onResize: (_width, phase) => {
240+
if (!this.scribe.scrollContainer) return;
241+
if (phase === 'start') { this._beginSidebarResize(); return; }
242+
if (phase === 'end') { this._endSidebarResize(); return; }
243+
this._relayout();
244+
},
240245
})
241246
: null;
242247
/**
@@ -246,8 +251,10 @@ class ScribePDFViewer {
246251
this._activeSidebar = showThumbnails ? 'thumbnails' : null;
247252
/** @type {?{raf: number}} In-flight sidebar open/close/switch transition (its live rAF handle), or null. */
248253
this._sidebarAnim = null;
249-
/** @type {?{min: number, max: number}} Rail width bounds cached for the duration of a bookmarks-view resize drag. */
254+
/** @type {?{min: number, max: number}} Rail width bounds cached for the duration of a bookmarks/comments-view resize drag. */
250255
this._sidebarResizeBounds = null;
256+
/** True while a sidebar resize drag (any view) is in flight, so `_relayout` skips the scrollbar refresh per move. */
257+
this._sidebarDragActive = false;
251258

252259
/** Height the message banner currently claims from the document area (0 when hidden). */
253260
this._messageBannerHeight = 0;
@@ -1227,9 +1234,8 @@ class ScribePDFViewer {
12271234
const inset = Math.min(panelW, Math.max(0, this._width - 80));
12281235
this.scribe.scrollContainer.style.marginLeft = `${inset}px`;
12291236
this.scribe.resize(this._width - inset, this._height - top);
1230-
// Skip the overlay-scrollbar refresh during an active sidebar-resize drag: reading the scroll metrics forces a synchronous reflow every pointermove (resize just invalidated the metrics cache).
1231-
// The drag's `end` phase runs one final `_relayout` that refreshes them, and a horizontal drag changes neither track's thumb until release anyway.
1232-
if (this._updateScrollbars && !this._sidebarResizeBounds) this._updateScrollbars();
1237+
// The scrollbar refresh rereads the scroll metrics the resize above just invalidated, forcing a synchronous reflow.
1238+
if (this._updateScrollbars && !this._sidebarDragActive) this._updateScrollbars();
12331239
}
12341240

12351241
/**
@@ -1262,29 +1268,24 @@ class ScribePDFViewer {
12621268
}
12631269

12641270
/**
1265-
* Apply a sidebar resize dragged from the bookmarks view, keeping the two views one shared width. The phases keep
1266-
* each frame cheap — the jank otherwise comes from doing the rail's `computeGeometry` (O(pages)) and forced layout
1267-
* reads on every pointermove:
1268-
* - `start`: cache the rail's width bounds once (one layout read), so each move clamps with pure arithmetic.
1269-
* - `move`: clamp with the cached bounds, resize ONLY the visible bookmarks panel, and re-inset the document. The
1270-
* hidden rail is left untouched — no per-frame re-column.
1271-
* - `end`: commit through the rail's `setWidth`, which clamps, applies, and re-columns the rail ONCE so it's
1272-
* correct when next shown; mirror the applied width onto the bookmarks panel.
1271+
* Apply a sidebar resize dragged from the bookmarks or comments view, keeping every view one shared width.
12731272
* @param {number} desiredWidth
12741273
* @param {'start'|'move'|'end'} phase
12751274
*/
12761275
_resizeSidebar(desiredWidth, phase) {
12771276
if (!this._thumbnailPanel || !this._bookmarksPanel) return;
12781277
if (phase === 'start') {
1278+
// One layout read here lets every move clamp with pure arithmetic.
12791279
this._sidebarResizeBounds = this._thumbnailPanel.getResizeBounds();
1280+
this._beginSidebarResize();
12801281
return;
12811282
}
12821283
if (phase === 'end') {
12831284
const applied = this._thumbnailPanel.setWidth(desiredWidth);
12841285
this._bookmarksPanel.panelElem.style.width = `${applied}px`;
12851286
if (this._commentsPanel) this._commentsPanel.panelElem.style.width = `${applied}px`;
12861287
this._sidebarResizeBounds = null;
1287-
this._relayout();
1288+
this._endSidebarResize();
12881289
return;
12891290
}
12901291
const b = this._sidebarResizeBounds;
@@ -1294,6 +1295,20 @@ class ScribePDFViewer {
12941295
this._relayout();
12951296
}
12961297

1298+
/** Enter a sidebar resize drag; paired with `_endSidebarResize` at release. */
1299+
_beginSidebarResize() {
1300+
this._sidebarDragActive = true;
1301+
this.scribe.startInteractionTextHide();
1302+
}
1303+
1304+
/** End a sidebar resize drag: settle the document area, then restore the text layers. */
1305+
_endSidebarResize() {
1306+
this._sidebarDragActive = false;
1307+
// Settle while the layers are still hidden so the scrollbar refresh's forced reflow stays cheap.
1308+
this._relayout();
1309+
this.scribe.endInteractionTextHide();
1310+
}
1311+
12971312
/**
12981313
* Animate the left sidebar between its states as one coherent motion: open and close slide the view in/out from the dock edge,
12991314
* and a switch crossfades the two views in place.

scribe-ui/js/controls/bookmarksPanel.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export function createBookmarksPanel(scribe, { onNavigate, onResize }) {
313313
function onResizeEnd(e) {
314314
window.removeEventListener('pointermove', onResizeMove);
315315
window.removeEventListener('pointerup', onResizeEnd);
316+
window.removeEventListener('pointercancel', onResizeEnd);
316317
if (onResize) onResize(resizeStartW + (e.clientX - resizeStartX), 'end');
317318
}
318319
resizeHandle.addEventListener('pointerdown', (e) => {
@@ -322,6 +323,8 @@ export function createBookmarksPanel(scribe, { onNavigate, onResize }) {
322323
if (onResize) onResize(resizeStartW, 'start');
323324
window.addEventListener('pointermove', onResizeMove);
324325
window.addEventListener('pointerup', onResizeEnd);
326+
// The host stays in its drag regime until an 'end' report, so a canceled drag must deliver one too.
327+
window.addEventListener('pointercancel', onResizeEnd);
325328
});
326329

327330
/**

scribe-ui/js/controls/commentsPanel.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ export function createCommentsPanel(scribe, { onNavigate, onResize }) {
643643
function onResizeEnd(e) {
644644
window.removeEventListener('pointermove', onResizeMove);
645645
window.removeEventListener('pointerup', onResizeEnd);
646+
window.removeEventListener('pointercancel', onResizeEnd);
646647
if (onResize) onResize(resizeStartW + (e.clientX - resizeStartX), 'end');
647648
}
648649
resizeHandle.addEventListener('pointerdown', (e) => {
@@ -652,6 +653,8 @@ export function createCommentsPanel(scribe, { onNavigate, onResize }) {
652653
if (onResize) onResize(resizeStartW, 'start');
653654
window.addEventListener('pointermove', onResizeMove);
654655
window.addEventListener('pointerup', onResizeEnd);
656+
// The host stays in its drag regime until an 'end' report, so a canceled drag must deliver one too.
657+
window.addEventListener('pointercancel', onResizeEnd);
655658
});
656659

657660
/** Reflect `selected` on the rendered rows. */

scribe-ui/js/controls/panels.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ function clearPageClipboard() {
9090
* @param {(n: number) => void} cfg.onSelect - Called with the page index when a thumbnail is clicked.
9191
* @param {(pageIndices: Array<number>) => void} [cfg.onExtract] - Called with the page indices to open as a new document.
9292
* @param {(at: number) => void} [cfg.onInsertFromFile] - Called with the gap index at which to insert pages picked from a file.
93-
* @param {(width: number) => void} [cfg.onResize] - Called with the panel's current visible width in px (0 when hidden), so the host can inset the document to the remaining area.
93+
* @param {(width: number, phase?: 'start'|'move'|'end') => void} [cfg.onResize] - Called with the panel's current visible width in px (0 when hidden).
94+
* `phase` is set on handle-drag reports and absent on non-drag reports (view shown or hidden).
9495
* @returns {{
9596
* panelElem: HTMLDivElement, toggleElem: HTMLSpanElement,
9697
* rebuild: (activeN?: number) => void, cancelCut: () => void, setActive: (n: number) => void,
@@ -804,7 +805,7 @@ export function createThumbnailPanel(scribe, {
804805
let resizeStartPanelW = 0;
805806
let resizeContainerW = 0;
806807
let resizeLivePanelW = 0;
807-
// The page centered when the drag began, plus its fractional position, so every reflow re-centers the same page.
808+
// The page pinned at drag start, plus its fractional position, so every reflow re-centers the same page.
808809
let resizeAnchorPage = -1;
809810
let resizeAnchorFrac = 0;
810811
// The panel's extra width (scrollbar gutter + padding + border) measured at drag start.
@@ -822,14 +823,15 @@ export function createThumbnailPanel(scribe, {
822823
// The width just changed. Refresh the cache once here so reflow/gridColsFor/windowRange read it without each forcing a layout.
823824
measureViewport();
824825
reflow();
825-
notifyResize();
826+
if (onResize) onResize(resizeLivePanelW, 'move');
826827
}
827828

828829
function onResizeEnd() {
829830
window.removeEventListener('pointermove', onResizeMove);
830831
window.removeEventListener('pointerup', onResizeEnd);
832+
window.removeEventListener('pointercancel', onResizeEnd);
831833
updateWindow(true);
832-
notifyResize();
834+
if (onResize) onResize(resizeLivePanelW, 'end');
833835
}
834836

835837
resizeHandle.addEventListener('pointerdown', (event) => {
@@ -838,8 +840,6 @@ export function createThumbnailPanel(scribe, {
838840
resizeStartPanelW = panelElem.getBoundingClientRect().width;
839841
resizeLivePanelW = resizeStartPanelW;
840842
resizeContainerW = (panelElem.parentElement && panelElem.parentElement.clientWidth) || resizeStartPanelW;
841-
// Pin the active page (the one shown in the viewer) for the whole drag so it stays in the pane across column changes,
842-
// or fall back to the page at the viewport center when nothing is active.
843843
measureViewport();
844844
resizeExtraW = resizeStartPanelW - viewportW;
845845
const centerY = scrollElem.scrollTop + viewportH / 2;
@@ -848,8 +848,11 @@ export function createThumbnailPanel(scribe, {
848848
: (pageCount > 0 ? rowAt(Math.max(0, centerY - PAD)) : -1);
849849
resizeAnchorFrac = resizeAnchorPage >= 0 && heights[resizeAnchorPage]
850850
? Math.max(0, Math.min(1, (centerY - (PAD + offsets[resizeAnchorPage])) / heights[resizeAnchorPage])) : 0;
851+
if (onResize) onResize(resizeStartPanelW, 'start');
851852
window.addEventListener('pointermove', onResizeMove);
852853
window.addEventListener('pointerup', onResizeEnd);
854+
// The host stays in its drag regime until an 'end' report, so a canceled drag must deliver one too.
855+
window.addEventListener('pointercancel', onResizeEnd);
853856
});
854857

855858
// Drag-to-select: a press in the panel's empty space rubber-bands a rectangle that selects every page it covers.

scribe-ui/viewer.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,34 @@ export class ScribeViewer {
10491049
}
10501050
}
10511051

1052+
/**
1053+
* Hide the built text layers until `endInteractionTextHide`, so a host interaction that moves or re-clips the content
1054+
* (a sidebar resize drag) does not repaint every built word per frame.
1055+
* No-ops when hiding is unsafe: visible-text modes, a focused edit input, or an active selection.
1056+
*/
1057+
startInteractionTextHide() {
1058+
const editing = document.activeElement && /** @type {HTMLElement} */ (document.activeElement).isContentEditable
1059+
&& this.elem?.contains(document.activeElement);
1060+
// Hiding visible text blanks the page, display:none blurs a focused edit (committing it), and a selection's layers must stay live.
1061+
if (this.state.displayMode !== 'invis' || editing || this._selectionPages().size) return;
1062+
// Take over a scroll-hide already in flight: its pending settle would otherwise restore the layers mid-interaction.
1063+
if (this._scrollTextHideTimer) {
1064+
clearTimeout(this._scrollTextHideTimer);
1065+
this._scrollTextHideTimer = null;
1066+
}
1067+
this._scrollTextHideEngaged = true;
1068+
for (const n of this.textGroupsRenderIndices) {
1069+
for (const grp of Object.values(this._textGroups[n] || {})) {
1070+
if (grp.style.display !== 'none') grp.style.display = 'none';
1071+
}
1072+
}
1073+
}
1074+
1075+
/** Restore the text layers hidden by `startInteractionTextHide`; sharing the scroll-hide restore keeps either hide source from leaving a group stuck hidden. */
1076+
endInteractionTextHide() {
1077+
this._endScrollTextHide();
1078+
}
1079+
10521080
/**
10531081
* Scroll the viewport by a pixel delta. Positive `deltaY` follows a downward drag (revealing content above).
10541082
* The old stage model moved `stage.y` (negative-of-scroll) by `+delta`, so native scroll uses the inverse sign.

0 commit comments

Comments
 (0)