2424
2525const BLOCK_SEPARATOR = '\n' ;
2626const ATOM_PLACEHOLDER = '\ufffc' ;
27+ const DELETION_BARRIER = '\u0000' ;
28+ const DEFAULT_SEARCH_MODEL = 'raw' ;
29+
30+ const hasTrackDeleteMark = ( node ) => node ?. marks ?. some ( ( mark ) => mark ?. type ?. name === 'trackDelete' ) ?? false ;
2731
2832/**
2933 * SearchIndex provides a lazily-built, cached index for searching across
@@ -48,31 +52,107 @@ export class SearchIndex {
4852 /** @type {import('prosemirror-model').Node | null } */
4953 doc = null ;
5054
55+ /** @type {'raw'|'visible' } */
56+ searchModel = DEFAULT_SEARCH_MODEL ;
57+
5158 /**
5259 * Build the search index from a ProseMirror document.
5360 * Uses doc.textBetween for the flattened string and walks
5461 * the document to build the segment offset map.
5562 *
5663 * @param {import('prosemirror-model').Node } doc - The ProseMirror document
5764 */
58- build ( doc ) {
59- // Get the flattened text using ProseMirror's optimized textBetween
60- this . text = doc . textBetween ( 0 , doc . content . size , BLOCK_SEPARATOR , ATOM_PLACEHOLDER ) ;
65+ build ( doc , options = { } ) {
66+ const searchModel = options ?. searchModel === 'visible' ? 'visible' : DEFAULT_SEARCH_MODEL ;
67+
68+ if ( searchModel === 'visible' ) {
69+ this . #buildVisible( doc ) ;
70+ } else {
71+ // Get the flattened text using ProseMirror's optimized textBetween
72+ this . text = doc . textBetween ( 0 , doc . content . size , BLOCK_SEPARATOR , ATOM_PLACEHOLDER ) ;
73+ }
74+
6175 this . segments = [ ] ;
6276 this . docSize = doc . content . size ;
6377 this . doc = doc ;
78+ this . searchModel = searchModel ;
6479
6580 // Walk the document to build the segment map
6681 // Note: doc node's content starts at position 0 (doc has no opening tag)
6782 let offset = 0 ;
68- this . #walkNodeContent( doc , 0 , offset , ( segment ) => {
69- this . segments . push ( segment ) ;
70- offset = segment . offsetEnd ;
71- } ) ;
83+ const visibleContext = searchModel === 'visible' ? { deletionBarrierActive : false } : null ;
84+ this . #walkNodeContent(
85+ doc ,
86+ 0 ,
87+ offset ,
88+ ( segment ) => {
89+ this . segments . push ( segment ) ;
90+ offset = segment . offsetEnd ;
91+ } ,
92+ searchModel ,
93+ visibleContext ,
94+ ) ;
7295
7396 this . valid = true ;
7497 }
7598
99+ /**
100+ * Build flattened text for the `visible` model, where tracked deletions
101+ * are removed from searchable text and replaced with a non-searchable
102+ * barrier to prevent false collapsed matches.
103+ *
104+ * @param {import('prosemirror-model').Node } doc
105+ */
106+ #buildVisible( doc ) {
107+ const parts = [ ] ;
108+ let emittedDeletionBarrier = false ;
109+
110+ const appendDeletionBarrier = ( ) => {
111+ if ( emittedDeletionBarrier ) return ;
112+ parts . push ( DELETION_BARRIER ) ;
113+ emittedDeletionBarrier = true ;
114+ } ;
115+
116+ const walkNodeContent = ( node ) => {
117+ let isFirstChild = true ;
118+ node . forEach ( ( child ) => {
119+ if ( child . isBlock && ! isFirstChild ) {
120+ parts . push ( BLOCK_SEPARATOR ) ;
121+ emittedDeletionBarrier = false ;
122+ }
123+ walkNode ( child ) ;
124+ isFirstChild = false ;
125+ } ) ;
126+ } ;
127+
128+ const walkNode = ( node ) => {
129+ if ( node . isText ) {
130+ const text = node . text || '' ;
131+ if ( ! text . length ) return ;
132+
133+ if ( hasTrackDeleteMark ( node ) ) {
134+ appendDeletionBarrier ( ) ;
135+ return ;
136+ }
137+
138+ parts . push ( text ) ;
139+ emittedDeletionBarrier = false ;
140+ return ;
141+ }
142+
143+ if ( node . isLeaf ) {
144+ parts . push ( ATOM_PLACEHOLDER ) ;
145+ emittedDeletionBarrier = false ;
146+ return ;
147+ }
148+
149+ walkNodeContent ( node ) ;
150+ } ;
151+
152+ walkNodeContent ( doc ) ;
153+ this . text = parts . join ( '' ) ;
154+ }
155+
76156 /**
77157 * Walk the content of a node to build segments.
78158 * This method processes the children of a node, given the position
@@ -84,7 +164,7 @@ export class SearchIndex {
84164 * @param {(segment: Segment) => void } addSegment - Callback to add a segment
85165 * @returns {number } The new offset after processing this node's content
86166 */
87- #walkNodeContent( node , contentStart , offset , addSegment ) {
167+ #walkNodeContent( node , contentStart , offset , addSegment , searchModel = DEFAULT_SEARCH_MODEL , context = null ) {
88168 let currentOffset = offset ;
89169 let isFirstChild = true ;
90170
@@ -101,9 +181,12 @@ export class SearchIndex {
101181 kind : 'blockSep' ,
102182 } ) ;
103183 currentOffset += 1 ;
184+ if ( context && searchModel === 'visible' ) {
185+ context . deletionBarrierActive = false ;
186+ }
104187 }
105188
106- currentOffset = this . #walkNode( child , childDocPos , currentOffset , addSegment ) ;
189+ currentOffset = this . #walkNode( child , childDocPos , currentOffset , addSegment , searchModel , context ) ;
107190 isFirstChild = false ;
108191 } ) ;
109192
@@ -119,11 +202,31 @@ export class SearchIndex {
119202 * @param {(segment: Segment) => void } addSegment - Callback to add a segment
120203 * @returns {number } The new offset after processing this node
121204 */
122- #walkNode( node , docPos , offset , addSegment ) {
205+ #walkNode( node , docPos , offset , addSegment , searchModel = DEFAULT_SEARCH_MODEL , context = null ) {
123206 if ( node . isText ) {
207+ if ( searchModel === 'visible' && hasTrackDeleteMark ( node ) ) {
208+ if ( context ?. deletionBarrierActive ) {
209+ return offset ;
210+ }
211+ addSegment ( {
212+ offsetStart : offset ,
213+ offsetEnd : offset + 1 ,
214+ docFrom : docPos ,
215+ docTo : docPos ,
216+ kind : 'atom' ,
217+ } ) ;
218+ if ( context ) {
219+ context . deletionBarrierActive = true ;
220+ }
221+ return offset + 1 ;
222+ }
223+
124224 // Text node: add a text segment
125225 const text = node . text || '' ;
126226 if ( text . length > 0 ) {
227+ if ( context && searchModel === 'visible' ) {
228+ context . deletionBarrierActive = false ;
229+ }
127230 addSegment ( {
128231 offsetStart : offset ,
129232 offsetEnd : offset + text . length ,
@@ -137,6 +240,9 @@ export class SearchIndex {
137240 }
138241
139242 if ( node . isLeaf ) {
243+ if ( context && searchModel === 'visible' ) {
244+ context . deletionBarrierActive = false ;
245+ }
140246 // Leaf node (atom): check if it's a hard_break or other atom
141247 if ( node . type . name === 'hard_break' ) {
142248 addSegment ( {
@@ -161,7 +267,7 @@ export class SearchIndex {
161267
162268 // For non-leaf nodes, recurse into content
163269 // Content starts at docPos + 1 (after opening tag)
164- return this . #walkNodeContent( node , docPos + 1 , offset , addSegment ) ;
270+ return this . #walkNodeContent( node , docPos + 1 , offset , addSegment , searchModel , context ) ;
165271 }
166272
167273 /**
@@ -177,8 +283,9 @@ export class SearchIndex {
177283 * @param {import('prosemirror-model').Node } doc - The document to check against
178284 * @returns {boolean } True if index is stale and needs rebuilding
179285 */
180- isStale ( doc ) {
181- return ! this . valid || this . doc !== doc ;
286+ isStale ( doc , options = { } ) {
287+ const searchModel = options ?. searchModel === 'visible' ? 'visible' : DEFAULT_SEARCH_MODEL ;
288+ return ! this . valid || this . doc !== doc || this . searchModel !== searchModel ;
182289 }
183290
184291 /**
@@ -187,9 +294,9 @@ export class SearchIndex {
187294 *
188295 * @param {import('prosemirror-model').Node } doc - The document
189296 */
190- ensureValid ( doc ) {
191- if ( this . isStale ( doc ) ) {
192- this . build ( doc ) ;
297+ ensureValid ( doc , options = { } ) {
298+ if ( this . isStale ( doc , options ) ) {
299+ this . build ( doc , options ) ;
193300 }
194301 }
195302
0 commit comments