@bendyline/squisq-editor-react 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +505 -15
  3. package/dist/index.js +6035 -2283
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +870 -40
  6. package/package.json +6 -5
  7. package/src/EditorContext.tsx +27 -0
  8. package/src/EditorShell.tsx +24 -0
  9. package/src/PreviewControls.tsx +20 -5
  10. package/src/PreviewPanel.tsx +5 -1
  11. package/src/RawEditor.tsx +12 -0
  12. package/src/RecorderEntry.tsx +3 -0
  13. package/src/Toolbar.tsx +315 -17
  14. package/src/WysiwygEditor.tsx +25 -11
  15. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  16. package/src/__tests__/editorShellProps.test.tsx +65 -0
  17. package/src/__tests__/findMode.test.tsx +104 -0
  18. package/src/__tests__/findModel.test.ts +55 -0
  19. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  20. package/src/__tests__/mediaReferences.test.ts +15 -0
  21. package/src/__tests__/previewControls.test.tsx +31 -0
  22. package/src/__tests__/tiptapBridge.test.ts +9 -0
  23. package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
  24. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  25. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  26. package/src/buildPreviewDoc.ts +9 -7
  27. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  28. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  29. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  30. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  31. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  32. package/src/codeSnippet/codeSnippetData.ts +43 -0
  33. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  34. package/src/diagram/DiagramCanvas.tsx +1 -0
  35. package/src/find/FindHighlightExtension.ts +81 -0
  36. package/src/find/FindToolbar.tsx +314 -0
  37. package/src/find/findModel.ts +77 -0
  38. package/src/index.ts +89 -0
  39. package/src/markdownCodeFence.ts +72 -0
  40. package/src/mediaReferences.ts +5 -3
  41. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  42. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  43. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  44. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  45. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  46. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  47. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  48. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  49. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  50. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  51. package/src/mermaid/mermaidCommands.ts +34 -0
  52. package/src/mermaid/mermaidData.ts +31 -0
  53. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  54. package/src/mermaid/mermaidModel.ts +31 -0
  55. package/src/mermaid/mermaidRenderer.ts +181 -0
  56. package/src/mermaid/mermaidShapes.ts +113 -0
  57. package/src/mermaid/mermaidSourceOps.ts +454 -0
  58. package/src/scene/Scene.tsx +46 -15
  59. package/src/scene/SceneBlockToolbar.tsx +29 -14
  60. package/src/scene/SceneViewControls.tsx +43 -0
  61. package/src/scene/__tests__/fitScale.test.ts +19 -0
  62. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  63. package/src/scene/fitScale.ts +17 -0
  64. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  65. package/src/scene/scene.css +85 -3
  66. package/src/styles/code-snippet.css +76 -0
  67. package/src/styles/editor.css +322 -2
  68. package/src/styles/index.css +2 -0
  69. package/src/styles/mermaid-diagram.css +487 -0
  70. package/src/writeCanvasSettings.ts +30 -0
@@ -0,0 +1,216 @@
1
+ /** Languages offered by the Insert Code Snippet menu and understood by Monaco. */
2
+
3
+ export interface CodeSnippetLanguage {
4
+ /** Language written after the opening Markdown fence. */
5
+ readonly fenceLanguage: string;
6
+ /** Human-readable picker/header label. */
7
+ readonly label: string;
8
+ /** Monaco language id used for syntax highlighting and language services. */
9
+ readonly monacoLanguage: string;
10
+ /** Small, immediately editable body inserted for a new snippet. */
11
+ readonly starter: string;
12
+ }
13
+
14
+ export const CODE_SNIPPET_LANGUAGES: readonly CodeSnippetLanguage[] = [
15
+ {
16
+ fenceLanguage: 'typescript',
17
+ label: 'TypeScript',
18
+ monacoLanguage: 'typescript',
19
+ starter: "const message: string = 'Hello, world!';",
20
+ },
21
+ {
22
+ fenceLanguage: 'javascript',
23
+ label: 'JavaScript',
24
+ monacoLanguage: 'javascript',
25
+ starter: "const message = 'Hello, world!';",
26
+ },
27
+ {
28
+ fenceLanguage: 'tsx',
29
+ label: 'TSX',
30
+ monacoLanguage: 'typescript',
31
+ starter: 'export function Component() {\n return <div>Hello, world!</div>;\n}',
32
+ },
33
+ {
34
+ fenceLanguage: 'jsx',
35
+ label: 'JSX',
36
+ monacoLanguage: 'javascript',
37
+ starter: 'export function Component() {\n return <div>Hello, world!</div>;\n}',
38
+ },
39
+ {
40
+ fenceLanguage: 'json',
41
+ label: 'JSON',
42
+ monacoLanguage: 'json',
43
+ starter: '{\n "key": "value"\n}',
44
+ },
45
+ {
46
+ fenceLanguage: 'html',
47
+ label: 'HTML',
48
+ monacoLanguage: 'html',
49
+ starter: '<div>Hello, world!</div>',
50
+ },
51
+ {
52
+ fenceLanguage: 'css',
53
+ label: 'CSS',
54
+ monacoLanguage: 'css',
55
+ starter: '.example {\n color: #2563eb;\n}',
56
+ },
57
+ {
58
+ fenceLanguage: 'python',
59
+ label: 'Python',
60
+ monacoLanguage: 'python',
61
+ starter: 'print("Hello, world!")',
62
+ },
63
+ {
64
+ fenceLanguage: 'bash',
65
+ label: 'Shell',
66
+ monacoLanguage: 'shell',
67
+ starter: 'echo "Hello, world!"',
68
+ },
69
+ {
70
+ fenceLanguage: 'sql',
71
+ label: 'SQL',
72
+ monacoLanguage: 'sql',
73
+ starter: 'SELECT *\nFROM table_name;',
74
+ },
75
+ {
76
+ fenceLanguage: 'yaml',
77
+ label: 'YAML',
78
+ monacoLanguage: 'yaml',
79
+ starter: 'key: value',
80
+ },
81
+ {
82
+ fenceLanguage: 'markdown',
83
+ label: 'Markdown',
84
+ monacoLanguage: 'markdown',
85
+ starter: '# Heading',
86
+ },
87
+ {
88
+ fenceLanguage: 'java',
89
+ label: 'Java',
90
+ monacoLanguage: 'java',
91
+ starter: 'class Main {\n public static void main(String[] args) {\n }\n}',
92
+ },
93
+ {
94
+ fenceLanguage: 'csharp',
95
+ label: 'C#',
96
+ monacoLanguage: 'csharp',
97
+ starter: 'Console.WriteLine("Hello, world!");',
98
+ },
99
+ {
100
+ fenceLanguage: 'cpp',
101
+ label: 'C++',
102
+ monacoLanguage: 'cpp',
103
+ starter: '#include <iostream>\n\nint main() {\n return 0;\n}',
104
+ },
105
+ {
106
+ fenceLanguage: 'go',
107
+ label: 'Go',
108
+ monacoLanguage: 'go',
109
+ starter: 'package main\n\nfunc main() {\n}',
110
+ },
111
+ {
112
+ fenceLanguage: 'rust',
113
+ label: 'Rust',
114
+ monacoLanguage: 'rust',
115
+ starter: 'fn main() {\n println!("Hello, world!");\n}',
116
+ },
117
+ {
118
+ fenceLanguage: 'ruby',
119
+ label: 'Ruby',
120
+ monacoLanguage: 'ruby',
121
+ starter: 'puts "Hello, world!"',
122
+ },
123
+ {
124
+ fenceLanguage: 'php',
125
+ label: 'PHP',
126
+ monacoLanguage: 'php',
127
+ starter: '<?php\necho "Hello, world!";',
128
+ },
129
+ {
130
+ fenceLanguage: 'swift',
131
+ label: 'Swift',
132
+ monacoLanguage: 'swift',
133
+ starter: 'print("Hello, world!")',
134
+ },
135
+ {
136
+ fenceLanguage: 'kotlin',
137
+ label: 'Kotlin',
138
+ monacoLanguage: 'kotlin',
139
+ starter: 'fun main() {\n println("Hello, world!")\n}',
140
+ },
141
+ {
142
+ fenceLanguage: 'dockerfile',
143
+ label: 'Dockerfile',
144
+ monacoLanguage: 'dockerfile',
145
+ starter: 'FROM node:22-alpine',
146
+ },
147
+ ] as const;
148
+
149
+ const BY_FENCE_LANGUAGE: ReadonlyMap<string, CodeSnippetLanguage> = new Map(
150
+ CODE_SNIPPET_LANGUAGES.map((language) => [language.fenceLanguage, language]),
151
+ );
152
+
153
+ /** Languages owned by the diagram/tree/timeline editors or their auto-detection gates. */
154
+ const SPECIAL_FENCE_LANGUAGES = new Set([
155
+ 'text',
156
+ 'txt',
157
+ 'plaintext',
158
+ 'plain',
159
+ 'ascii',
160
+ 'diagram',
161
+ 'tree',
162
+ 'timeline',
163
+ 'mermaid',
164
+ ]);
165
+
166
+ const MONACO_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
167
+ c: 'c',
168
+ 'c++': 'cpp',
169
+ 'c#': 'csharp',
170
+ cs: 'csharp',
171
+ cxx: 'cpp',
172
+ docker: 'dockerfile',
173
+ htm: 'html',
174
+ js: 'javascript',
175
+ jsx: 'javascript',
176
+ md: 'markdown',
177
+ py: 'python',
178
+ rb: 'ruby',
179
+ sh: 'shell',
180
+ shell: 'shell',
181
+ ts: 'typescript',
182
+ tsx: 'typescript',
183
+ yml: 'yaml',
184
+ };
185
+
186
+ /** The first token is the syntax id; any remaining fence metadata stays untouched. */
187
+ export function codeSnippetFenceLanguageToken(language: string | null | undefined): string {
188
+ if (typeof language !== 'string') return '';
189
+ return language.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? '';
190
+ }
191
+
192
+ /** True for explicit language-tagged fences not owned by a richer Squisq block editor. */
193
+ export function isCodeSnippetFenceLanguage(language: string | null | undefined): boolean {
194
+ const token = codeSnippetFenceLanguageToken(language);
195
+ return token.length > 0 && !SPECIAL_FENCE_LANGUAGES.has(token);
196
+ }
197
+
198
+ export function monacoLanguageForFence(language: string | null | undefined): string {
199
+ const token = codeSnippetFenceLanguageToken(language);
200
+ const catalogEntry = BY_FENCE_LANGUAGE.get(token);
201
+ return (catalogEntry?.monacoLanguage ?? MONACO_LANGUAGE_ALIASES[token] ?? token) || 'plaintext';
202
+ }
203
+
204
+ export function codeSnippetLanguageLabel(language: string | null | undefined): string {
205
+ const token = codeSnippetFenceLanguageToken(language);
206
+ const catalogEntry = BY_FENCE_LANGUAGE.get(token);
207
+ if (catalogEntry) return catalogEntry.label;
208
+ return token
209
+ ? token.replace(/(^|[-_])([a-z])/g, (_match, _prefix, letter) => letter.toUpperCase())
210
+ : 'Code';
211
+ }
212
+
213
+ /** Markdown insertion form. The caller decides whether to wrap selected text or use a starter. */
214
+ export function codeSnippetMarkdown(language: string, source: string): string {
215
+ return `\n\`\`\`${language}\n${source}\n\`\`\`\n`;
216
+ }
@@ -222,6 +222,7 @@ export function DiagramCanvas({
222
222
  showMaximize={showMaximize}
223
223
  maximized={maximized}
224
224
  onToggleMaximize={onToggleMaximize}
225
+ showViewControls
225
226
  showToolbar={false}
226
227
  textEditing={textConfig}
227
228
  />
@@ -0,0 +1,81 @@
1
+ import { Extension, type Editor } from '@tiptap/core';
2
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
3
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
4
+ import { findProseMirrorMatches, normalizeFindIndex } from './findModel';
5
+
6
+ interface FindHighlightState {
7
+ query: string;
8
+ selectedIndex: number;
9
+ decorations: DecorationSet;
10
+ }
11
+
12
+ interface FindHighlightMeta {
13
+ query: string;
14
+ selectedIndex: number;
15
+ }
16
+
17
+ const FIND_HIGHLIGHT_KEY = new PluginKey<FindHighlightState>('squisq-find-highlight');
18
+
19
+ function buildState(
20
+ doc: Parameters<typeof findProseMirrorMatches>[0],
21
+ query: string,
22
+ selectedIndex: number,
23
+ ): FindHighlightState {
24
+ const matches = findProseMirrorMatches(doc, query);
25
+ const selected = normalizeFindIndex(selectedIndex, matches.length);
26
+ const decorations = matches.map((match, index) =>
27
+ Decoration.inline(match.from, match.to, {
28
+ class:
29
+ index === selected ? 'squisq-find-match squisq-find-match--selected' : 'squisq-find-match',
30
+ }),
31
+ );
32
+ return {
33
+ query,
34
+ selectedIndex: selected,
35
+ decorations: DecorationSet.create(doc, decorations),
36
+ };
37
+ }
38
+
39
+ /** ProseMirror decorations used by the shell's host-triggered Find mode. */
40
+ export const FindHighlightExtension = Extension.create({
41
+ name: 'squisqFindHighlight',
42
+
43
+ addProseMirrorPlugins() {
44
+ return [
45
+ new Plugin<FindHighlightState>({
46
+ key: FIND_HIGHLIGHT_KEY,
47
+ state: {
48
+ init: (_, state) => buildState(state.doc, '', 0),
49
+ apply: (transaction, previous, _oldState, newState) => {
50
+ const meta = transaction.getMeta(FIND_HIGHLIGHT_KEY) as FindHighlightMeta | undefined;
51
+ if (!meta && !transaction.docChanged) return previous;
52
+ return buildState(
53
+ newState.doc,
54
+ meta?.query ?? previous.query,
55
+ meta?.selectedIndex ?? previous.selectedIndex,
56
+ );
57
+ },
58
+ },
59
+ props: {
60
+ decorations: (state) => FIND_HIGHLIGHT_KEY.getState(state)?.decorations ?? null,
61
+ },
62
+ }),
63
+ ];
64
+ },
65
+ });
66
+
67
+ /** Update WYSIWYG highlights and return the current number of matches. */
68
+ export function updateTiptapFindHighlights(
69
+ editor: Editor,
70
+ query: string,
71
+ selectedIndex: number,
72
+ ): number {
73
+ const matches = findProseMirrorMatches(editor.state.doc, query);
74
+ editor.view.dispatch(
75
+ editor.state.tr.setMeta(FIND_HIGHLIGHT_KEY, {
76
+ query,
77
+ selectedIndex: normalizeFindIndex(selectedIndex, matches.length),
78
+ } satisfies FindHighlightMeta),
79
+ );
80
+ return matches.length;
81
+ }
@@ -0,0 +1,314 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+ import type { editor as MonacoEditorNs } from 'monaco-editor';
3
+ import { useEditorContext } from '../EditorContext';
4
+ import { Icon } from '../Icon';
5
+ import { updateTiptapFindHighlights } from './FindHighlightExtension';
6
+ import { findTextMatches, normalizeFindIndex } from './findModel';
7
+
8
+ interface HighlightRegistryLike {
9
+ set(name: string, highlight: unknown): void;
10
+ delete(name: string): boolean;
11
+ }
12
+
13
+ interface HighlightConstructorLike {
14
+ new (...ranges: Range[]): unknown;
15
+ }
16
+
17
+ interface PreviewHighlightResult {
18
+ count: number;
19
+ cleanup: () => void;
20
+ }
21
+
22
+ export interface FindToolbarProps {
23
+ onClose: () => void;
24
+ }
25
+
26
+ /** Search controls shown immediately to the right of the Write/Source/Use tabs. */
27
+ export function FindToolbar({ onClose }: FindToolbarProps) {
28
+ const { activeView, markdownSource, tiptapEditor, monacoEditor } = useEditorContext();
29
+ const [query, setQuery] = useState('');
30
+ const [selectedIndex, setSelectedIndex] = useState(0);
31
+ const [matchCount, setMatchCount] = useState(0);
32
+ const inputRef = useRef<HTMLInputElement>(null);
33
+ const reactId = useId().replace(/[^a-zA-Z0-9_-]/g, '');
34
+ const previewHighlightName = `squisq-find-${reactId}`;
35
+ const previewSelectedName = `${previewHighlightName}-selected`;
36
+
37
+ useEffect(() => {
38
+ inputRef.current?.focus();
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ const style = document.createElement('style');
43
+ style.dataset.squisqFindHighlight = reactId;
44
+ style.textContent = `::highlight(${previewHighlightName}) { background: #fde68a; color: inherit; } ::highlight(${previewSelectedName}) { background: #f59e0b; color: #111827; }`;
45
+ document.head.append(style);
46
+ return () => style.remove();
47
+ }, [previewHighlightName, previewSelectedName, reactId]);
48
+
49
+ useEffect(() => {
50
+ let cleanup = () => {};
51
+ let count = 0;
52
+
53
+ if (activeView === 'raw' && monacoEditor) {
54
+ const model = monacoEditor.getModel();
55
+ const matches = model
56
+ ? model.findMatches(query.trim(), false, false, false, null, false)
57
+ : [];
58
+ count = query.trim() ? matches.length : 0;
59
+ const selected = normalizeFindIndex(selectedIndex, count);
60
+ const decorations = monacoEditor.createDecorationsCollection(
61
+ matches.slice(0, count).map((match, index) => ({
62
+ range: match.range,
63
+ options: monacoDecorationOptions(index === selected),
64
+ })),
65
+ );
66
+ if (count > 0) monacoEditor.revealRangeInCenter(matches[selected].range);
67
+ cleanup = () => decorations.clear();
68
+ } else if (activeView === 'wysiwyg' && tiptapEditor && !tiptapEditor.isDestroyed) {
69
+ count = updateTiptapFindHighlights(tiptapEditor, query, selectedIndex);
70
+ const selected = tiptapEditor.view.dom.querySelector<HTMLElement>(
71
+ '.squisq-find-match--selected',
72
+ );
73
+ selected?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
74
+ cleanup = () => {
75
+ if (!tiptapEditor.isDestroyed) updateTiptapFindHighlights(tiptapEditor, '', 0);
76
+ };
77
+ } else if (activeView === 'preview') {
78
+ const root = inputRef.current
79
+ ?.closest('.squisq-editor-shell')
80
+ ?.querySelector<HTMLElement>('[data-testid="preview-panel"]');
81
+ if (root) {
82
+ const result = applyPreviewHighlights(
83
+ root,
84
+ query,
85
+ selectedIndex,
86
+ previewHighlightName,
87
+ previewSelectedName,
88
+ );
89
+ count = result.count;
90
+ cleanup = result.cleanup;
91
+ }
92
+ }
93
+
94
+ setMatchCount(count);
95
+ setSelectedIndex((current) => normalizeFindIndex(current, count));
96
+ return cleanup;
97
+ }, [
98
+ activeView,
99
+ markdownSource,
100
+ monacoEditor,
101
+ previewHighlightName,
102
+ previewSelectedName,
103
+ query,
104
+ selectedIndex,
105
+ tiptapEditor,
106
+ ]);
107
+
108
+ const moveSelection = useCallback(
109
+ (delta: number) => {
110
+ if (matchCount === 0) return;
111
+ setSelectedIndex((current) => normalizeFindIndex(current + delta, matchCount));
112
+ },
113
+ [matchCount],
114
+ );
115
+
116
+ const resultLabel =
117
+ query.trim() === ''
118
+ ? '0 of 0'
119
+ : matchCount === 0
120
+ ? 'No results'
121
+ : `${normalizeFindIndex(selectedIndex, matchCount) + 1} of ${matchCount}`;
122
+
123
+ return (
124
+ <div className="squisq-find-toolbar" role="search" aria-label="Find in document">
125
+ <div className="squisq-find-field">
126
+ <Icon icon="fa-solid fa-magnifying-glass" />
127
+ <input
128
+ ref={inputRef}
129
+ type="search"
130
+ value={query}
131
+ className="squisq-find-input"
132
+ aria-label="Find in document"
133
+ placeholder="Find in document"
134
+ autoComplete="off"
135
+ spellCheck={false}
136
+ onChange={(event) => {
137
+ setQuery(event.target.value);
138
+ setSelectedIndex(0);
139
+ }}
140
+ onKeyDown={(event) => {
141
+ if (event.key === 'Enter') {
142
+ event.preventDefault();
143
+ moveSelection(event.shiftKey ? -1 : 1);
144
+ } else if (event.key === 'Escape') {
145
+ event.preventDefault();
146
+ onClose();
147
+ }
148
+ }}
149
+ />
150
+ <span className="squisq-find-count" aria-live="polite" aria-atomic="true">
151
+ {resultLabel}
152
+ </span>
153
+ </div>
154
+ <button
155
+ type="button"
156
+ className="squisq-find-button"
157
+ aria-label="Previous match"
158
+ data-tooltip="Previous match (Shift+Enter)"
159
+ disabled={matchCount === 0}
160
+ onClick={() => moveSelection(-1)}
161
+ >
162
+ <Icon icon="fa-solid fa-chevron-up" />
163
+ </button>
164
+ <button
165
+ type="button"
166
+ className="squisq-find-button"
167
+ aria-label="Next match"
168
+ data-tooltip="Next match (Enter)"
169
+ disabled={matchCount === 0}
170
+ onClick={() => moveSelection(1)}
171
+ >
172
+ <Icon icon="fa-solid fa-chevron-down" />
173
+ </button>
174
+ <button
175
+ type="button"
176
+ className="squisq-find-button squisq-find-close"
177
+ aria-label="Close find"
178
+ data-tooltip="Close find (Esc)"
179
+ onClick={onClose}
180
+ >
181
+ <Icon icon="fa-solid fa-xmark" />
182
+ </button>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function monacoDecorationOptions(selected: boolean): MonacoEditorNs.IModelDecorationOptions {
188
+ return {
189
+ inlineClassName: selected
190
+ ? 'squisq-find-match squisq-find-match--selected'
191
+ : 'squisq-find-match',
192
+ };
193
+ }
194
+
195
+ function applyPreviewHighlights(
196
+ root: HTMLElement,
197
+ query: string,
198
+ selectedIndex: number,
199
+ highlightName: string,
200
+ selectedName: string,
201
+ ): PreviewHighlightResult {
202
+ const ranges = collectTextRanges(root, query);
203
+ const selected = normalizeFindIndex(selectedIndex, ranges.length);
204
+ const css = globalThis.CSS as (typeof CSS & { highlights?: HighlightRegistryLike }) | undefined;
205
+ const HighlightConstructor = (
206
+ globalThis as typeof globalThis & {
207
+ Highlight?: HighlightConstructorLike;
208
+ }
209
+ ).Highlight;
210
+
211
+ if (css?.highlights && HighlightConstructor) {
212
+ css.highlights.set(highlightName, new HighlightConstructor(...ranges));
213
+ if (ranges.length > 0) {
214
+ css.highlights.set(selectedName, new HighlightConstructor(ranges[selected]));
215
+ scrollRangeIntoView(ranges[selected]);
216
+ } else {
217
+ css.highlights.delete(selectedName);
218
+ }
219
+ return {
220
+ count: ranges.length,
221
+ cleanup: () => {
222
+ css.highlights?.delete(highlightName);
223
+ css.highlights?.delete(selectedName);
224
+ },
225
+ };
226
+ }
227
+
228
+ // CSS Custom Highlight is broadly available in current browsers. Keep a
229
+ // DOM-mark fallback for older embedded webviews; Preview is read-only, and
230
+ // every inserted mark is unwrapped when the query changes or Find closes.
231
+ const marks = markPreviewRanges(ranges, selected);
232
+ marks
233
+ .find((mark) => mark.classList.contains('squisq-find-match--selected'))
234
+ ?.scrollIntoView?.({
235
+ block: 'center',
236
+ inline: 'nearest',
237
+ });
238
+ return {
239
+ count: ranges.length,
240
+ cleanup: () => {
241
+ const parents = new Set<Node>();
242
+ for (const mark of marks) {
243
+ const parent = mark.parentNode;
244
+ if (!parent) continue;
245
+ parents.add(parent);
246
+ mark.replaceWith(...Array.from(mark.childNodes));
247
+ }
248
+ parents.forEach((parent) => parent.normalize());
249
+ },
250
+ };
251
+ }
252
+
253
+ function collectTextRanges(root: HTMLElement, query: string): Range[] {
254
+ if (!query.trim()) return [];
255
+ const doc = root.ownerDocument;
256
+ const showText = doc.defaultView?.NodeFilter.SHOW_TEXT ?? 4;
257
+ const walker = doc.createTreeWalker(root, showText);
258
+ const ranges: Range[] = [];
259
+ let current = walker.nextNode();
260
+ while (current) {
261
+ const text = current as Text;
262
+ const parent = text.parentElement;
263
+ if (
264
+ parent &&
265
+ !parent.closest(
266
+ 'button, input, textarea, select, script, style, [aria-hidden="true"], [data-squisq-find-ignore]',
267
+ )
268
+ ) {
269
+ for (const match of findTextMatches(text.data, query)) {
270
+ const range = doc.createRange();
271
+ range.setStart(text, match.from);
272
+ range.setEnd(text, match.to);
273
+ ranges.push(range);
274
+ }
275
+ }
276
+ current = walker.nextNode();
277
+ }
278
+ return ranges;
279
+ }
280
+
281
+ function scrollRangeIntoView(range: Range): void {
282
+ const element =
283
+ range.startContainer instanceof Element
284
+ ? range.startContainer
285
+ : range.startContainer.parentElement;
286
+ (element as HTMLElement | null)?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
287
+ }
288
+
289
+ function markPreviewRanges(ranges: Range[], selectedIndex: number): HTMLElement[] {
290
+ const marks: HTMLElement[] = [];
291
+ // Work backwards so splitting a text node does not invalidate the offsets
292
+ // of an earlier match in that same node.
293
+ [...ranges]
294
+ .map((range, index) => ({ range, index }))
295
+ .reverse()
296
+ .forEach(({ range, index }) => {
297
+ const text = range.startContainer;
298
+ if (!(text instanceof Text) || text !== range.endContainer || !text.parentNode) return;
299
+ const after = text.splitText(range.endOffset);
300
+ const matched = text.splitText(range.startOffset);
301
+ const mark = text.ownerDocument.createElement('mark');
302
+ mark.className =
303
+ index === selectedIndex
304
+ ? 'squisq-find-match squisq-find-match--selected'
305
+ : 'squisq-find-match';
306
+ matched.replaceWith(mark);
307
+ mark.append(matched);
308
+ marks.push(mark);
309
+ // Keep the tail referenced so aggressive DOM implementations do not
310
+ // discard it before the next reverse-ordered range is processed.
311
+ void after;
312
+ });
313
+ return marks;
314
+ }
@@ -0,0 +1,77 @@
1
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
2
+
3
+ export interface FindTextMatch {
4
+ from: number;
5
+ to: number;
6
+ }
7
+
8
+ /**
9
+ * Find non-overlapping, case-insensitive literal matches. Offsets are UTF-16
10
+ * code-unit offsets, matching DOM Range, Monaco, and ProseMirror positions.
11
+ */
12
+ export function findTextMatches(text: string, query: string): FindTextMatch[] {
13
+ const needle = query.trim();
14
+ if (!needle || !text) return [];
15
+
16
+ const matcher = new RegExp(escapeRegExp(needle), 'giu');
17
+ const matches: FindTextMatch[] = [];
18
+ let match: RegExpExecArray | null;
19
+ while ((match = matcher.exec(text)) !== null) {
20
+ matches.push({ from: match.index, to: match.index + match[0].length });
21
+ // Literal non-empty queries always advance, but retain this guard so a
22
+ // future matcher change cannot create an infinite loop.
23
+ if (match[0].length === 0) matcher.lastIndex += 1;
24
+ }
25
+ return matches;
26
+ }
27
+
28
+ export function normalizeFindIndex(index: number, count: number): number {
29
+ if (count <= 0) return 0;
30
+ return ((index % count) + count) % count;
31
+ }
32
+
33
+ /**
34
+ * Resolve visible text matches to ProseMirror document positions. Text from
35
+ * adjacent marked spans in one text block is searched as one run, so a query
36
+ * can cross a bold/italic boundary without matching across separate blocks.
37
+ */
38
+ export function findProseMirrorMatches(doc: ProseMirrorNode, query: string): FindTextMatch[] {
39
+ const matches: FindTextMatch[] = [];
40
+
41
+ doc.descendants((node, blockPos) => {
42
+ if (!node.isTextblock) return true;
43
+
44
+ const spans: Array<FindTextMatch & { docFrom: number }> = [];
45
+ let text = '';
46
+ node.descendants((child, offset) => {
47
+ if (child.isText && child.text) {
48
+ const from = text.length;
49
+ text += child.text;
50
+ spans.push({ from, to: text.length, docFrom: blockPos + 1 + offset });
51
+ } else if (child.isInline) {
52
+ // Prevent a phrase from matching invisibly across an atom or hard break.
53
+ text += '\u0000';
54
+ }
55
+ return true;
56
+ });
57
+
58
+ for (const match of findTextMatches(text, query)) {
59
+ const startSpan = spans.find((span) => match.from >= span.from && match.from < span.to);
60
+ const endOffset = match.to - 1;
61
+ const endSpan = spans.find((span) => endOffset >= span.from && endOffset < span.to);
62
+ if (!startSpan || !endSpan) continue;
63
+ matches.push({
64
+ from: startSpan.docFrom + match.from - startSpan.from,
65
+ to: endSpan.docFrom + match.to - endSpan.from,
66
+ });
67
+ }
68
+
69
+ return false;
70
+ });
71
+
72
+ return matches;
73
+ }
74
+
75
+ function escapeRegExp(value: string): string {
76
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+ }