From 6f5253ea368b7c10a0b467a6b0702c783eac65ef Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Tue, 7 Apr 2026 18:37:36 +0100
Subject: [PATCH 01/14] fix: disable scroll anchoring on tabulator virtual
scroll container
Prevents Chrome's scroll anchoring from double-compensating
when the virtual DOM renderer adjusts padding during scroll-up,
which caused visible content drift.
---
log-viewer/src/tabulator/style/DataGrid.scss | 1 +
1 file changed, 1 insertion(+)
diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss
index 240b3afa..b298af5d 100644
--- a/log-viewer/src/tabulator/style/DataGrid.scss
+++ b/log-viewer/src/tabulator/style/DataGrid.scss
@@ -45,6 +45,7 @@ $footerActiveColor: #d00 !default; //footer bottom active text color
.tabulator {
.tabulator-tableholder {
overflow-x: hidden;
+ overflow-anchor: none;
.tabulator-table {
display: block;
background-color: default;
From 8899958cd02cb0cb4f1508ff967899069281f7e1 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Tue, 7 Apr 2026 18:40:56 +0100
Subject: [PATCH 02/14] perf: add CSS compositor hints for tabulator scroll
performance
will-change: transform promotes the scroll container to a compositor
layer for GPU-accelerated scrolling.
content-visibility: auto skips rendering off-screen table content.
---
log-viewer/src/tabulator/style/DataGrid.scss | 2 ++
1 file changed, 2 insertions(+)
diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss
index b298af5d..256b9fc2 100644
--- a/log-viewer/src/tabulator/style/DataGrid.scss
+++ b/log-viewer/src/tabulator/style/DataGrid.scss
@@ -46,9 +46,11 @@ $footerActiveColor: #d00 !default; //footer bottom active text color
.tabulator-tableholder {
overflow-x: hidden;
overflow-anchor: none;
+ will-change: transform;
.tabulator-table {
display: block;
background-color: default;
+ content-visibility: auto;
}
}
From 0fd8cb1c76646319d497aad2966d8f113fc7d641 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Tue, 7 Apr 2026 18:43:51 +0100
Subject: [PATCH 03/14] refactor: migrate find highlights to CSS Highlight API
Replace DOM-based find/match highlighting (span insertion with
.findMatch/.currentFindMatch classes) with the CSS Highlight API.
Uses shared Highlight objects with per-instance Range tracking,
rAF-throttled scroll listener for virtual scroll, and
::highlight() pseudo-elements for styling.
---
.../analysis/components/AnalysisView.ts | 38 +-
.../call-tree/components/CalltreeView.ts | 34 +-
.../features/database/components/DMLView.ts | 28 +-
.../features/database/components/SOQLView.ts | 25 +-
log-viewer/src/styles/global.styles.ts | 13 +-
log-viewer/src/tabulator/module/Find.ts | 362 ++++++++++--------
6 files changed, 257 insertions(+), 243 deletions(-)
diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts
index ea3cd4d9..d46f920d 100644
--- a/log-viewer/src/features/analysis/components/AnalysisView.ts
+++ b/log-viewer/src/features/analysis/components/AnalysisView.ts
@@ -24,7 +24,7 @@ import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupSort } from '../../../tabulator/groups/GroupSort.js';
import * as CommonModules from '../../../tabulator/module/CommonModules.js';
-import { Find, formatter } from '../../../tabulator/module/Find.js';
+import { Find } from '../../../tabulator/module/Find.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
@@ -251,19 +251,12 @@ export class AnalysisView extends LitElement {
return;
}
this.blockClearHighlights = true;
- this.analysisTable?.blockRedraw();
const currentRow = this.findMap[this.findArgs.count];
- const rows = [
- currentRow,
- this.findMap[this.findArgs.count + 1],
- this.findMap[this.findArgs.count - 1],
- ];
- rows.forEach((row) => {
- row?.reformat();
+ //@ts-expect-error This is a custom function added in by Find custom module
+ await this.analysisTable.setCurrentMatch(this.findArgs.count, currentRow, {
+ scrollIfVisible: false,
+ focusRow: false,
});
- //@ts-expect-error This is a custom function added in by RowNavigation custom module
- this.analysisTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false });
- this.analysisTable?.restoreRedraw();
this.blockClearHighlights = false;
}
@@ -321,9 +314,6 @@ export class AnalysisView extends LitElement {
groupClosedShowCalcs: true,
groupStartOpen: false,
groupToggleElement: 'header',
- rowFormatter: (row: RowComponent) => {
- formatter(row, this.findArgs);
- },
columnDefaults: {
title: 'default',
resizable: true,
@@ -426,7 +416,21 @@ export class AnalysisView extends LitElement {
],
});
- this.analysisTable.on('renderStarted', () => {
+ this.analysisTable.on('dataSorted', () => {
+ if (!this.blockClearHighlights && this.totalMatches > 0) {
+ this._resetFindWidget();
+ this._clearSearchHighlights();
+ }
+ });
+
+ this.analysisTable.on('dataFiltered', () => {
+ if (!this.blockClearHighlights && this.totalMatches > 0) {
+ this._resetFindWidget();
+ this._clearSearchHighlights();
+ }
+ });
+
+ this.analysisTable.on('dataGrouped', () => {
if (!this.blockClearHighlights && this.totalMatches > 0) {
this._resetFindWidget();
this._clearSearchHighlights();
@@ -442,7 +446,7 @@ export class AnalysisView extends LitElement {
this.findArgs.text = '';
this.findArgs.count = 0;
//@ts-expect-error This is a custom function added in by Find custom module
- this.analysisTable.clearFindHighlights(Object.values(this.findMap));
+ this.analysisTable.clearFindHighlights();
this.findMap = {};
this.totalMatches = 0;
}
diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts
index a7f31098..01e25142 100644
--- a/log-viewer/src/features/call-tree/components/CalltreeView.ts
+++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts
@@ -24,7 +24,7 @@ import MinMaxFilter from '../../../tabulator/filters/MinMax.js';
import { progressFormatter } from '../../../tabulator/format/Progress.js';
import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import * as CommonModules from '../../../tabulator/module/CommonModules.js';
-import { Find, formatter } from '../../../tabulator/module/Find.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';
@@ -397,22 +397,12 @@ export class CalltreeView extends LitElement {
return;
}
this.blockClearHighlights = true;
- this.calltreeTable?.blockRedraw();
const currentRow = this.findMap[this.findArgs.count];
- const rows = [
- currentRow,
- this.findMap[this.findArgs.count + 1],
- this.findMap[this.findArgs.count - 1],
- ];
- rows.forEach((row) => {
- row?.reformat();
+ //@ts-expect-error This is a custom function added in by Find custom module
+ await this.calltreeTable.setCurrentMatch(this.findArgs.count, currentRow, {
+ scrollIfVisible: false,
+ focusRow: false,
});
-
- if (currentRow) {
- //@ts-expect-error This is a custom function added in by RowNavigation custom module
- this.calltreeTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false });
- }
- this.calltreeTable?.restoreRedraw();
this.blockClearHighlights = false;
}
@@ -610,9 +600,6 @@ export class CalltreeView extends LitElement {
return "
";
}
},
- rowFormatter: (row: RowComponent) => {
- formatter(row, this.findArgs);
- },
columnCalcs: 'both',
columnDefaults: {
title: 'default',
@@ -867,7 +854,14 @@ export class CalltreeView extends LitElement {
this.typeFilterCache.clear();
});
- this.calltreeTable.on('renderStarted', () => {
+ this.calltreeTable.on('dataSorted', () => {
+ if (!this.blockClearHighlights && this.totalMatches > 0) {
+ this._resetFindWidget();
+ this._clearSearchHighlights();
+ }
+ });
+
+ this.calltreeTable.on('dataFiltered', () => {
if (!this.blockClearHighlights && this.totalMatches > 0) {
this._resetFindWidget();
this._clearSearchHighlights();
@@ -899,7 +893,7 @@ export class CalltreeView extends LitElement {
this.findArgs.text = '';
this.findArgs.count = 0;
//@ts-expect-error This is a custom function added in by Find custom module
- this.calltreeTable.clearFindHighlights(Object.values(this.findMap));
+ this.calltreeTable.clearFindHighlights();
this.findMap = {};
this.totalMatches = 0;
}
diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts
index 19d551a2..c921b46f 100644
--- a/log-viewer/src/features/database/components/DMLView.ts
+++ b/log-viewer/src/features/database/components/DMLView.ts
@@ -21,7 +21,7 @@ import Number from '../../../tabulator/format/Number.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupSort } from '../../../tabulator/groups/GroupSort.js';
import * as CommonModules from '../../../tabulator/module/CommonModules.js';
-import { Find, formatter } from '../../../tabulator/module/Find.js';
+import { Find } from '../../../tabulator/module/Find.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
@@ -69,6 +69,7 @@ export class DMLView extends LitElement {
document.addEventListener('lv-find', this._findEvt);
document.addEventListener('lv-find-close', this._findEvt);
+ document.addEventListener('lv-find-match', this._findEvt);
}
updated(changedProperties: PropertyValues): void {
@@ -201,24 +202,19 @@ export class DMLView extends LitElement {
}
// todo: fix search on grouped data
- _highlightMatches(highlightIndex: number) {
+ async _highlightMatches(highlightIndex: number) {
if (!this.dmlTable?.element?.clientHeight) {
return;
}
this.findArgs.count = highlightIndex;
const currentRow = this.findMap[highlightIndex];
- const rows = [currentRow, this.findMap[this.oldIndex]];
this.blockClearHighlights = true;
- this.dmlTable.blockRedraw();
- rows.forEach((row) => {
- row?.reformat();
+ //@ts-expect-error This is a custom function added in by Find custom module
+ await this.dmlTable.setCurrentMatch(highlightIndex, currentRow, {
+ scrollIfVisible: false,
+ focusRow: false,
});
- if (currentRow) {
- //@ts-expect-error This is a custom function added in by RowNavigation custom module
- this.dmlTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false });
- }
- this.dmlTable.restoreRedraw();
this.blockClearHighlights = false;
this.oldIndex = highlightIndex;
}
@@ -230,10 +226,6 @@ export class DMLView extends LitElement {
}
const newFindArgs = JSON.parse(JSON.stringify(e.detail));
- if (!isTableVisible) {
- newFindArgs.text = '';
- }
-
const newSearch =
newFindArgs.text !== this.findArgs.text ||
newFindArgs.options.matchCase !== this.findArgs.options?.matchCase;
@@ -381,10 +373,6 @@ export class DMLView extends LitElement {
const detailContainer = this.createDetailPanel(data.timestamp);
row.getElement().replaceChildren(detailContainer);
}
-
- requestAnimationFrame(() => {
- formatter(row, this.findArgs);
- });
},
});
@@ -469,7 +457,7 @@ export class DMLView extends LitElement {
this.findArgs.text = '';
this.findArgs.count = 0;
//@ts-expect-error This is a custom function added in by Find custom module
- this.dmlTable.clearFindHighlights(Object.values(this.findMap));
+ this.dmlTable.clearFindHighlights();
this.findMap = {};
this.totalMatches = 0;
diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts
index af252a88..1fdf9866 100644
--- a/log-viewer/src/features/database/components/SOQLView.ts
+++ b/log-viewer/src/features/database/components/SOQLView.ts
@@ -27,7 +27,7 @@ import Number from '../../../tabulator/format/Number.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupSort } from '../../../tabulator/groups/GroupSort.js';
import * as CommonModules from '../../../tabulator/module/CommonModules.js';
-import { Find, formatter } from '../../../tabulator/module/Find.js';
+import { Find } from '../../../tabulator/module/Find.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
@@ -80,6 +80,7 @@ export class SOQLView extends LitElement {
document.addEventListener('lv-find', this._findEvt);
document.addEventListener('lv-find-close', this._findEvt);
+ document.addEventListener('lv-find-match', this._findEvt);
}
updated(changedProperties: PropertyValues): void {
@@ -228,7 +229,7 @@ export class SOQLView extends LitElement {
});
}
- _highlightMatches(highlightIndex: number) {
+ async _highlightMatches(highlightIndex: number) {
if (!this.soqlTable?.element?.clientHeight) {
return;
}
@@ -236,17 +237,11 @@ export class SOQLView extends LitElement {
this.findArgs.count = highlightIndex;
const currentRow = this.findMap[highlightIndex];
this.blockClearHighlights = true;
- this.soqlTable.blockRedraw();
- const rows = [currentRow, this.findMap[this.oldIndex]];
- rows.forEach((row) => {
- row?.reformat();
+ //@ts-expect-error This is a custom function added in by Find custom module
+ await this.soqlTable.setCurrentMatch(highlightIndex, currentRow, {
+ scrollIfVisible: false,
+ focusRow: false,
});
-
- if (currentRow) {
- //@ts-expect-error This is a custom function added in by RowNavigation custom module
- this.soqlTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false });
- }
- this.soqlTable.restoreRedraw();
this.blockClearHighlights = false;
this.oldIndex = highlightIndex;
@@ -503,10 +498,6 @@ export class SOQLView extends LitElement {
const detailContainer = this.createSOQLDetailPanel(data.timestamp, timestampToSOQl);
row.getElement().replaceChildren(detailContainer);
}
-
- requestAnimationFrame(() => {
- formatter(row, this.findArgs);
- });
},
});
@@ -591,7 +582,7 @@ export class SOQLView extends LitElement {
this.findArgs.text = '';
this.findArgs.count = 0;
//@ts-expect-error This is a custom function added in by Find custom module
- this.soqlTable.clearFindHighlights(Object.values(this.findMap));
+ this.soqlTable.clearFindHighlights();
this.findMap = {};
this.totalMatches = 0;
diff --git a/log-viewer/src/styles/global.styles.ts b/log-viewer/src/styles/global.styles.ts
index ec816c8b..5ad6d034 100644
--- a/log-viewer/src/styles/global.styles.ts
+++ b/log-viewer/src/styles/global.styles.ts
@@ -31,18 +31,13 @@ export const globalStyles = css`
background-color: var(--vscode-scrollbarSlider-background);
}
- .findMatch {
- animation-duration: 0;
- animation-name: inherit !important;
+ ::highlight(find-match) {
color: var(--vscode-editor-findMatchForeground);
- background-color: var(--vscode-editor-findMatchHighlightBackground, 'yellow');
+ background-color: var(--vscode-editor-findMatchHighlightBackground, yellow);
}
- .currentFindMatch {
+ ::highlight(current-find-match) {
color: var(--vscode-editor-findMatchHighlightForeground);
- background-color: var(--vscode-editor-findMatchBackground, '#8B8000');
- border: 2px solid var(--vscode-editor-findMatchBorder);
- padding: 1px;
- box-sizing: border-box;
+ background-color: var(--vscode-editor-findMatchBackground, #8b8000);
}
`;
diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts
index 3bc46b2b..9c681c77 100644
--- a/log-viewer/src/tabulator/module/Find.ts
+++ b/log-viewer/src/tabulator/module/Find.ts
@@ -3,18 +3,68 @@
*/
import { Module, type GroupComponent, type RowComponent, type Tabulator } from 'tabulator-tables';
+type FindArgs = { text: string; count: number; options: { matchCase: boolean } };
+type GoToRowOptions = { scrollIfVisible: boolean; focusRow: boolean };
+
export class Find extends Module {
static moduleName = 'FindModule';
+ // Shared across all Find instances — one per highlight type
+ static _findHighlight: Highlight | null = null;
+ static _currentHighlight: Highlight | null = null;
+
+ // Per-instance range tracking for cleanup without affecting other instances
+ _myFindRanges: Range[] = [];
+ _myCurrentRanges: Range[] = [];
+ _findArgs: FindArgs | null = null;
+ _currentMatchIndex = 0;
+ _matchIndexes: { [key: number]: RowComponent } = {};
+
constructor(table: Tabulator) {
super(table);
// @ts-expect-error registerTableFunction() needs adding to tabulator types
this.registerTableFunction('find', this._find.bind(this));
- // @ts-expect-error registerTableFunction() needs adding to tabulator types
this.registerTableFunction('clearFindHighlights', this._clearFindHighlights.bind(this));
+ // @ts-expect-error registerTableFunction() needs adding to tabulator types
+ this.registerTableFunction('setCurrentMatch', this._setCurrentMatch.bind(this));
}
- initialize() {}
+ initialize() {
+ this.table.on('renderComplete', () => {
+ if (this._findArgs?.text) {
+ this._applyHighlights();
+ }
+ });
+
+ // Virtual scroll doesn't fire renderComplete, so listen for scroll events
+ // to apply highlights to newly visible rows. Debounced to avoid blocking
+ // the main thread during fast scrolling, plus scrollend for instant final update.
+ const holder = this.table.element.querySelector('.tabulator-tableholder');
+ if (holder) {
+ let rafId: number | null = null;
+ holder.addEventListener('scroll', () => {
+ if (!this._findArgs?.text) {
+ return;
+ }
+ if (rafId === null) {
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ this._applyHighlights();
+ });
+ }
+ });
+
+ holder.addEventListener('scrollend', () => {
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ if (this._findArgs?.text) {
+ this._applyHighlights();
+ }
+ });
+ }
+ }
async _find(findArgs: FindArgs) {
const result: { totalMatches: number; matchIndexes: { [key: number]: RowComponent } } = {
@@ -22,7 +72,9 @@ export class Find extends Module {
matchIndexes: {},
};
- this._clearMatches();
+ this._clearInstanceRanges();
+ this._currentMatchIndex = 0;
+ this._matchIndexes = {};
// We only do this when groups exist to get row order
const flattenFromGrps = (row: GroupComponent): RowComponent[] => {
@@ -31,7 +83,7 @@ export class Find extends Module {
row
.getSubGroups()
.flatMap(flattenFromGrps)
- .forEach((child) => {
+ .forEach((child: RowComponent) => {
mergedArray.push(child);
});
return mergedArray;
@@ -39,29 +91,25 @@ export class Find extends Module {
const tbl = this.table;
const grps = tbl.getGroups().flatMap(flattenFromGrps);
- const flattenedRows = grps.length ? grps : this._getRows(tbl.getRows('active'));
+ const flattenedRows: RowComponent[] = grps.length ? grps : this._getRows(tbl.getRows('active'));
const findOptions = findArgs.options;
let searchString = findOptions.matchCase ? findArgs.text : findArgs.text.toLowerCase();
searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&');
const regex = new RegExp(searchString, `g${findArgs.options.matchCase ? '' : 'i'}`);
- tbl.blockRedraw();
+ // Reset highlightIndexes on all rows (no reformat needed with CSS Highlight API)
for (const row of flattenedRows) {
const data = row.getData();
- if (data.highlightIndexes?.length > 0) {
- data.highlightIndexes.length = 0;
- row.reformat();
- } else if (!data.highlightIndexes) {
+ if (!data.highlightIndexes) {
data.highlightIndexes = [];
+ } else {
+ data.highlightIndexes.length = 0;
}
}
- tbl.restoreRedraw();
- await new Promise((resolve) => requestAnimationFrame(resolve));
let totalMatches = 0;
if (searchString) {
- const rowsToReformat = new Set();
const len = flattenedRows.length;
for (let i = 0; i < len; i++) {
const row = flattenedRows[i];
@@ -73,73 +121,187 @@ export class Find extends Module {
data.highlightIndexes = [];
row.getCells().forEach((cell) => {
const elem = cell.getElement();
- const matchCount = this._countMatches(elem, findArgs, regex);
+ const matchCount = this._countMatches(elem, regex);
if (matchCount) {
- const kLen = matchCount;
- for (let k = 0; k < kLen; k++) {
+ for (let k = 0; k < matchCount; k++) {
totalMatches++;
data.highlightIndexes.push(totalMatches);
result.matchIndexes[totalMatches] = row;
}
- rowsToReformat.add(row);
}
});
}
- tbl.blockRedraw();
- rowsToReformat.forEach((row) => {
- row?.reformat();
- });
- tbl.restoreRedraw();
}
result.totalMatches = totalMatches;
+ this._findArgs = findArgs;
+ this._matchIndexes = result.matchIndexes;
+ this._applyHighlights();
+
return result;
}
- _clearFindHighlights(rows: RowComponent[]) {
- this.table.blockRedraw();
+ async _setCurrentMatch(index: number, row?: RowComponent, goToRowOpts?: GoToRowOptions) {
+ this._currentMatchIndex = index;
+ if (this._findArgs) {
+ this._findArgs.count = index;
+ }
+ this._applyHighlights();
+
+ if (row) {
+ try {
+ // @ts-expect-error goToRow is a custom function added by RowNavigation module
+ await this.table.goToRow(row, goToRowOpts);
+ } finally {
+ this._applyHighlights();
+ }
+ }
+ }
+
+ _applyHighlights() {
+ // Lazy-init static Highlights
+ if (!Find._findHighlight) {
+ Find._findHighlight = new Highlight();
+ }
+ if (!Find._currentHighlight) {
+ Find._currentHighlight = new Highlight();
+ }
+
+ // Detach highlights during modification to prevent per-range style recalc
+ CSS.highlights.delete('find-match');
+ CSS.highlights.delete('current-find-match');
+
+ // Clear this instance's old ranges from the shared Highlights
+ this._clearInstanceRanges();
+
+ if (!this._findArgs?.text) {
+ CSS.highlights.set('find-match', Find._findHighlight);
+ CSS.highlights.set('current-find-match', Find._currentHighlight);
+ return;
+ }
+
+ const findOptions = this._findArgs.options;
+ let searchString = findOptions.matchCase
+ ? this._findArgs.text
+ : this._findArgs.text.toLowerCase();
+ searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&');
+ const regex = new RegExp(searchString, `g${findOptions.matchCase ? '' : 'i'}`);
+
+ const rows = this._getRenderedRows();
for (const row of rows) {
+ // Skip GroupComponents — they don't have getData
+ if (typeof row.getData !== 'function') {
+ continue;
+ }
+
const data = row.getData();
- data.highlightIndexes = [];
- row.reformat();
+
+ let matchIdx = 0;
+ row.getCells().forEach((cell) => {
+ const elem = cell.getElement();
+ this._walkTextNodes(elem, (textNode) => {
+ const text = textNode.textContent;
+ if (!text) {
+ return;
+ }
+
+ regex.lastIndex = 0;
+ let match: RegExpExecArray | null;
+ while ((match = regex.exec(text)) !== null) {
+ const highlightIndex = data.highlightIndexes?.[matchIdx];
+ matchIdx++;
+
+ const range = new Range();
+ range.setStart(textNode, match.index);
+ range.setEnd(textNode, match.index + match[0].length);
+
+ if (highlightIndex === this._currentMatchIndex) {
+ Find._currentHighlight!.add(range);
+ this._myCurrentRanges.push(range);
+ } else {
+ Find._findHighlight!.add(range);
+ this._myFindRanges.push(range);
+ }
+ }
+ });
+ });
}
- this.table.restoreRedraw();
+
+ // Re-attach highlights — browser applies all ranges in a single paint
+ CSS.highlights.set('find-match', Find._findHighlight!);
+ CSS.highlights.set('current-find-match', Find._currentHighlight!);
}
- _countMatches(elem: Node, findArgs: FindArgs, regex: RegExp) {
- let count = 0;
+ _clearFindHighlights() {
+ this._clearInstanceRanges();
+ this._findArgs = null;
+ this._currentMatchIndex = 0;
+ this._matchIndexes = {};
+ }
+
+ _clearInstanceRanges() {
+ for (const range of this._myFindRanges) {
+ Find._findHighlight?.delete(range);
+ }
+ for (const range of this._myCurrentRanges) {
+ Find._currentHighlight?.delete(range);
+ }
+ this._myFindRanges = [];
+ this._myCurrentRanges = [];
+ }
+ _walkTextNodes(node: Node, callback: (textNode: Text) => void) {
const children =
//@ts-expect-error renderRoot does not exist on node and we should probably not access it but there is no other option at the moment
- (elem.childNodes?.length ? elem.childNodes : elem.renderRoot?.childNodes) ?? [];
+ (node.childNodes?.length ? node.childNodes : node.renderRoot?.childNodes) ?? [];
const len = children.length;
for (let i = 0; i < len; i++) {
const cur = children[i];
if (!cur) {
continue;
}
-
if (cur.nodeType === 1) {
- count += this._countMatches(cur, findArgs, regex);
+ this._walkTextNodes(cur, callback);
} else if (cur.nodeType === 3) {
- const originalText = cur.textContent;
- if (!originalText) {
- continue;
- }
- const match = originalText.match(regex);
- count += match?.length ?? 0;
+ callback(cur as Text);
}
}
+ }
+
+ _countMatches(elem: Node, regex: RegExp): number {
+ let count = 0;
+ this._walkTextNodes(elem, (textNode) => {
+ const text = textNode.textContent;
+ if (!text) {
+ return;
+ }
+ const match = text.match(regex);
+ count += match?.length ?? 0;
+ });
return count;
}
- _getRows(rows: RowComponent[]) {
+ _getRenderedRows(): RowComponent[] {
+ // Returns all rendered rows including buffer (not just viewport).
+ // This allows highlights to be applied to off-screen buffer rows,
+ // eliminating the "pop in" effect when they scroll into view.
+ const renderer = this.table.rowManager?.renderer;
+ if (renderer && renderer.vDomTop !== undefined) {
+ const displayRows = this.table.rowManager.getDisplayRows();
+ return displayRows
+ .slice(renderer.vDomTop, renderer.vDomBottom + 1)
+ .map((row: { getComponent: () => RowComponent }) => row.getComponent());
+ }
+ return this.table.getRows('visible');
+ }
+
+ _getRows(rows: RowComponent[]): RowComponent[] {
const isDataTreeEnabled = this.table.modules.dataTree && this.table.options.dataTreeFilter;
if (!isDataTreeEnabled) {
return rows;
}
- const children = [];
+ const children: RowComponent[] = [];
for (const row of rows) {
children.push(row);
for (const child of this._getFilteredChildren(row)) {
@@ -164,10 +326,8 @@ export class Find extends Module {
sorting.sort(internalChildren, true);
}
- const filteredChildren = [];
for (const internalChild of internalChildren) {
const childComp: RowComponent = internalChild.getComponent();
- filteredChildren.push(childComp);
output.push(childComp);
const subChildren = this._getFilteredChildren(childComp);
@@ -178,122 +338,4 @@ export class Find extends Module {
return output;
}
-
- _clearMatches() {
- const matches = this.table.element.querySelectorAll('.currentFindMatch, .findMatch');
- for (const elm of matches) {
- const previous = elm.previousSibling;
- const next = elm.nextSibling;
- if (previous) {
- const newText = (previous.textContent ?? '') + elm.textContent;
- previous.textContent = newText;
-
- if (next) {
- const newText = (previous.textContent ?? '') + next.textContent;
- previous.textContent = newText;
- }
- elm.remove();
- } else {
- if (next) {
- const newText = (elm.textContent ?? '') + next.textContent;
- elm.textContent = newText;
- next.remove();
- }
- elm.classList.remove('currentFindMatch', 'findMatch');
- }
- }
- }
}
-
-export function formatter(row: RowComponent, findArgs: FindArgs) {
- const { text } = findArgs;
- if (!text) {
- return;
- }
- const data = row.getData();
- if (!data || !data.highlightIndexes?.length) {
- return;
- }
-
- const highlights = {
- indexes: data.highlightIndexes,
- currentMatch: 0,
- };
- row.getCells().forEach((cell) => {
- const cellElem = cell.getElement();
- _highlightText(cellElem, findArgs, highlights);
- });
-
- //@ts-expect-error This is private to tabulator, but we have no other choice atm.
- if (row._getSelf().type === 'row') {
- row.normalizeHeight();
- }
-}
-
-function _highlightText(
- elem: Node,
- findArgs: FindArgs,
- highlights: { indexes: number[]; currentMatch: number },
-) {
- const searchText = findArgs.options.matchCase ? findArgs.text : findArgs.text.toLowerCase();
- const matchHighlightIndex = findArgs.count;
-
- //@ts-expect-error renderRoot does not exist on node and we should probably not access it but there is no other option at the moment
- const children = (elem.childNodes?.length ? elem.childNodes : elem.renderRoot?.childNodes) ?? [];
- const len = children.length;
- for (let i = 0; i < len; i++) {
- const cur = children[i];
- if (!cur) {
- continue;
- }
-
- if (cur.nodeType === 1) {
- _highlightText(cur, findArgs, highlights);
- } else if (cur.nodeType === 3) {
- const parentNode = cur.parentNode;
- let originalText = cur.textContent;
- if (!originalText) {
- continue;
- }
-
- let matchIndex = (
- findArgs.options.matchCase ? originalText : originalText?.toLowerCase()
- )?.indexOf(searchText);
- while (matchIndex > -1) {
- const hightlightIndex = highlights.indexes[highlights.currentMatch++];
-
- const endOfMatchIndex = matchIndex + searchText.length;
- const matchingText = originalText.substring(matchIndex, endOfMatchIndex);
-
- const highlightSpan = document.createElement('span');
- highlightSpan.className =
- hightlightIndex === matchHighlightIndex ? 'currentFindMatch' : 'findMatch';
- highlightSpan.textContent = matchingText;
- if (parentNode.isEqualNode(highlightSpan)) {
- break;
- }
-
- if (matchIndex > 0) {
- const beforeText = originalText.substring(0, matchIndex);
- const beforeTextElem = document.createElement('text');
- beforeTextElem.textContent = beforeText;
- parentNode?.insertBefore(beforeTextElem, cur);
- }
- parentNode?.insertBefore(highlightSpan, cur);
-
- const endText = originalText.substring(endOfMatchIndex, originalText.length);
- if (!endText.length) {
- parentNode?.removeChild(cur);
- break;
- }
- cur.textContent = endText;
- originalText = endText;
- matchIndex = (
- findArgs.options.matchCase ? originalText : originalText?.toLowerCase()
- )?.indexOf(searchText);
- }
- }
- }
-}
-
-type FindArgs = { text: string; count: number; options: { matchCase: boolean } };
From 18e53beda891a56bc5cfdccc0c31be835e76529e Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Tue, 7 Apr 2026 18:47:36 +0100
Subject: [PATCH 04/14] refactor: improve goToRow with promise-based flow and
scroll fallback
Return a Promise from goToRow for async coordination. Use
scrollIntoView as fallback after tabulator's scrollToRow to handle
edge cases near the bottom of the grid. Check element connectivity
in _isVisible and compare against tableholder bounds instead of
window.
---
.../src/tabulator/module/RowNavigation.ts | 109 ++++++++++--------
1 file changed, 64 insertions(+), 45 deletions(-)
diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts
index 9ce3b407..02b9023c 100644
--- a/log-viewer/src/tabulator/module/RowNavigation.ts
+++ b/log-viewer/src/tabulator/module/RowNavigation.ts
@@ -13,70 +13,89 @@ export class RowNavigation extends Module {
this.registerTableFunction('goToRow', this.goToRow.bind(this));
}
- goToRow(row: RowComponent, opts: GoToRowOptions = { scrollIfVisible: true, focusRow: true }) {
- if (row) {
- const { focusRow } = opts;
+ goToRow(
+ row: RowComponent,
+ opts: GoToRowOptions = { scrollIfVisible: true, focusRow: true },
+ ): Promise {
+ if (!row) {
+ return Promise.resolve();
+ }
- const table = this.table;
- this.tableHolder ??= table.element.querySelector('.tabulator-tableholder') as HTMLElement;
+ const { focusRow } = opts;
- table.blockRedraw();
+ const table = this.table;
+ this.tableHolder ??= table.element.querySelector('.tabulator-tableholder') as HTMLElement;
- const grp = row.getGroup();
- if (grp && !grp.isVisible()) {
- grp.show();
- }
+ table.blockRedraw();
- const rowsToExpand = [];
- //@ts-expect-error This is private to tabulator, but we have no other choice atm.
- let parent = row._getSelf().modules.dataTree ? row.getTreeParent() : false;
- while (parent) {
- if (!parent.isTreeExpanded()) {
- rowsToExpand.push(parent);
- }
- parent = parent.getTreeParent();
+ const grp = row.getGroup();
+ if (grp && !grp.isVisible()) {
+ grp.show();
+ }
+
+ const rowsToExpand = [];
+ //@ts-expect-error This is private to tabulator, but we have no other choice atm.
+ let parent = row._getSelf().modules.dataTree ? row.getTreeParent() : false;
+ while (parent) {
+ if (!parent.isTreeExpanded()) {
+ rowsToExpand.push(parent);
}
+ parent = parent.getTreeParent();
+ }
- rowsToExpand.forEach((row) => {
- row.treeExpand();
- });
+ for (const row of rowsToExpand) {
+ row.treeExpand();
+ }
- table.getSelectedRows().forEach((rowToDeselect) => {
- rowToDeselect.deselect();
- });
- row.select();
- table.restoreRedraw();
+ for (const row of table.getSelectedRows()) {
+ row.deselect();
+ }
- if (focusRow) {
- this.tableHolder.focus();
- }
- if (row) {
- setTimeout(() => this._scrollToRow(row, opts));
- }
+ row.select();
+ table.restoreRedraw();
+
+ if (focusRow) {
+ this.tableHolder.focus();
}
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ this._scrollToRow(row, opts).then(resolve);
+ });
+ });
}
- _scrollToRow(row: RowComponent, opts: GoToRowOptions) {
+ _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise {
const { scrollIfVisible, focusRow } = opts;
- this.table.scrollToRow(row, 'center', scrollIfVisible).then(() => {
- setTimeout(() => {
- const elem = row.getElement();
+ return this.table
+ .scrollToRow(row, 'center', scrollIfVisible)
+ .catch(() => {})
+ .then(() => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ const elem = row.getElement();
+
+ if (scrollIfVisible || !this._isVisible(elem)) {
+ elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
+ }
- if (scrollIfVisible || !this._isVisible(elem)) {
- // NOTE: work around because this.table.scrollToRow does not work correctly when the row is near the very bottom of the grid.
- elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
- }
+ if (focusRow) {
+ elem.focus();
+ }
- if (focusRow) {
- elem.focus();
- }
+ resolve();
+ });
+ });
});
- });
}
_isVisible(el: Element) {
+ if (!this.tableHolder || !el.isConnected) {
+ return false;
+ }
+ const holderRect = this.tableHolder.getBoundingClientRect();
const rect = el.getBoundingClientRect();
- return rect.top >= 0 && rect.bottom <= window.innerHeight;
+ return rect.top >= holderRect.top && rect.bottom <= holderRect.bottom;
}
}
From f8ebd42fa8fcb415f02f4ff26913557560935ebe Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Wed, 8 Apr 2026 12:01:22 +0100
Subject: [PATCH 05/14] refactor: remove setTimeout deferrals from
RowNavigation scroll flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two setTimeout wrappers in goToRow and _scrollToRow were deferring
execution unnecessarily — goToRow now calls _scrollToRow directly, and
_scrollToRow resolves synchronously after scrollIntoView rather than deferring
to the next tick.
---
.../src/tabulator/module/RowNavigation.ts | 24 +++++++------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts
index 02b9023c..1bf787cb 100644
--- a/log-viewer/src/tabulator/module/RowNavigation.ts
+++ b/log-viewer/src/tabulator/module/RowNavigation.ts
@@ -58,11 +58,7 @@ export class RowNavigation extends Module {
this.tableHolder.focus();
}
- return new Promise((resolve) => {
- setTimeout(() => {
- this._scrollToRow(row, opts).then(resolve);
- });
- });
+ return this._scrollToRow(row, opts);
}
_scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise {
@@ -73,19 +69,17 @@ export class RowNavigation extends Module {
.catch(() => {})
.then(() => {
return new Promise((resolve) => {
- setTimeout(() => {
- const elem = row.getElement();
+ const elem = row.getElement();
- if (scrollIfVisible || !this._isVisible(elem)) {
- elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
- }
+ if (scrollIfVisible || !this._isVisible(elem)) {
+ elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
+ }
- if (focusRow) {
- elem.focus();
- }
+ if (focusRow) {
+ elem.focus();
+ }
- resolve();
- });
+ resolve();
});
});
}
From 449f4eaf1c4a3a8d604555c46441ce5043dcb6b6 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Wed, 8 Apr 2026 12:03:02 +0100
Subject: [PATCH 06/14] perf: extract and optimise call tree name column
formatter
Extracts the inline name formatter into CalltreeNameFormatter.ts using a
factory/closure pattern. Key hot-path improvements: replaces
createElement('span') with document.createTextNode() (no element allocation
for plain text rows), eliminates private row._row.modules.dataTree access by
computing treeLevel once in _toCallTree and storing it in row data, and reads
dataTreeChildIndent via this.table on first render only.
---
.../components/CalltreeNameFormatter.ts | 39 +++++++++++++++++++
.../call-tree/components/CalltreeView.ts | 38 ++++--------------
2 files changed, 46 insertions(+), 31 deletions(-)
create mode 100644 log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts
diff --git a/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts
new file mode 100644
index 00000000..35bc4a33
--- /dev/null
+++ b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2022 Certinia Inc. All rights reserved.
+ */
+import type { LogEvent, LogEventType } from 'apex-log-parser';
+import type { CellComponent, EmptyCallback } from 'tabulator-tables';
+
+export function createCalltreeNameFormatter(excludedTypes: Set) {
+ let childIndent: number = 9;
+
+ return function calltreeNameFormatter(
+ cell: CellComponent,
+ _formatterParams: object,
+ _onRendered: EmptyCallback,
+ ): string | HTMLElement {
+ const data = cell.getData() as { originalData: LogEvent; treeLevel: number };
+ const { originalData: node, treeLevel } = data;
+ // @ts-expect-error this.table is added by tabulator when the formatter is called, but isn't in the types for some reason
+ childIndent ??= this.table.options.dataTreeChildIndent ?? 9;
+ const levelIndent = treeLevel * childIndent;
+
+ const cellElem = cell.getElement();
+ cellElem.style.paddingLeft = `${levelIndent + 4}px`;
+ cellElem.style.textIndent = `-${levelIndent}px`;
+
+ if (node.hasValidSymbols) {
+ const link = document.createElement('a');
+ link.setAttribute('href', '#!');
+ link.textContent = node.text;
+ return link;
+ }
+
+ let text = node.text;
+ if (node.type && node.type !== text && !excludedTypes.has(node.type)) {
+ text = node.type + ': ' + text;
+ }
+
+ return document.createTextNode(text) as unknown as HTMLElement;
+ };
+}
diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts
index 01e25142..d84d3dd4 100644
--- a/log-viewer/src/features/call-tree/components/CalltreeView.ts
+++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts
@@ -29,6 +29,7 @@ import { MiddleRowFocus } from '../../../tabulator/module/MiddleRowFocus.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
+import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js';
// styles
import { globalStyles } from '../../../styles/global.styles.js';
@@ -570,7 +571,7 @@ export class CalltreeView extends LitElement {
const excludedTypes = new Set(['SOQL_EXECUTE_BEGIN', 'DML_BEGIN']);
const governorLimits = rootMethod.governorLimits;
- let childIndent;
+ const nameFormatter = createCalltreeNameFormatter(excludedTypes);
this.calltreeTable = new Tabulator(callTreeTableContainer, {
data: this._toCallTree(rootMethod.children),
layout: 'fitColumns',
@@ -617,34 +618,7 @@ export class CalltreeView extends LitElement {
return 'Total';
},
cssClass: 'datagrid-textarea datagrid-code-text',
- formatter: (cell, _formatterParams, _onRendered) => {
- const cellElem = cell.getElement();
- const row = cell.getRow();
- // @ts-expect-error: _row is private. This is temporary and I will patch the text wrap behaviour in the library.
- const dataTree = row._row.modules.dataTree;
- const treeLevel = dataTree?.index ?? 0;
- childIndent ??= row.getTable().options.dataTreeChildIndent || 0;
- const levelIndent = treeLevel * childIndent;
- cellElem.style.paddingLeft = `${levelIndent + 4}px`;
- cellElem.style.textIndent = `-${levelIndent}px`;
-
- const node = (cell.getData() as CalltreeRow).originalData;
- let text = node.text;
- if (node.hasValidSymbols) {
- const link = document.createElement('a');
- link.setAttribute('href', '#!');
- link.textContent = text;
- return link;
- }
-
- if (node.type && !excludedTypes.has(node.type) && node.type !== text) {
- text = node.type + ': ' + text;
- }
-
- const textSpan = document.createElement('span');
- textSpan.textContent = text;
- return textSpan;
- },
+ formatter: nameFormatter,
variableHeight: true,
cellClick: (e, cell) => {
const { type } = window.getSelection() ?? {};
@@ -977,7 +951,7 @@ export class CalltreeView extends LitElement {
}
}
- private _toCallTree(nodes: LogEvent[]): CalltreeRow[] | undefined {
+ private _toCallTree(nodes: LogEvent[], treeLevel = 0): CalltreeRow[] | undefined {
const len = nodes.length;
if (!len) {
return undefined;
@@ -989,12 +963,13 @@ export class CalltreeView extends LitElement {
if (!node) {
continue;
}
- const children = node.children.length ? this._toCallTree(node.children) : null;
+ const children = node.children.length ? this._toCallTree(node.children, treeLevel + 1) : null;
results.push({
id: node.timestamp + '-' + i,
originalData: node,
_children: children,
text: node.text,
+ treeLevel,
namespace: node.namespace,
duration: node.duration,
dmlCount: node.dmlCount,
@@ -1070,6 +1045,7 @@ interface CalltreeRow {
originalData: LogEvent;
_children: CalltreeRow[] | undefined | null;
text: string;
+ treeLevel: number;
duration: CountTotals;
namespace: string;
dmlCount: CountTotals;
From df6ab9adb79e4aba46ce27fd7546714b62e65a72 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Thu, 9 Apr 2026 14:31:32 +0100
Subject: [PATCH 07/14] fix: correct scroll-to-row centering for virtual DOM
bottom-pad bug
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace scrollIntoView with \_centerRow that restores vDomBottomPad
before setting scrollTop via offsetTop. Tabulator's \_addBottomRow
zeroes vDomBottomPad when vDomBottom reaches the last row index —
even for mid-table rows — shrinking scrollHeight and preventing
center alignment. Also refactor \_scrollToRow to async/await.
---
.../src/tabulator/module/RowNavigation.ts | 69 ++++++++++++++-----
1 file changed, 50 insertions(+), 19 deletions(-)
diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts
index 1bf787cb..d1e4524e 100644
--- a/log-viewer/src/tabulator/module/RowNavigation.ts
+++ b/log-viewer/src/tabulator/module/RowNavigation.ts
@@ -13,7 +13,7 @@ export class RowNavigation extends Module {
this.registerTableFunction('goToRow', this.goToRow.bind(this));
}
- goToRow(
+ async goToRow(
row: RowComponent,
opts: GoToRowOptions = { scrollIfVisible: true, focusRow: true },
): Promise {
@@ -58,30 +58,27 @@ export class RowNavigation extends Module {
this.tableHolder.focus();
}
- return this._scrollToRow(row, opts);
+ return new Promise((resolve) => {
+ // Need to wait for any pending redraws to finish before scrolling or it will not work
+ setTimeout(() => {
+ this._scrollToRow(row, opts).then(resolve);
+ });
+ });
}
- _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise {
+ async _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise {
const { scrollIfVisible, focusRow } = opts;
- return this.table
- .scrollToRow(row, 'center', scrollIfVisible)
- .catch(() => {})
- .then(() => {
- return new Promise((resolve) => {
- const elem = row.getElement();
-
- if (scrollIfVisible || !this._isVisible(elem)) {
- elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
- }
+ await this.table.scrollToRow(row, 'center', scrollIfVisible);
- if (focusRow) {
- elem.focus();
- }
+ const elem = row.getElement();
+ if (scrollIfVisible || !this._isVisible(elem)) {
+ this._centerRow(elem);
+ }
- resolve();
- });
- });
+ if (focusRow) {
+ elem.focus();
+ }
}
_isVisible(el: Element) {
@@ -92,4 +89,38 @@ export class RowNavigation extends Module {
const rect = el.getBoundingClientRect();
return rect.top >= holderRect.top && rect.bottom <= holderRect.bottom;
}
+
+ // TODO: Remove once fixed upstream in tabulator-tables.
+ //
+ // Tabulator bug: _addBottomRow zeroes vDomBottomPad when vDomBottom reaches the last
+ // row index — even for mid-table rows in expanded trees. This shrinks scrollHeight,
+ // clamping scrollTop so scrollToRow places the row at the viewport bottom not center.
+ // Fix: restore the minimum vDomBottomPad needed for centering, then set scrollTop
+ // directly via offsetTop.
+ _centerRow(elem: HTMLElement) {
+ if (!this.tableHolder) return;
+
+ // Only near-bottom rows have vDomBottomPad forced to 0 — skip the DOM write for
+ // all other rows where Tabulator already set it correctly.
+ const renderer = this.table.rowManager?.renderer as Record | undefined;
+ if (renderer && this.tableHolder && ((renderer.vDomBottomPad as number) ?? 0) === 0) {
+ const displayRows: unknown[] = this.table.rowManager?.getDisplayRows?.() ?? [];
+ const vDomBottom = (renderer.vDomBottom as number) ?? 0;
+ const vDomRowHeight = (renderer.vDomRowHeight as number) ?? 24;
+ const truePad = Math.max(0, (displayRows.length - vDomBottom - 1) * vDomRowHeight);
+ // Cap at clientHeight/2 — the maximum extra scroll range needed to center any row.
+ // Avoids large paddingBottom values for mid-table rows. If truePad < clientHeight/2
+ // the row is genuinely near the bottom and the browser clamps naturally — no blank space.
+ const neededPad = Math.min(truePad, this.tableHolder.clientHeight / 2);
+ if (neededPad > 0) {
+ renderer.vDomBottomPad = neededPad;
+ (renderer.tableElement as HTMLElement).style.paddingBottom = `${neededPad}px`;
+ }
+ }
+
+ // Reading elem.offsetTop forces a layout flush — paddingBottom is included in
+ // scrollHeight before scrollTop is assigned.
+ const holderHeight = this.tableHolder.clientHeight;
+ this.tableHolder.scrollTop = elem.offsetTop - holderHeight / 2 + elem.offsetHeight / 2;
+ }
}
From 17497a809f5d0361d2fa3fb3e9fa4052bf281ada Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Thu, 9 Apr 2026 14:47:10 +0100
Subject: [PATCH 08/14] perf: optimise find search with headless formatters and
CSS Highlight API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace per-cell DOM-based highlighting with CSS Highlight API ranges,
eliminating row.reformat() calls during search. Extract searchable text
via headless formatter execution with a reusable mock cell and per-row
cache, bypassing getCells() and its O(rows × cols) DOM element creation.
Skip innerHTML parsing for non-HTML formatter results. Highlight ranges
now span across adjacent text nodes for cross-element match support.
---
log-viewer/src/tabulator/module/Find.ts | 259 ++++++++++++++++++++----
1 file changed, 217 insertions(+), 42 deletions(-)
diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts
index 9c681c77..e45b95ff 100644
--- a/log-viewer/src/tabulator/module/Find.ts
+++ b/log-viewer/src/tabulator/module/Find.ts
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2024 Certinia Inc. All rights reserved.
*/
-import { Module, type GroupComponent, type RowComponent, type Tabulator } from 'tabulator-tables';
+import {
+ Module,
+ type CellComponent,
+ type ColumnComponent,
+ type GroupComponent,
+ type RowComponent,
+ type Tabulator,
+} from 'tabulator-tables';
type FindArgs = { text: string; count: number; options: { matchCase: boolean } };
type GoToRowOptions = { scrollIfVisible: boolean; focusRow: boolean };
@@ -20,6 +27,18 @@ export class Find extends Module {
_currentMatchIndex = 0;
_matchIndexes: { [key: number]: RowComponent } = {};
+ // Headless formatter execution: single detached element (never in the document)
+ // and a per-row-field text cache keyed by the stable row-data object reference.
+ _mockSearchElem: HTMLElement = document.createElement('div');
+ _cellTextCache: WeakMap