From 6fe9cb7b0f0de7a1c603e8b7233ab89f61f9cf30 Mon Sep 17 00:00:00 2001 From: cam Date: Mon, 19 Jan 2026 14:45:22 -0800 Subject: [PATCH 1/3] fix: match search across block breaks without whitespace --- .../src/extensions/search/SearchIndex.js | 8 +++- .../search/search-cross-paragraph.test.js | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/search/SearchIndex.js b/packages/super-editor/src/extensions/search/SearchIndex.js index 37970bc671..ed15702a82 100644 --- a/packages/super-editor/src/extensions/search/SearchIndex.js +++ b/packages/super-editor/src/extensions/search/SearchIndex.js @@ -273,7 +273,13 @@ export class SearchIndex { // Split by whitespace (including non-breaking spaces), escape each part, rejoin with flexible whitespace pattern const parts = searchString.split(/[\s\u00a0]+/).filter((part) => part.length > 0); if (parts.length === 0) return ''; - return parts.map((part) => SearchIndex.escapeRegex(part)).join('[\\s\\u00a0]+'); + const blockSeparatorPattern = '(?:\\n)?'; + const escapedParts = parts.map((part) => { + const chars = Array.from(part); + if (chars.length === 0) return ''; + return chars.map((ch) => SearchIndex.escapeRegex(ch)).join(blockSeparatorPattern); + }); + return escapedParts.join('[\\s\\u00a0]+'); } /** diff --git a/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js b/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js index b5afef3ab6..2c68382441 100644 --- a/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js +++ b/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js @@ -122,6 +122,27 @@ describe('Cross-paragraph search', () => { } }); + it('should find matches across paragraph boundaries without whitespace in query', () => { + const editor = createDocxTestEditor(); + + try { + const { doc, paragraph, run } = editor.schema.nodes; + const testDoc = doc.create(null, [ + paragraph.create(null, [run.create(null, [editor.schema.text('February 7, 2023')])]), + paragraph.create(null, [run.create(null, [editor.schema.text('Via Electronic Mail')])]), + ]); + + const index = new SearchIndex(); + index.build(testDoc); + + const matches = index.search('2023Via'); + + expect(matches.length).toBeGreaterThan(0); + } finally { + editor.destroy(); + } + }); + it('should handle case-insensitive search', () => { const editor = createDocxTestEditor(); @@ -252,6 +273,32 @@ describe('Cross-paragraph search', () => { editor.destroy(); } }); + + it('should map cross-paragraph match without whitespace to multiple ranges', () => { + const editor = createDocxTestEditor(); + + try { + const { doc, paragraph, run } = editor.schema.nodes; + const testDoc = doc.create(null, [ + paragraph.create(null, [run.create(null, [editor.schema.text('February 7, 2023')])]), + paragraph.create(null, [run.create(null, [editor.schema.text('Via Electronic Mail')])]), + ]); + + const index = new SearchIndex(); + index.build(testDoc); + + const matches = index.search('2023Via'); + expect(matches.length).toBeGreaterThan(0); + + const ranges = index.offsetRangeToDocRanges(matches[0].start, matches[0].end); + expect(ranges).toHaveLength(2); + + const combinedText = ranges.map((range) => testDoc.textBetween(range.from, range.to)).join(''); + expect(combinedText).toBe('2023Via'); + } finally { + editor.destroy(); + } + }); }); }); From 8fe5f52d6506f8bebe0cc4e2dce07d0cb947ba56 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 20 Jan 2026 18:59:41 -0800 Subject: [PATCH 2/3] fix: remove dead code and add toFlexiblePattern unit tests --- .../src/extensions/search/SearchIndex.js | 1 - .../search/search-cross-paragraph.test.js | 35 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/extensions/search/SearchIndex.js b/packages/super-editor/src/extensions/search/SearchIndex.js index 26ffec1f0e..6ee22165c1 100644 --- a/packages/super-editor/src/extensions/search/SearchIndex.js +++ b/packages/super-editor/src/extensions/search/SearchIndex.js @@ -281,7 +281,6 @@ export class SearchIndex { const blockSeparatorPattern = '(?:\\n)?'; const escapedParts = parts.map((part) => { const chars = Array.from(part); - if (chars.length === 0) return ''; return chars.map((ch) => SearchIndex.escapeRegex(ch)).join(blockSeparatorPattern); }); let pattern = escapedParts.join('[\\s\\u00a0]+'); diff --git a/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js b/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js index 2c68382441..7adf62bda4 100644 --- a/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js +++ b/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js @@ -137,7 +137,8 @@ describe('Cross-paragraph search', () => { const matches = index.search('2023Via'); - expect(matches.length).toBeGreaterThan(0); + expect(matches).toHaveLength(1); + expect(matches[0].text).toBe('2023\nVia'); } finally { editor.destroy(); } @@ -224,6 +225,38 @@ describe('Cross-paragraph search', () => { }); }); + describe('toFlexiblePattern', () => { + it('should generate pattern with block separators between characters', () => { + const pattern = SearchIndex.toFlexiblePattern('abc'); + expect(pattern).toBe('a(?:\\n)?b(?:\\n)?c'); + }); + + it('should handle multi-word input with whitespace between words', () => { + const pattern = SearchIndex.toFlexiblePattern('ab cd'); + expect(pattern).toBe('a(?:\\n)?b[\\s\\u00a0]+c(?:\\n)?d'); + }); + + it('should preserve leading whitespace in pattern', () => { + const pattern = SearchIndex.toFlexiblePattern(' abc'); + expect(pattern).toBe('[\\s\\u00a0]+a(?:\\n)?b(?:\\n)?c'); + }); + + it('should preserve trailing whitespace in pattern', () => { + const pattern = SearchIndex.toFlexiblePattern('abc '); + expect(pattern).toBe('a(?:\\n)?b(?:\\n)?c[\\s\\u00a0]+'); + }); + + it('should return empty string for empty input', () => { + const pattern = SearchIndex.toFlexiblePattern(''); + expect(pattern).toBe(''); + }); + + it('should return whitespace pattern for whitespace-only input', () => { + const pattern = SearchIndex.toFlexiblePattern(' '); + expect(pattern).toBe('[\\s\\u00a0]+'); + }); + }); + describe('offset mapping', () => { it('should map single-paragraph match to correct doc positions', () => { const editor = createDocxTestEditor(); From 6094bea3ed9414c62acea553984811ef6981bb41 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 21 Jan 2026 10:33:52 -0800 Subject: [PATCH 3/3] fix: allow search across multiple consecutive block separators --- .../src/extensions/search/SearchIndex.js | 2 +- .../search/search-cross-paragraph.test.js | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/extensions/search/SearchIndex.js b/packages/super-editor/src/extensions/search/SearchIndex.js index 6ee22165c1..5949696892 100644 --- a/packages/super-editor/src/extensions/search/SearchIndex.js +++ b/packages/super-editor/src/extensions/search/SearchIndex.js @@ -278,7 +278,7 @@ export class SearchIndex { if (parts.length === 0) { return hasLeadingWhitespace || hasTrailingWhitespace ? '[\\s\\u00a0]+' : ''; } - const blockSeparatorPattern = '(?:\\n)?'; + const blockSeparatorPattern = '(?:\\n)*'; const escapedParts = parts.map((part) => { const chars = Array.from(part); return chars.map((ch) => SearchIndex.escapeRegex(ch)).join(blockSeparatorPattern); diff --git a/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js b/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js index 7adf62bda4..d82ec2cae7 100644 --- a/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js +++ b/packages/super-editor/src/tests/extensions/search/search-cross-paragraph.test.js @@ -228,22 +228,22 @@ describe('Cross-paragraph search', () => { describe('toFlexiblePattern', () => { it('should generate pattern with block separators between characters', () => { const pattern = SearchIndex.toFlexiblePattern('abc'); - expect(pattern).toBe('a(?:\\n)?b(?:\\n)?c'); + expect(pattern).toBe('a(?:\\n)*b(?:\\n)*c'); }); it('should handle multi-word input with whitespace between words', () => { const pattern = SearchIndex.toFlexiblePattern('ab cd'); - expect(pattern).toBe('a(?:\\n)?b[\\s\\u00a0]+c(?:\\n)?d'); + expect(pattern).toBe('a(?:\\n)*b[\\s\\u00a0]+c(?:\\n)*d'); }); it('should preserve leading whitespace in pattern', () => { const pattern = SearchIndex.toFlexiblePattern(' abc'); - expect(pattern).toBe('[\\s\\u00a0]+a(?:\\n)?b(?:\\n)?c'); + expect(pattern).toBe('[\\s\\u00a0]+a(?:\\n)*b(?:\\n)*c'); }); it('should preserve trailing whitespace in pattern', () => { const pattern = SearchIndex.toFlexiblePattern('abc '); - expect(pattern).toBe('a(?:\\n)?b(?:\\n)?c[\\s\\u00a0]+'); + expect(pattern).toBe('a(?:\\n)*b(?:\\n)*c[\\s\\u00a0]+'); }); it('should return empty string for empty input', () => { @@ -255,6 +255,12 @@ describe('Cross-paragraph search', () => { const pattern = SearchIndex.toFlexiblePattern(' '); expect(pattern).toBe('[\\s\\u00a0]+'); }); + + it('should match across multiple consecutive block separators', () => { + const pattern = SearchIndex.toFlexiblePattern('ab'); + const regex = new RegExp(pattern); + expect(regex.test('a\n\n\nb')).toBe(true); + }); }); describe('offset mapping', () => {