@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.
- package/README.md +10 -2
- package/dist/index.d.ts +505 -15
- package/dist/index.js +6035 -2283
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +870 -40
- package/package.json +6 -5
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +24 -0
- package/src/PreviewControls.tsx +20 -5
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +3 -0
- package/src/Toolbar.tsx +315 -17
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +31 -0
- package/src/__tests__/tiptapBridge.test.ts +9 -0
- package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/diagram/DiagramCanvas.tsx +1 -0
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/index.ts +89 -0
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +322 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- 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
|
+
}
|
|
@@ -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
|
+
}
|