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();
+ });
});