diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index 405a45f3..595d5689 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -41,9 +41,9 @@ export function createAggregatedTable( placeholder: 'No Call Tree Available', height: '100%', maxHeight: '100%', - // @ts-expect-error custom property for module/RowKeyboardNavigation + MiddleRowFocus + // @ts-expect-error custom property for module/RowKeyboardNavigation + ScrollAnchor rowKeyboardNavigation: true, - middleRowFocus: true, + scrollAnchor: true, dataTree: true, dataTreeChildColumnCalcs: false, dataTreeBranchElement: '', diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index 6dd46cd2..a2328d8d 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -94,7 +94,7 @@ export function createBottomUpTable( height: '100%', maxHeight: '100%', rowKeyboardNavigation: true, - middleRowFocus: true, + scrollAnchor: true, dataTree: true, dataTreeChildColumnCalcs: false, dataTreeBranchElement: '', diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index a6b79012..1832151b 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -5,9 +5,9 @@ import { Tabulator } from 'tabulator-tables'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; import { Find } from '../../../tabulator/module/Find.js'; -import { MiddleRowFocus } from '../../../tabulator/module/MiddleRowFocus.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; +import { ScrollAnchor } from '../../../tabulator/module/ScrollAnchor.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; import type { MergedCalltreeRow } from '../utils/MergeAdjacent.js'; @@ -24,7 +24,7 @@ export interface TableCallbacks { export function registerTableModules(): void { Tabulator.registerModule(Object.values(CommonModules)); - Tabulator.registerModule([RowKeyboardNavigation, RowNavigation, MiddleRowFocus, Find]); + Tabulator.registerModule([RowKeyboardNavigation, RowNavigation, ScrollAnchor, Find]); } export function headerSortElement(_column: unknown, dir: string): string { diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index cc97048f..5c24ecba 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -53,8 +53,8 @@ export function createTimeOrderTable( maxHeight: '100%', // custom property for datagrid/module/RowKeyboardNavigation rowKeyboardNavigation: true, - // custom property for module/MiddleRowFocus - middleRowFocus: true, + // custom property for module/ScrollAnchor + scrollAnchor: true, dataTree: true, dataTreeChildColumnCalcs: false, dataTreeBranchElement: '', diff --git a/log-viewer/src/tabulator/module/MiddleRowFocus.ts b/log-viewer/src/tabulator/module/MiddleRowFocus.ts deleted file mode 100644 index c1a31611..00000000 --- a/log-viewer/src/tabulator/module/MiddleRowFocus.ts +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright (c) 2024 Certinia Inc. All rights reserved. - */ -import type { LogEvent } from 'apex-log-parser'; -import { Module, type RowComponent, type Tabulator } from 'tabulator-tables'; - -type TimedNodeProp = { originalData: LogEvent }; - -const middleRowFocusOption = 'middleRowFocus' as const; -/** - * Enable MiddleRowFocus by importing the class and calling - * Tabulator.registerModule(MiddleRowFocus); before the first instantiation of the table. - * Then enable by setting middleRowFocus to true in table config. - * To disable RowNavigation set middleRowFocus to false in table options. - */ -export class MiddleRowFocus extends Module { - static moduleName = 'middleRowFocus'; - - tableHolder: HTMLElement | null = null; - private tableEl: HTMLElement | null = null; - middleRow: RowComponent | null = null; - private pendingFilterRaf: number | null = null; - - // Single tree toggle: skip the next renderComplete recenter so scrollTop stays put. - // Bulk toggle (expand-all / collapse-all): the second toggle in the synchronous burst - // clears the skip flag, so the recenter runs as before. - private skipNextRender = false; - private toggleSeenInBurst = false; - - // Boundary anchoring: when the user is at the top or bottom of the scroll range - // before an operation, recentering on the middle row pushes them away from the - // edge they were at. Capture the boundary at snapshot time and restore it - // (scrollTop = 0 / scrollHeight - clientHeight) instead of centering. - private static readonly boundaryThresholdPx = 10; - private wasAtTop = false; - private wasAtBottom = false; - - constructor(table: Tabulator) { - super(table); - this.registerTableOption(middleRowFocusOption, false); - } - - initialize() { - // @ts-expect-error not in types - if (this.options(middleRowFocusOption)) { - this.tableHolder = this.table.element.querySelector('.tabulator-tableholder'); - - this.table.on('dataTreeRowExpanded', () => this._onTreeToggle()); - this.table.on('dataTreeRowCollapsed', () => this._onTreeToggle()); - - // Sort resets scrollTop before renderStarted fires, so capture the anchor here - // (pre-sort) instead. The renderStarted handler below is a no-op once middleRow - // is set — see the !this.middleRow guard. - this.table.on('dataSorting', () => this._captureAnchor()); - - this.table.on('renderStarted', () => this._captureAnchor()); - - // Tabulator bug workaround: rerenderRows on filter can leave .tabulator-table's - // paddingTop inflated when the pre-filter vDomTop/vDomBottom point past the - // post-filter row count. Detect and zero on the next frame after the render. - // See tabulator-virtual-scroll-fixes.md "Fix 7" for the upstream patch. - this.table.on('dataFiltered', () => { - if (this.pendingFilterRaf !== null) { - cancelAnimationFrame(this.pendingFilterRaf); - } - this.pendingFilterRaf = requestAnimationFrame(() => { - this.pendingFilterRaf = null; - this._resetStaleTopPadding(); - }); - }); - - this.table.on('renderComplete', async () => { - if (this.skipNextRender) { - this.skipNextRender = false; - this._clearAnchor(); - } else { - this._restoreAnchor(); - } - this.toggleSeenInBurst = false; - }); - } - } - - /** - * Capture the user's scroll anchor: the row at the visual middle and whether - * the user was at the top / bottom edge. Idempotent within one operation — - * subsequent calls are no-ops once an anchor is already set. - */ - private _captureAnchor() { - if (!this.tableHolder || this.middleRow) { - return; - } - const holder = this.tableHolder; - const max = Math.max(0, holder.scrollHeight - holder.clientHeight); - this.wasAtTop = holder.scrollTop <= MiddleRowFocus.boundaryThresholdPx; - this.wasAtBottom = max - holder.scrollTop <= MiddleRowFocus.boundaryThresholdPx; - this.middleRow = this._findMiddleVisibleRow(holder); - } - - /** - * Restore the captured anchor. Boundary cases (was-at-top / was-at-bottom) snap - * to the edge so we don't push the user off it. Mid-table centers on middleRow. - */ - private _restoreAnchor() { - const holder = this.tableHolder; - if (!holder) { - this._clearAnchor(); - return; - } - if (this.wasAtTop) { - holder.scrollTop = 0; - } else if (this.wasAtBottom) { - holder.scrollTop = Math.max(0, holder.scrollHeight - holder.clientHeight); - } else { - this._scrollToRow(this.middleRow); - } - this._clearAnchor(); - } - - private _clearAnchor() { - this.middleRow = null; - this.wasAtTop = false; - this.wasAtBottom = false; - } - - /** - * Tabulator bug workaround: after a filter, rerenderRows() (`tabulator_esm.mjs`, - * line ~25265) iterates pre-filter `vDomTop..vDomBottom` against the post-filter - * `rows()` array. The resulting `topOffset` flows into `_virtualRenderFill` and - * inflates `vDomTopPad` → `paddingTop` on `.tabulator-table` → blank strip across - * the top of the holder. We detect the symptom (`scrollTop < paddingTop`, which - * implies more empty space above the rendered window than the user has scrolled - * past) and reset the padding plus Tabulator's internal `vDomTopPad` so future - * renders are coherent. - */ - private _resetStaleTopPadding() { - if (!this.tableHolder) { - return; - } - if (!this.tableEl) { - this.tableEl = this.tableHolder.querySelector('.tabulator-table'); - } - const tableEl = this.tableEl; - if (!tableEl) { - return; - } - const paddingTop = parseFloat(tableEl.style.paddingTop) || 0; - const scrollTop = this.tableHolder.scrollTop; - if (paddingTop > 0 && scrollTop < paddingTop) { - tableEl.style.paddingTop = '0px'; - const renderer = this.table.rowManager?.renderer as Record | undefined; - if (renderer) { - renderer.vDomTopPad = 0; - } - } - } - - private _onTreeToggle() { - if (!this.toggleSeenInBurst) { - // First toggle in this burst: assume single, arm the skip. - this.toggleSeenInBurst = true; - this.skipNextRender = true; - } else { - // A second toggle arrived synchronously => it's a bulk operation; let the - // existing recenter run so the user's middle row stays in view. - this.skipNextRender = false; - } - // Clear the anchor so renderStarted captures fresh (with up-to-date boundary flags). - this._clearAnchor(); - } - - private _scrollToRow(row: RowComponent | null) { - if (!row) { - return; - } - - let rowToScrollTo: RowComponent | null = row; - if (rowToScrollTo?.getData) { - const displayRows = this.table.rowManager.getDisplayRows(); - //@ts-expect-error This is private to tabulator, but we have no other choice atm. - const internalRow = rowToScrollTo._getSelf(); - const canScroll = displayRows.indexOf(internalRow) !== -1; - if (!canScroll) { - const rowData = rowToScrollTo.getData() as TimedNodeProp; - const node = rowData.originalData; - - rowToScrollTo = this._findClosestActive(this.table.getRows('active'), node.timestamp); - } - - if (rowToScrollTo) { - this.table.scrollToRow(rowToScrollTo, 'center', true).then(() => { - setTimeout(() => { - rowToScrollTo - ?.getElement() - .scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); - }); - }); - } - } - } - - private _findClosestActive(rows: RowComponent[], timeStamp: number): RowComponent | null { - if (!rows) { - return null; - } - - let start = 0, - end = rows.length - 1; - - // Iterate as long as the beginning does not encounter the end. - const displayRows = this.table.rowManager.getDisplayRows(); - while (start <= end) { - // find out the middle index - const mid = Math.floor((start + end) / 2); - const row = rows[mid]; - - if (!row) { - break; - } - const node = (row.getData() as TimedNodeProp).originalData; - const endTime = node.exitStamp ?? node.timestamp; - - if (timeStamp === node.timestamp) { - //@ts-expect-error This is private to tabulator, but we have no other choice atm. - const internalRow = row._getSelf(); - const isActive = displayRows.indexOf(internalRow) !== -1; - if (isActive) { - return row; - } - - return this._findClosestActiveSibling(mid, rows, displayRows); - } else if (timeStamp >= node.timestamp && timeStamp <= endTime) { - const childMatch = this._findClosestActive(row.getTreeChildren() ?? [], timeStamp); - if (childMatch) { - return childMatch; - } - return this._findClosestActiveSibling(mid, rows, displayRows); - } - // Otherwise, look in the left or right half - else if (timeStamp > endTime) { - start = mid + 1; - } else if (timeStamp < node.timestamp) { - end = mid - 1; - } else { - return null; - } - } - - return null; - } - - private _findClosestActiveSibling( - midIndex: number, - rows: RowComponent[], - activeRows: RowComponent[], - ) { - const indexes = []; - - let previousIndex = midIndex; - let previousVisible; - while (previousIndex >= 0) { - previousVisible = rows[previousIndex]; - if (!previousVisible) { - continue; - } - //@ts-expect-error This is private to tabulator, but we have no other choice atm. - const internalRow = previousVisible._getSelf(); - const isActive = activeRows.indexOf(internalRow) !== -1; - if (previousVisible && isActive) { - indexes.push(previousIndex); - break; - } - - previousIndex--; - } - - const distanceFromMid = previousIndex > -1 ? midIndex - previousIndex : midIndex; - - const len = rows.length; - let nextIndex = midIndex; - let nextVisible; - while (nextIndex >= 0 && nextIndex !== len && nextIndex - midIndex < distanceFromMid) { - nextVisible = rows[nextIndex]; - if (!nextVisible) { - continue; - } - - //@ts-expect-error This is private to tabulator, but we have no other choice atm. - const internalRow = nextVisible._getSelf(); - const isActive = activeRows.indexOf(internalRow) !== -1; - if (nextVisible && isActive) { - indexes.push(nextIndex); - break; - } - nextIndex++; - } - - const closestIndex = indexes.length - ? indexes.reduce((a, b) => { - return Math.abs(b - midIndex) < Math.abs(a - midIndex) ? b : a; - }) - : null; - - return closestIndex ? rows[closestIndex] || null : null; - } - - private _findMiddleVisibleRow(tableHolder: HTMLElement) { - const visibleRows = this.table.getRows('visible'); - const len = visibleRows.length; - if (len === 0) { - return null; - } else if (len === 1) { - return visibleRows[0] ?? null; - } - - const tableRect = tableHolder.getBoundingClientRect(); - const totalHeight = Math.round(tableRect.height / 2); - - let currentHeight = 0; - for (const row of visibleRows) { - const elementRect = row.getElement().getBoundingClientRect(); - - const topDiff = tableRect.top - elementRect.top; - currentHeight += topDiff > 0 ? elementRect.height - topDiff : elementRect.height; - - const bottomDiff = elementRect.bottom - tableRect.bottom; - currentHeight -= bottomDiff > 0 ? bottomDiff : 0; - - if (Math.round(currentHeight) >= totalHeight) { - return row; - } - } - return null; - } -} diff --git a/log-viewer/src/tabulator/module/ScrollAnchor.ts b/log-viewer/src/tabulator/module/ScrollAnchor.ts new file mode 100644 index 00000000..bf6fc220 --- /dev/null +++ b/log-viewer/src/tabulator/module/ScrollAnchor.ts @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2024 Certinia Inc. All rights reserved. + */ +import { Module, type RowComponent, type Tabulator } from 'tabulator-tables'; + +const scrollAnchorOption = 'scrollAnchor' as const; + +/** + * Pixel-accurate scroll anchoring across table re-renders. + * + * Capture (renderStarted / dataSorting): the middle visible row and its Y offset + * inside the holder. Restore (renderComplete, fully synchronous): set scrollTop + * so the same row sits at the same pixel — no visible jump. + * + * Enable by registering the module and setting `scrollAnchor: true` in table options. + */ +export class ScrollAnchor extends Module { + static moduleName = 'scrollAnchor'; + + tableHolder: HTMLElement | null = null; + private tableEl: HTMLElement | null = null; + anchorRow: RowComponent | null = null; + private anchorOffsetFromHolderTop = 0; + private anchorDisplayIndex = -1; + private pendingFilterRaf: number | null = null; + + // Single tree toggle: skip the next renderComplete recenter so scrollTop stays put. + // Bulk toggle (expand-all / collapse-all): the second toggle in the synchronous burst + // clears the skip flag, so the recenter runs as before. + private skipNextRender = false; + private toggleSeenInBurst = false; + + // Boundary anchoring: when the user is at the top or bottom of the scroll range + // before an operation, recentering pushes them away from the edge they were at. + // Capture the boundary at snapshot time and restore it instead of pixel-anchoring. + private static readonly boundaryThresholdPx = 10; + private wasAtTop = false; + private wasAtBottom = false; + + constructor(table: Tabulator) { + super(table); + this.registerTableOption(scrollAnchorOption, false); + } + + initialize() { + // @ts-expect-error not in types + if (this.options(scrollAnchorOption)) { + this.tableHolder = this.table.element.querySelector('.tabulator-tableholder'); + + this.table.on('dataTreeRowExpanded', () => this._onTreeToggle()); + this.table.on('dataTreeRowCollapsed', () => this._onTreeToggle()); + + // Sort resets scrollTop before renderStarted fires, so capture the anchor here + // (pre-sort) instead. The renderStarted handler below is a no-op once anchorRow + // is set — see the !this.anchorRow guard. + this.table.on('dataSorting', () => this._captureAnchor()); + + this.table.on('renderStarted', () => this._captureAnchor()); + + // Tabulator bug workaround: rerenderRows on filter can leave .tabulator-table's + // paddingTop inflated when the pre-filter vDomTop/vDomBottom point past the + // post-filter row count. Detect and zero on the next frame after the render. + // See tabulator-virtual-scroll-fixes.md "Fix 7" for the upstream patch. + this.table.on('dataFiltered', () => { + if (this.pendingFilterRaf !== null) { + cancelAnimationFrame(this.pendingFilterRaf); + } + this.pendingFilterRaf = requestAnimationFrame(() => { + this.pendingFilterRaf = null; + this._resetStaleTopPadding(); + }); + }); + + this.table.on('renderComplete', () => { + if (this.skipNextRender) { + this.skipNextRender = false; + this._clearAnchor(); + } else { + this._restoreAnchor(); + } + this.toggleSeenInBurst = false; + }); + } + } + + /** + * Capture the user's scroll anchor: the middle visible row, its Y offset inside + * the holder, and whether the user was at the top / bottom edge. Idempotent + * within one operation — subsequent calls are no-ops once an anchor is set. + */ + private _captureAnchor() { + if (!this.tableHolder || this.anchorRow) { + return; + } + const holder = this.tableHolder; + const max = Math.max(0, holder.scrollHeight - holder.clientHeight); + this.wasAtTop = holder.scrollTop <= ScrollAnchor.boundaryThresholdPx; + this.wasAtBottom = max - holder.scrollTop <= ScrollAnchor.boundaryThresholdPx; + + const row = this._findMiddleVisibleRow(holder); + this.anchorRow = row; + if (row) { + // offsetTop is relative to the offsetParent (.tabulator-table); subtracting + // scrollTop gives the row's Y position inside the holder viewport — same + // result as paired getBoundingClientRect calls without forcing layout reads. + this.anchorOffsetFromHolderTop = row.getElement().offsetTop - holder.scrollTop; + // @ts-expect-error _getSelf is private to tabulator, but we have no other choice atm. + this.anchorDisplayIndex = this.table.rowManager.getDisplayRows().indexOf(row._getSelf()); + } + } + + /** + * Restore the captured anchor synchronously. Boundary cases (was-at-top / + * was-at-bottom) snap to the edge. Mid-table sets scrollTop so the anchor row + * sits at exactly the same Y pixel it occupied pre-render — no flash. + */ + private _restoreAnchor() { + const holder = this.tableHolder; + if (!holder) { + this._clearAnchor(); + return; + } + if (this.wasAtTop) { + holder.scrollTop = 0; + } else if (this.wasAtBottom) { + holder.scrollTop = Math.max(0, holder.scrollHeight - holder.clientHeight); + } else { + this._anchorPixelAccurate(holder); + } + this._clearAnchor(); + } + + private _clearAnchor() { + this.anchorRow = null; + this.anchorOffsetFromHolderTop = 0; + this.anchorDisplayIndex = -1; + this.wasAtTop = false; + this.wasAtBottom = false; + } + + /** + * Synchronous, pixel-accurate scroll anchor restore. + * + * If the anchor row is outside the post-render virtual DOM window its element + * is detached and `offsetTop` is stale. Call Tabulator's private + * `_virtualRenderFill` (same hook the public `scrollToRow` uses) to refill the + * vDom centered on the row, then set scrollTop in the same JS turn as + * renderComplete — the browser paints the corrected position directly. + */ + private _anchorPixelAccurate(holder: HTMLElement) { + const row = this._resolveAnchorRow(); + if (!row) { + return; + } + + const rowEl = row.getElement(); + if (!rowEl.isConnected) { + const renderer = this.table.rowManager.renderer as Record | undefined; + // @ts-expect-error _getSelf is private to tabulator, but we have no other choice atm. + const internalRow = row._getSelf() as unknown; + const rendererRows = (renderer?.rows as (() => unknown[]) | undefined)?.(); + const fill = renderer?._virtualRenderFill as + | ((index: number, force?: boolean) => void) + | undefined; + if (rendererRows && fill) { + const index = rendererRows.indexOf(internalRow); + if (index > -1) { + fill.call(renderer, index, true); + } + } + } + + holder.scrollTop = Math.max(0, rowEl.offsetTop - this.anchorOffsetFromHolderTop); + } + + /** + * Resolve the anchor row in the post-render display set. If it was filtered + * or collapsed away, fall back to the nearest displayed tree ancestor, then + * to the row at the captured display-index clamped to the new display length. + */ + private _resolveAnchorRow(): RowComponent | null { + const row = this.anchorRow; + if (!row) { + return null; + } + const displayRows = this.table.rowManager.getDisplayRows(); + if (this._isRowActive(row, displayRows)) { + return row; + } + + let parent = row.getTreeParent(); + while (parent) { + if (this._isRowActive(parent, displayRows)) { + return parent; + } + parent = parent.getTreeParent(); + } + + if (this.anchorDisplayIndex >= 0 && displayRows.length > 0) { + const idx = Math.min(this.anchorDisplayIndex, displayRows.length - 1); + const internalRow = displayRows[idx]; + if (internalRow) { + return (internalRow as unknown as { getComponent: () => RowComponent }).getComponent(); + } + } + + return null; + } + + private _isRowActive(row: RowComponent, displayRows: RowComponent[]): boolean { + // @ts-expect-error _getSelf is private to tabulator, but we have no other choice atm. + const internalRow = row._getSelf(); + return displayRows.indexOf(internalRow) !== -1; + } + + /** + * Tabulator bug workaround: after a filter, rerenderRows() (`tabulator_esm.mjs`, + * line ~25265) iterates pre-filter `vDomTop..vDomBottom` against the post-filter + * `rows()` array. The resulting `topOffset` flows into `_virtualRenderFill` and + * inflates `vDomTopPad` → `paddingTop` on `.tabulator-table` → blank strip across + * the top of the holder. We detect the symptom (`scrollTop < paddingTop`, which + * implies more empty space above the rendered window than the user has scrolled + * past) and reset the padding plus Tabulator's internal `vDomTopPad` so future + * renders are coherent. + */ + private _resetStaleTopPadding() { + if (!this.tableHolder) { + return; + } + if (!this.tableEl) { + this.tableEl = this.tableHolder.querySelector('.tabulator-table'); + } + const tableEl = this.tableEl; + if (!tableEl) { + return; + } + const paddingTop = parseFloat(tableEl.style.paddingTop) || 0; + const scrollTop = this.tableHolder.scrollTop; + if (paddingTop > 0 && scrollTop < paddingTop) { + tableEl.style.paddingTop = '0px'; + const renderer = this.table.rowManager?.renderer as Record | undefined; + if (renderer) { + renderer.vDomTopPad = 0; + } + } + } + + private _onTreeToggle() { + if (!this.toggleSeenInBurst) { + this.toggleSeenInBurst = true; + this.skipNextRender = true; + } else { + this.skipNextRender = false; + } + this._clearAnchor(); + } + + private _findMiddleVisibleRow(tableHolder: HTMLElement) { + const visibleRows = this.table.getRows('visible'); + const len = visibleRows.length; + if (len === 0) { + return null; + } else if (len === 1) { + return visibleRows[0] ?? null; + } + + const tableRect = tableHolder.getBoundingClientRect(); + const totalHeight = Math.round(tableRect.height / 2); + + let currentHeight = 0; + for (const row of visibleRows) { + const elementRect = row.getElement().getBoundingClientRect(); + + const topDiff = tableRect.top - elementRect.top; + currentHeight += topDiff > 0 ? elementRect.height - topDiff : elementRect.height; + + const bottomDiff = elementRect.bottom - tableRect.bottom; + currentHeight -= bottomDiff > 0 ? bottomDiff : 0; + + if (Math.round(currentHeight) >= totalHeight) { + return row; + } + } + return null; + } +} diff --git a/log-viewer/src/tabulator/module/__tests__/MiddleRowFocus.test.ts b/log-viewer/src/tabulator/module/__tests__/ScrollAnchor.test.ts similarity index 57% rename from log-viewer/src/tabulator/module/__tests__/MiddleRowFocus.test.ts rename to log-viewer/src/tabulator/module/__tests__/ScrollAnchor.test.ts index f80256a8..3ed5e5c3 100644 --- a/log-viewer/src/tabulator/module/__tests__/MiddleRowFocus.test.ts +++ b/log-viewer/src/tabulator/module/__tests__/ScrollAnchor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; -import { MiddleRowFocus } from '../MiddleRowFocus'; +import { ScrollAnchor } from '../ScrollAnchor'; function rect(top: number, height: number) { return { @@ -16,8 +16,10 @@ function rect(top: number, height: number) { } function makeRow(top: number, height = 20) { + const internal = {}; return { getElement: () => ({ getBoundingClientRect: () => rect(top, height) }), + _getSelf: () => internal, }; } @@ -37,14 +39,14 @@ function makeBareTable() { function setup() { const table = makeBareTable(); - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); return { plugin, table }; } -describe('MiddleRowFocus', () => { +describe('ScrollAnchor', () => { it('returns the row whose cumulative visible height first crosses half of the holder height', () => { // holder height 100, target 50. r1 contributes 30 (running 30), r2 contributes 30 // (running 60 — first row at/over 50), r3 never reached. @@ -62,7 +64,7 @@ describe('MiddleRowFocus', () => { }), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; const found = ( @@ -110,30 +112,28 @@ describe('MiddleRowFocus', () => { scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); // Sort starts — pre-sort middle row should be captured now (= r2). handlers.dataSorting?.[0]?.(); - expect((plugin as unknown as { middleRow: unknown }).middleRow).toBe(r2); + expect((plugin as unknown as { anchorRow: unknown }).anchorRow).toBe(r2); // Tabulator now rebuilds DOM with sorted rows in a different order. If our // dataSorting capture didn't happen, renderStarted would capture the wrong // row. Simulate that by changing the visible set and firing renderStarted — - // the guard `!this.middleRow` should make this a no-op. + // the guard `!this.anchorRow` should make this a no-op. (table.getRows as jest.Mock).mockImplementation((...args: unknown[]) => { if (args[0] === 'visible') return [r3, r2, r1]; // reversed return []; }); handlers.renderStarted?.[0]?.(); - expect((plugin as unknown as { middleRow: unknown }).middleRow).toBe(r2); + expect((plugin as unknown as { anchorRow: unknown }).anchorRow).toBe(r2); }); it('zeros stale paddingTop after filter when scrollTop is less than paddingTop', () => { - // Build a holder containing a .tabulator-table child whose style.paddingTop - // simulates the inflated value Tabulator's rerenderRows leaves behind. const tableEl = { style: { paddingTop: '120px' } }; const holder = { scrollTop: 0, @@ -152,12 +152,11 @@ describe('MiddleRowFocus', () => { scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); - // Drive the workaround directly (rAF-free) — same code path the rAF schedules. (plugin as unknown as { _resetStaleTopPadding: () => void })._resetStaleTopPadding(); expect(tableEl.style.paddingTop).toBe('0px'); @@ -184,7 +183,7 @@ describe('MiddleRowFocus', () => { rowManager: { getDisplayRows: () => [] }, scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); @@ -198,7 +197,6 @@ describe('MiddleRowFocus', () => { it('captures wasAtBottom when the user is at the scroll bottom', () => { const r1 = makeRow(0, 20); const r2 = makeRow(20, 20); - // scrollHeight 1000, clientHeight 100 → max scrollTop = 900. const holder = { scrollTop: 900, scrollHeight: 1000, @@ -216,7 +214,7 @@ describe('MiddleRowFocus', () => { rowManager: { getDisplayRows: () => [] }, scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); @@ -244,14 +242,13 @@ describe('MiddleRowFocus', () => { rowManager: { getDisplayRows: () => [] }, scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); - // Simulate post-snapshot state where wasAtTop is set. (plugin as unknown as { wasAtTop: boolean }).wasAtTop = true; - holder.scrollTop = 200; // pretend Tabulator moved scrollTop during render + holder.scrollTop = 200; handlers.renderComplete?.[0]?.(); expect(holder.scrollTop).toBe(0); @@ -275,7 +272,7 @@ describe('MiddleRowFocus', () => { rowManager: { getDisplayRows: () => [] }, scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); @@ -283,13 +280,11 @@ describe('MiddleRowFocus', () => { (plugin as unknown as { wasAtBottom: boolean }).wasAtBottom = true; handlers.renderComplete?.[0]?.(); - expect(holder.scrollTop).toBe(900); // 1000 - 100 + expect(holder.scrollTop).toBe(900); expect(table.scrollToRow).not.toHaveBeenCalled(); }); it('does NOT zero paddingTop when scrollTop has accounted for it (legitimate state)', () => { - // Mid-table: scrollTop matches paddingTop, meaning the user has genuinely scrolled - // past the rows the padding represents. Mitigation must leave this alone. const tableEl = { style: { paddingTop: '500px' } }; const holder = { scrollTop: 500, @@ -308,7 +303,7 @@ describe('MiddleRowFocus', () => { scrollToRow: jest.fn(() => Promise.resolve()), }; - const plugin = new MiddleRowFocus(table as never); + const plugin = new ScrollAnchor(table as never); (plugin as unknown as { table: typeof table }).table = table; (plugin as unknown as { options: () => boolean }).options = () => true; plugin.initialize(); @@ -327,8 +322,205 @@ describe('MiddleRowFocus', () => { table.handlers.dataTreeRowExpanded?.[0]?.(); table.handlers.dataTreeRowExpanded?.[0]?.(); - // After the first toggle skip was armed; subsequent toggles cleared it. expect((plugin as unknown as { skipNextRender: boolean }).skipNextRender).toBe(false); expect((plugin as unknown as { toggleSeenInBurst: boolean }).toggleSeenInBurst).toBe(true); }); + + it('captures the anchor offset within the holder for pixel-accurate restore', () => { + // Anchor row offsetTop=130, holder.scrollTop=100 → captured offset = 30 (the + // row's Y position inside the visible holder viewport). + const r1Internal = {}; + const r2Internal = {}; + const r1 = { + getElement: () => ({ offsetTop: 100, getBoundingClientRect: () => rect(50, 20) }), + _getSelf: () => r1Internal, + }; + const r2 = { + getElement: () => ({ offsetTop: 130, getBoundingClientRect: () => rect(80, 40) }), + _getSelf: () => r2Internal, + }; + const holder = { + scrollTop: 100, + scrollHeight: 1000, + clientHeight: 100, + getBoundingClientRect: () => rect(50, 100), + }; + const handlers: Record void)[]> = {}; + const table = { + handlers, + on: jest.fn((evt: string, fn: (...args: unknown[]) => void) => { + (handlers[evt] ??= []).push(fn); + }), + element: { querySelector: jest.fn(() => holder) }, + getRows: jest.fn((type?: string) => (type === 'visible' ? [r1, r2] : [])), + rowManager: { getDisplayRows: () => [] }, + scrollToRow: jest.fn(() => Promise.resolve()), + }; + const plugin = new ScrollAnchor(table as never); + (plugin as unknown as { table: typeof table }).table = table; + (plugin as unknown as { options: () => boolean }).options = () => true; + plugin.initialize(); + + handlers.renderStarted?.[0]?.(); + + expect((plugin as unknown as { anchorRow: unknown }).anchorRow).toBe(r2); + expect( + (plugin as unknown as { anchorOffsetFromHolderTop: number }).anchorOffsetFromHolderTop, + ).toBe(30); + }); + + it('restores scrollTop synchronously in renderComplete (no awaits, no scrollToRow)', () => { + // Pre-render: middle row sat at offset 30 from holder top. Post-render: same row + // is at offsetTop 500 inside .tabulator-table → expected scrollTop = 500 - 30 = 470. + // Crucially the assertion runs immediately after the renderComplete call — no await, + // no rAF, no setTimeout. If the write were async this would still be the old value. + const internalRow = { __internal: true }; + const rowEl = { offsetTop: 500 }; + const r2: { + getElement: () => unknown; + getData: () => { originalData: { timestamp: number } }; + _getSelf: () => unknown; + } = { + getElement: () => rowEl, + getData: () => ({ originalData: { timestamp: 0 } }), + _getSelf: () => internalRow, + }; + const holder: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + getBoundingClientRect: () => ReturnType; + } = { + scrollTop: 100, + scrollHeight: 5000, + clientHeight: 100, + getBoundingClientRect: () => rect(0, 100), + }; + const renderer = { + rows: jest.fn(() => [internalRow]), + _virtualRenderFill: jest.fn(), + }; + const handlers: Record void)[]> = {}; + const table = { + handlers, + on: jest.fn((evt: string, fn: (...args: unknown[]) => void) => { + (handlers[evt] ??= []).push(fn); + }), + element: { querySelector: jest.fn(() => holder) }, + getRows: jest.fn(() => []), + rowManager: { renderer, getDisplayRows: () => [internalRow] }, + scrollToRow: jest.fn(() => Promise.resolve()), + }; + const plugin = new ScrollAnchor(table as never); + (plugin as unknown as { table: typeof table }).table = table; + (plugin as unknown as { options: () => boolean }).options = () => true; + plugin.initialize(); + + // Manually seed the captured anchor (skip dataSorting/renderStarted to keep test focused). + const p = plugin as unknown as { + anchorRow: typeof r2; + anchorOffsetFromHolderTop: number; + }; + p.anchorRow = r2; + p.anchorOffsetFromHolderTop = 30; + + handlers.renderComplete?.[0]?.(); + + expect(renderer._virtualRenderFill).toHaveBeenCalledWith(0, true); + expect(holder.scrollTop).toBe(470); + expect(table.scrollToRow).not.toHaveBeenCalled(); + }); + + it('fallback: collapse case walks up getTreeParent to the nearest displayed ancestor', () => { + // Anchor row was a child collapsed under a parent. Parent is displayed. + const parentInternal = {}; + const childInternal = {}; + const parentComponent = { + _getSelf: () => parentInternal, + getTreeParent: () => false, + }; + const childComponent = { + _getSelf: () => childInternal, + getTreeParent: () => parentComponent, + }; + const { table, plugin } = setup(); + table.rowManager.getDisplayRows = () => [parentInternal] as never; + + const p = plugin as unknown as { anchorRow: unknown }; + p.anchorRow = childComponent; + + const resolved = ( + plugin as unknown as { _resolveAnchorRow: () => unknown } + )._resolveAnchorRow(); + expect(resolved).toBe(parentComponent); + }); + + it('fallback: filter case picks the row at the captured display-rows index (clamped)', () => { + // Anchor row was at display-index 50 pre-render. Post-render display set has + // only 10 rows (filter removed most). Index clamped to 9 (length - 1). + const internalRows = Array.from({ length: 10 }, (_, i) => ({ id: i })); + const expectedComponent = { mark: 'expected' }; + (internalRows[9] as unknown as { getComponent: () => unknown }).getComponent = () => + expectedComponent; + + const anchorInternal = {}; + const anchorComponent = { + _getSelf: () => anchorInternal, + getTreeParent: () => false, + }; + const { table, plugin } = setup(); + table.rowManager.getDisplayRows = () => internalRows as never; + + const p = plugin as unknown as { anchorRow: unknown; anchorDisplayIndex: number }; + p.anchorRow = anchorComponent; + p.anchorDisplayIndex = 50; + + const resolved = ( + plugin as unknown as { _resolveAnchorRow: () => unknown } + )._resolveAnchorRow(); + expect(resolved).toBe(expectedComponent); + }); + + it('fallback: filter case with exact index returns the row at that index', () => { + const internalRows = Array.from({ length: 100 }, (_, i) => ({ id: i })); + const expectedComponent = { mark: 'at-30' }; + (internalRows[30] as unknown as { getComponent: () => unknown }).getComponent = () => + expectedComponent; + + const anchorInternal = {}; + const anchorComponent = { + _getSelf: () => anchorInternal, + getTreeParent: () => false, + }; + const { table, plugin } = setup(); + table.rowManager.getDisplayRows = () => internalRows as never; + + const p = plugin as unknown as { anchorRow: unknown; anchorDisplayIndex: number }; + p.anchorRow = anchorComponent; + p.anchorDisplayIndex = 30; + + const resolved = ( + plugin as unknown as { _resolveAnchorRow: () => unknown } + )._resolveAnchorRow(); + expect(resolved).toBe(expectedComponent); + }); + + it('fallback: returns null when no parent is displayed and display rows are empty', () => { + const anchorInternal = {}; + const anchorComponent = { + _getSelf: () => anchorInternal, + getTreeParent: () => false, + }; + const { table, plugin } = setup(); + table.rowManager.getDisplayRows = () => [] as never; + + const p = plugin as unknown as { anchorRow: unknown; anchorDisplayIndex: number }; + p.anchorRow = anchorComponent; + p.anchorDisplayIndex = 5; + + const resolved = ( + plugin as unknown as { _resolveAnchorRow: () => unknown } + )._resolveAnchorRow(); + expect(resolved).toBeNull(); + }); });