Skip to content

Commit a63e00d

Browse files
artem-harbourArtem Nistuley
andauthored
feat: match word search behavior for tracked changes (#3025)
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
1 parent 5d061f9 commit a63e00d

12 files changed

Lines changed: 413 additions & 32 deletions

File tree

packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export const makeDefaultItems = ({
324324

325325
const renderSearchDropdown = () => {
326326
const handleSubmit = ({ value }) => {
327-
superToolbar.activeEditor.commands.search(value);
327+
superToolbar.activeEditor.commands.search(value, { searchModel: 'visible' });
328328
};
329329

330330
return h('div', {}, [

packages/super-editor/src/editors/v1/document-api-adapters/find-adapter.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Node as ProseMirrorNode } from 'prosemirror-model';
22
import type { Editor } from '../core/Editor.js';
33
import type { Query } from '@superdoc/document-api';
4-
import { findLegacyAdapter } from './find-adapter.js';
4+
import { findLegacyAdapter, sdFindAdapter } from './find-adapter.js';
55

66
// ---------------------------------------------------------------------------
77
// Helpers — lightweight ProseMirror-like stubs
@@ -703,6 +703,22 @@ describe('findLegacyAdapter — text selectors', () => {
703703
expect(capturedOptions!.maxMatches).toBe(Infinity);
704704
});
705705

706+
it('uses visible search model for text selector queries', () => {
707+
let capturedOptions: Record<string, unknown> | undefined;
708+
const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 });
709+
const search: SearchFn = (_pattern, options) => {
710+
capturedOptions = options as Record<string, unknown>;
711+
return [];
712+
};
713+
const editor = makeEditor(doc, search);
714+
const query: Query = { select: { type: 'text', pattern: 'tracked' } };
715+
716+
findLegacyAdapter(editor, query);
717+
718+
expect(capturedOptions).toBeDefined();
719+
expect(capturedOptions!.searchModel).toBe('visible');
720+
});
721+
706722
it('throws when editor has no search command', () => {
707723
const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 });
708724
const editor = makeEditor(doc); // no search command
@@ -894,6 +910,51 @@ describe('findLegacyAdapter — text selectors', () => {
894910
});
895911
});
896912

913+
describe('sdFindAdapter — tracked changes text search defaults', () => {
914+
it('uses visible model semantics for delete/add/collapsed queries', () => {
915+
const doc = buildDoc('beforeDELETEafter ADD', {
916+
typeName: 'paragraph',
917+
attrs: { sdBlockId: 'p1' },
918+
nodeSize: 50,
919+
offset: 0,
920+
});
921+
922+
const search: SearchFn = (pattern, options) => {
923+
const source = pattern instanceof RegExp ? pattern.source : String(pattern);
924+
const searchModel = (options as { searchModel?: string } | undefined)?.searchModel ?? 'raw';
925+
const isVisible = searchModel === 'visible';
926+
927+
if (source === 'DELETE') {
928+
return isVisible ? [] : [{ from: 7, to: 13, text: 'DELETE' }];
929+
}
930+
if (source === 'ADD') {
931+
return [{ from: 18, to: 21, text: 'ADD' }];
932+
}
933+
if (source === 'beforeafter') {
934+
return isVisible ? [] : [{ from: 0, to: 11, text: 'beforeafter' }];
935+
}
936+
937+
return [];
938+
};
939+
940+
const editor = makeEditor(doc, search);
941+
942+
const deletedOnly = sdFindAdapter(editor, { select: { type: 'text', pattern: 'DELETE' } });
943+
const addedOnly = sdFindAdapter(editor, { select: { type: 'text', pattern: 'ADD' } });
944+
const collapsed = sdFindAdapter(editor, { select: { type: 'text', pattern: 'beforeafter' } });
945+
946+
expect(deletedOnly.total).toBe(0);
947+
expect(deletedOnly.items).toHaveLength(0);
948+
949+
expect(addedOnly.total).toBe(1);
950+
expect(addedOnly.items).toHaveLength(1);
951+
expect(addedOnly.items[0].address).toEqual({ kind: 'block', nodeType: 'paragraph', nodeId: 'p1' });
952+
953+
expect(collapsed.total).toBe(0);
954+
expect(collapsed.items).toHaveLength(0);
955+
});
956+
});
957+
897958
// ---------------------------------------------------------------------------
898959
// Context / snippet building
899960
// ---------------------------------------------------------------------------

packages/super-editor/src/editors/v1/document-api-adapters/find/text-strategy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export function executeTextSelector(
105105
highlight: false,
106106
caseSensitive: selector.caseSensitive ?? false,
107107
maxMatches: Infinity,
108+
searchModel: 'visible',
108109
});
109110

110111
if (!Array.isArray(rawResult)) {

packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424

2525
const BLOCK_SEPARATOR = '\n';
2626
const 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

Comments
 (0)