11/*
22 * Copyright (c) 2024 Certinia Inc. All rights reserved.
33 */
4- import { Module , type GroupComponent , type RowComponent , type Tabulator } from 'tabulator-tables' ;
4+ import {
5+ Module ,
6+ type CellComponent ,
7+ type ColumnComponent ,
8+ type GroupComponent ,
9+ type RowComponent ,
10+ type Tabulator ,
11+ } from 'tabulator-tables' ;
512
613type FindArgs = { text : string ; count : number ; options : { matchCase : boolean } } ;
714type GoToRowOptions = { scrollIfVisible : boolean ; focusRow : boolean } ;
@@ -20,6 +27,18 @@ export class Find extends Module {
2027 _currentMatchIndex = 0 ;
2128 _matchIndexes : { [ key : number ] : RowComponent } = { } ;
2229
30+ // Headless formatter execution: single detached element (never in the document)
31+ // and a per-row-field text cache keyed by the stable row-data object reference.
32+ _mockSearchElem : HTMLElement = document . createElement ( 'div' ) ;
33+ _cellTextCache : WeakMap < object , Map < string , string > > = new WeakMap ( ) ;
34+
35+ // Reusable mock cell — mutable fields updated before each formatter call.
36+ _mcData : object = { } ;
37+ _mcValue : unknown = undefined ;
38+ _mcField = '' ;
39+ _mcColumn : ColumnComponent | null = null ;
40+ _mockCell : CellComponent = this . _createMockCell ( ) ;
41+
2342 constructor ( table : Tabulator ) {
2443 super ( table ) ;
2544 // @ts -expect-error registerTableFunction() needs adding to tabulator types
@@ -30,6 +49,12 @@ export class Find extends Module {
3049 }
3150
3251 initialize ( ) {
52+ // Reset the text cache whenever a new dataset is loaded so stale entries
53+ // from the previous log never pollute a fresh search.
54+ this . table . on ( 'tableBuilt' , ( ) => {
55+ this . _cellTextCache = new WeakMap ( ) ;
56+ } ) ;
57+
3358 this . table . on ( 'renderComplete' , ( ) => {
3459 if ( this . _findArgs ?. text ) {
3560 this . _applyHighlights ( ) ;
@@ -110,6 +135,28 @@ export class Find extends Module {
110135
111136 let totalMatches = 0 ;
112137 if ( searchString ) {
138+ // Avoid row.getCells() — for uninitialized off-screen rows it calls generateCells()
139+ // which creates a DOM element per cell (document.createElement). For 10k rows that
140+ // is O(rows × cols) element creation before a single search character is matched.
141+ // Instead iterate columnsByIndex directly, which is the same array generateCells()
142+ // would use, avoiding all Cell object and DOM element creation.
143+ // columnManager.getRealColumns() is internal — returns columnsByIndex, same order as getCells()
144+ const internalCols : Array < {
145+ field : string ;
146+ getComponent : ( ) => ColumnComponent ;
147+ getFieldValue : ( data : object ) => unknown ;
148+ modules ?: {
149+ format ?: {
150+ formatter ?: (
151+ cell : CellComponent ,
152+ params : object ,
153+ onRendered : ( ) => void ,
154+ ) => string | HTMLElement ;
155+ params ?: object | ( ( cell : CellComponent ) => object ) ;
156+ } ;
157+ } ;
158+ } > = this . table . columnManager ?. getRealColumns ?.( ) ?? [ ] ;
159+
113160 const len = flattenedRows . length ;
114161 for ( let i = 0 ; i < len ; i ++ ) {
115162 const row = flattenedRows [ i ] ;
@@ -119,17 +166,39 @@ export class Find extends Module {
119166
120167 const data = row . getData ( ) ;
121168 data . highlightIndexes = [ ] ;
122- row . getCells ( ) . forEach ( ( cell ) => {
123- const elem = cell . getElement ( ) ;
124- const matchCount = this . _countMatches ( elem , regex ) ;
169+
170+ let rowCache = this . _cellTextCache . get ( data as object ) ;
171+ if ( ! rowCache ) {
172+ rowCache = new Map < string , string > ( ) ;
173+ this . _cellTextCache . set ( data as object , rowCache ) ;
174+ }
175+
176+ for ( const col of internalCols ) {
177+ const field = col . field ;
178+ if ( ! field ) continue ;
179+
180+ let text = rowCache . get ( field ) ;
181+ if ( text === undefined ) {
182+ text = this . _runFormatterForColumn (
183+ data as object ,
184+ field ,
185+ col . getFieldValue ( data as object ) ,
186+ col . getComponent ( ) ,
187+ col . modules ?. format ,
188+ ) ;
189+ rowCache . set ( field , text ) ;
190+ }
191+
192+ regex . lastIndex = 0 ;
193+ const matchCount = text . match ( regex ) ?. length ?? 0 ;
125194 if ( matchCount ) {
126195 for ( let k = 0 ; k < matchCount ; k ++ ) {
127196 totalMatches ++ ;
128197 data . highlightIndexes . push ( totalMatches ) ;
129198 result . matchIndexes [ totalMatches ] = row ;
130199 }
131200 }
132- } ) ;
201+ }
133202 }
134203 }
135204
@@ -199,31 +268,32 @@ export class Find extends Module {
199268 let matchIdx = 0 ;
200269 row . getCells ( ) . forEach ( ( cell ) => {
201270 const elem = cell . getElement ( ) ;
202- this . _walkTextNodes ( elem , ( textNode ) => {
203- const text = textNode . textContent ;
204- if ( ! text ) {
205- return ;
206- }
207-
208- regex . lastIndex = 0 ;
209- let match : RegExpExecArray | null ;
210- while ( ( match = regex . exec ( text ) ) !== null ) {
211- const highlightIndex = data . highlightIndexes ?. [ matchIdx ] ;
212- matchIdx ++ ;
213-
214- const range = new Range ( ) ;
215- range . setStart ( textNode , match . index ) ;
216- range . setEnd ( textNode , match . index + match [ 0 ] . length ) ;
217-
218- if ( highlightIndex === this . _currentMatchIndex ) {
219- Find . _currentHighlight ! . add ( range ) ;
220- this . _myCurrentRanges . push ( range ) ;
221- } else {
222- Find . _findHighlight ! . add ( range ) ;
223- this . _myFindRanges . push ( range ) ;
224- }
271+ // Build a flat text-node map so we can create Ranges that span across
272+ // adjacent elements (e.g. two <span>s whose text forms a single match).
273+ const { text : fullText , nodes : textNodeMap } = this . _buildTextNodeMap ( elem ) ;
274+ if ( ! fullText ) return ;
275+
276+ regex . lastIndex = 0 ;
277+ let match : RegExpExecArray | null ;
278+ while ( ( match = regex . exec ( fullText ) ) !== null ) {
279+ const highlightIndex = data . highlightIndexes ?. [ matchIdx ] ;
280+ matchIdx ++ ;
281+
282+ const range = this . _createMatchRange (
283+ textNodeMap ,
284+ match . index ,
285+ match . index + match [ 0 ] . length ,
286+ ) ;
287+ if ( ! range ) continue ;
288+
289+ if ( highlightIndex === this . _currentMatchIndex ) {
290+ Find . _currentHighlight ! . add ( range ) ;
291+ this . _myCurrentRanges . push ( range ) ;
292+ } else {
293+ Find . _findHighlight ! . add ( range ) ;
294+ this . _myFindRanges . push ( range ) ;
225295 }
226- } ) ;
296+ }
227297 } ) ;
228298 }
229299
@@ -268,19 +338,6 @@ export class Find extends Module {
268338 }
269339 }
270340
271- _countMatches ( elem : Node , regex : RegExp ) : number {
272- let count = 0 ;
273- this . _walkTextNodes ( elem , ( textNode ) => {
274- const text = textNode . textContent ;
275- if ( ! text ) {
276- return ;
277- }
278- const match = text . match ( regex ) ;
279- count += match ?. length ?? 0 ;
280- } ) ;
281- return count ;
282- }
283-
284341 _getRenderedRows ( ) : RowComponent [ ] {
285342 // Returns all rendered rows including buffer (not just viewport).
286343 // This allows highlights to be applied to off-screen buffer rows,
@@ -338,4 +395,122 @@ export class Find extends Module {
338395
339396 return output ;
340397 }
398+
399+ // Collects all text nodes under root with their cumulative byte offsets into a
400+ // flat array, plus the concatenated full text. Used by _applyHighlights so
401+ // that a single regex match can span multiple sibling elements.
402+ _buildTextNodeMap ( root : Node ) : { text : string ; nodes : Array < { node : Text ; start : number } > } {
403+ const nodes : Array < { node : Text ; start : number } > = [ ] ;
404+ let offset = 0 ;
405+ this . _walkTextNodes ( root , ( textNode ) => {
406+ nodes . push ( { node : textNode , start : offset } ) ;
407+ offset += textNode . textContent ?. length ?? 0 ;
408+ } ) ;
409+ return { text : nodes . map ( ( n ) => n . node . textContent ?? '' ) . join ( '' ) , nodes } ;
410+ }
411+
412+ // Creates a Range covering [matchStart, matchEnd) in the text-node map
413+ // produced by _buildTextNodeMap. Supports ranges that cross node boundaries.
414+ _createMatchRange (
415+ nodes : Array < { node : Text ; start : number } > ,
416+ matchStart : number ,
417+ matchEnd : number ,
418+ ) : Range | null {
419+ const range = new Range ( ) ;
420+ let startSet = false ;
421+
422+ for ( const { node, start } of nodes ) {
423+ const nodeEnd = start + ( node . textContent ?. length ?? 0 ) ;
424+
425+ if ( ! startSet && matchStart < nodeEnd ) {
426+ range . setStart ( node , matchStart - start ) ;
427+ startSet = true ;
428+ }
429+
430+ if ( startSet && matchEnd <= nodeEnd ) {
431+ range . setEnd ( node , matchEnd - start ) ;
432+ return range ;
433+ }
434+ }
435+
436+ return null ;
437+ }
438+
439+ // Runs the column formatter headlessly for a given row data + column, without
440+ // requiring a CellComponent. This avoids row.getCells(), which triggers
441+ // generateCells() and DOM element creation for every uninitialized off-screen row.
442+ // Results are cached by (rowData, field) and reused on repeat searches.
443+ _createMockCell ( ) : CellComponent {
444+ const mockElem = this . _mockSearchElem ;
445+ return {
446+ getElement : ( ) => mockElem ,
447+ getData : ( ) => this . _mcData ,
448+ getValue : ( ) => this . _mcValue ,
449+ getInitialValue : ( ) => this . _mcValue ,
450+ getField : ( ) => this . _mcField ,
451+ getRow : ( ) => ( { getData : ( ) => this . _mcData } ) as unknown as RowComponent ,
452+ getColumn : ( ) => this . _mcColumn ! ,
453+ checkHeight : ( ) => { } ,
454+ edit : ( ) => { } ,
455+ cancelEdit : ( ) => { } ,
456+ isEdited : ( ) => false ,
457+ clearEdited : ( ) => { } ,
458+ isValid : ( ) => true ,
459+ clearValidation : ( ) => { } ,
460+ validate : ( ) => true ,
461+ popup : ( ) => { } ,
462+ } as unknown as CellComponent ;
463+ }
464+
465+ _runFormatterForColumn (
466+ data : object ,
467+ field : string ,
468+ value : unknown ,
469+ columnComponent : ColumnComponent ,
470+ fmt :
471+ | {
472+ formatter ?: (
473+ cell : CellComponent ,
474+ params : object ,
475+ onRendered : ( ) => void ,
476+ ) => string | HTMLElement ;
477+ params ?: object | ( ( cell : CellComponent ) => object ) ;
478+ }
479+ | undefined ,
480+ ) : string {
481+ if ( ! fmt ?. formatter ) {
482+ return String ( value ?? '' ) ;
483+ }
484+
485+ this . _mcData = data ;
486+ this . _mcValue = value ;
487+ this . _mcField = field ;
488+ this . _mcColumn = columnComponent ;
489+
490+ const mockCell = this . _mockCell ;
491+ const resolvedParams =
492+ typeof fmt . params === 'function' ? fmt . params ( mockCell ) : ( fmt . params ?? { } ) ;
493+
494+ let result : string | HTMLElement | undefined ;
495+ try {
496+ const ctx : object = this . table . modules ?. format ?? { table : this . table } ;
497+ result = fmt . formatter . call ( ctx , mockCell , resolvedParams , ( ) => { } ) ;
498+ } catch {
499+ return String ( value ?? '' ) ;
500+ }
501+
502+ if ( typeof result === 'string' ) {
503+ if ( result . includes ( '<' ) && result . includes ( '>' ) ) {
504+ const mockElem = this . _mockSearchElem ;
505+ mockElem . innerHTML = result ;
506+ const text = mockElem . textContent ?? '' ;
507+ mockElem . textContent = '' ;
508+ return text ;
509+ }
510+
511+ return result ;
512+ }
513+
514+ return result ?. textContent ?? '' ;
515+ }
341516}
0 commit comments