@ceedcv-maya/shared-editor-react 0.6.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/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +87 -0
- package/src/components/ColorPicker.tsx +100 -0
- package/src/components/CommentHoverPopover.tsx +82 -0
- package/src/components/EditorContentHtml.tsx +29 -0
- package/src/components/EditorToolbar.tsx +225 -0
- package/src/components/EditorToolbarButton.tsx +32 -0
- package/src/components/EditorToolbarGroups.tsx +401 -0
- package/src/components/FindReplaceBar.tsx +253 -0
- package/src/components/MayaEditor.tsx +379 -0
- package/src/components/SourceInputDialog.tsx +120 -0
- package/src/extensions/AlertBlock.ts +59 -0
- package/src/extensions/CommentMark.ts +57 -0
- package/src/extensions/IframeBlock.ts +76 -0
- package/src/extensions/Indent.ts +133 -0
- package/src/hooks/useEditorContent.ts +47 -0
- package/src/i18n/en.json +54 -0
- package/src/i18n/es.json +54 -0
- package/src/index.ts +47 -0
- package/src/lib/CommentAnchor.ts +68 -0
- package/src/lib/docxToHtml.ts +58 -0
- package/src/lib/dompurifyConfig.test.ts +98 -0
- package/src/lib/dompurifyConfig.ts +123 -0
- package/src/lib/editorExtensions.ts +73 -0
- package/src/lib/htmlToMarkdown.ts +166 -0
- package/src/lib/htmlToTiptapDoc.test.ts +52 -0
- package/src/lib/htmlToTiptapDoc.ts +26 -0
- package/src/lib/markdownToHtml.ts +234 -0
- package/src/lib/normalizeTableHtml.ts +74 -0
- package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
- package/src/lib/splitHtmlIntoBlocks.ts +136 -0
- package/src/serializers/BlockNoteToTiptap.ts +223 -0
- package/src/styles/maya-editor.css +538 -0
- package/src/types.ts +56 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditorToolbar button groups extracted into focused subcomponents.
|
|
3
|
+
* Each group handles a logically distinct set of formatting or block operations.
|
|
4
|
+
*/
|
|
5
|
+
import type { Editor } from '@tiptap/react';
|
|
6
|
+
import type { ToolbarLabels } from './EditorToolbar';
|
|
7
|
+
import { ColorPicker } from './ColorPicker';
|
|
8
|
+
import { Btn } from './EditorToolbarButton';
|
|
9
|
+
|
|
10
|
+
export interface ToolbarGroupProps {
|
|
11
|
+
editor: Editor;
|
|
12
|
+
labels: ToolbarLabels;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Basic formatting: bold, italic, underline, code, link.
|
|
17
|
+
* Always visible, even in lite mode.
|
|
18
|
+
*/
|
|
19
|
+
export function FormattingButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
20
|
+
const setLink = () => {
|
|
21
|
+
const prev = editor.getAttributes('link').href as string | undefined;
|
|
22
|
+
const url = window.prompt(L.linkPrompt, prev ?? '');
|
|
23
|
+
if (url === null) return;
|
|
24
|
+
if (url === '') {
|
|
25
|
+
editor.chain().focus().unsetLink().run();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<Btn
|
|
34
|
+
active={editor.isActive('bold')}
|
|
35
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
36
|
+
title={L.bold}
|
|
37
|
+
>
|
|
38
|
+
<strong>B</strong>
|
|
39
|
+
</Btn>
|
|
40
|
+
<Btn
|
|
41
|
+
active={editor.isActive('italic')}
|
|
42
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
43
|
+
title={L.italic}
|
|
44
|
+
>
|
|
45
|
+
<em>I</em>
|
|
46
|
+
</Btn>
|
|
47
|
+
<Btn
|
|
48
|
+
active={editor.isActive('underline')}
|
|
49
|
+
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
50
|
+
title={L.underline}
|
|
51
|
+
>
|
|
52
|
+
<u>U</u>
|
|
53
|
+
</Btn>
|
|
54
|
+
<Btn
|
|
55
|
+
active={editor.isActive('code')}
|
|
56
|
+
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
57
|
+
title={L.code}
|
|
58
|
+
>
|
|
59
|
+
{'</>'}
|
|
60
|
+
</Btn>
|
|
61
|
+
<Btn active={editor.isActive('link')} onClick={setLink} title={L.link}>
|
|
62
|
+
🔗
|
|
63
|
+
</Btn>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Advanced formatting: strikethrough, text color, highlight color.
|
|
70
|
+
*/
|
|
71
|
+
export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<Btn
|
|
75
|
+
active={editor.isActive('strike')}
|
|
76
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
77
|
+
title={L.strike}
|
|
78
|
+
>
|
|
79
|
+
<s>S</s>
|
|
80
|
+
</Btn>
|
|
81
|
+
|
|
82
|
+
<ColorPicker
|
|
83
|
+
title={L.textColor}
|
|
84
|
+
value={(editor.getAttributes('textStyle').color as string | undefined) ?? null}
|
|
85
|
+
glyph={<span style={{ fontWeight: 700 }}>A</span>}
|
|
86
|
+
clearLabel={L.colorDefault}
|
|
87
|
+
onSelect={(c) => {
|
|
88
|
+
if (c === null) editor.chain().focus().unsetColor().run();
|
|
89
|
+
else editor.chain().focus().setColor(c).run();
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
<ColorPicker
|
|
93
|
+
title={L.backgroundColor}
|
|
94
|
+
value={(editor.getAttributes('highlight').color as string | undefined) ?? null}
|
|
95
|
+
glyph={<span style={{ fontWeight: 700 }}>▮</span>}
|
|
96
|
+
clearLabel={L.colorDefault}
|
|
97
|
+
onSelect={(c) => {
|
|
98
|
+
if (c === null) editor.chain().focus().unsetHighlight().run();
|
|
99
|
+
else editor.chain().focus().setHighlight({ color: c }).run();
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Text alignment buttons: left, center, right, justify.
|
|
108
|
+
*/
|
|
109
|
+
export function AlignmentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<Btn
|
|
113
|
+
active={editor.isActive({ textAlign: 'left' })}
|
|
114
|
+
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
115
|
+
title={L.alignLeft}
|
|
116
|
+
>
|
|
117
|
+
⬱
|
|
118
|
+
</Btn>
|
|
119
|
+
<Btn
|
|
120
|
+
active={editor.isActive({ textAlign: 'center' })}
|
|
121
|
+
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
122
|
+
title={L.alignCenter}
|
|
123
|
+
>
|
|
124
|
+
☱
|
|
125
|
+
</Btn>
|
|
126
|
+
<Btn
|
|
127
|
+
active={editor.isActive({ textAlign: 'right' })}
|
|
128
|
+
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
|
129
|
+
title={L.alignRight}
|
|
130
|
+
>
|
|
131
|
+
⬲
|
|
132
|
+
</Btn>
|
|
133
|
+
<Btn
|
|
134
|
+
active={editor.isActive({ textAlign: 'justify' })}
|
|
135
|
+
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
|
136
|
+
title={L.alignJustify}
|
|
137
|
+
>
|
|
138
|
+
☰
|
|
139
|
+
</Btn>
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Indentation buttons: indent (increase nesting) and outdent (decrease nesting).
|
|
146
|
+
*/
|
|
147
|
+
export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
<Btn
|
|
151
|
+
onClick={() => {
|
|
152
|
+
if (editor.isActive('listItem') || editor.isActive('taskItem')) {
|
|
153
|
+
editor.chain().focus().sinkListItem(
|
|
154
|
+
editor.isActive('taskItem') ? 'taskItem' : 'listItem',
|
|
155
|
+
).run();
|
|
156
|
+
} else {
|
|
157
|
+
editor.chain().focus().indent().run();
|
|
158
|
+
}
|
|
159
|
+
}}
|
|
160
|
+
title={L.indent}
|
|
161
|
+
>
|
|
162
|
+
⇥
|
|
163
|
+
</Btn>
|
|
164
|
+
<Btn
|
|
165
|
+
onClick={() => {
|
|
166
|
+
if (editor.isActive('listItem') || editor.isActive('taskItem')) {
|
|
167
|
+
editor.chain().focus().liftListItem(
|
|
168
|
+
editor.isActive('taskItem') ? 'taskItem' : 'listItem',
|
|
169
|
+
).run();
|
|
170
|
+
} else {
|
|
171
|
+
editor.chain().focus().outdent().run();
|
|
172
|
+
}
|
|
173
|
+
}}
|
|
174
|
+
title={L.outdent}
|
|
175
|
+
>
|
|
176
|
+
⇤
|
|
177
|
+
</Btn>
|
|
178
|
+
</>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Heading levels: H1, H2, H3.
|
|
184
|
+
*/
|
|
185
|
+
export function HeadingButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
186
|
+
return (
|
|
187
|
+
<>
|
|
188
|
+
<Btn
|
|
189
|
+
active={editor.isActive('heading', { level: 1 })}
|
|
190
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
191
|
+
title={L.heading1}
|
|
192
|
+
>
|
|
193
|
+
H1
|
|
194
|
+
</Btn>
|
|
195
|
+
<Btn
|
|
196
|
+
active={editor.isActive('heading', { level: 2 })}
|
|
197
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
198
|
+
title={L.heading2}
|
|
199
|
+
>
|
|
200
|
+
H2
|
|
201
|
+
</Btn>
|
|
202
|
+
<Btn
|
|
203
|
+
active={editor.isActive('heading', { level: 3 })}
|
|
204
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
205
|
+
title={L.heading3}
|
|
206
|
+
>
|
|
207
|
+
H3
|
|
208
|
+
</Btn>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* List and block buttons: bullet list, ordered list, task list, blockquote, code block, horizontal rule.
|
|
215
|
+
*/
|
|
216
|
+
export function ListAndBlockButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
<Btn
|
|
220
|
+
active={editor.isActive('bulletList')}
|
|
221
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
222
|
+
title={L.bulletList}
|
|
223
|
+
>
|
|
224
|
+
• —
|
|
225
|
+
</Btn>
|
|
226
|
+
<Btn
|
|
227
|
+
active={editor.isActive('orderedList')}
|
|
228
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
229
|
+
title={L.orderedList}
|
|
230
|
+
>
|
|
231
|
+
1.
|
|
232
|
+
</Btn>
|
|
233
|
+
<Btn
|
|
234
|
+
active={editor.isActive('taskList')}
|
|
235
|
+
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
|
236
|
+
title={L.taskList}
|
|
237
|
+
>
|
|
238
|
+
☑
|
|
239
|
+
</Btn>
|
|
240
|
+
<Btn
|
|
241
|
+
active={editor.isActive('blockquote')}
|
|
242
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
243
|
+
title={L.blockquote}
|
|
244
|
+
>
|
|
245
|
+
❝
|
|
246
|
+
</Btn>
|
|
247
|
+
<Btn
|
|
248
|
+
active={editor.isActive('codeBlock')}
|
|
249
|
+
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
250
|
+
title={L.codeBlock}
|
|
251
|
+
>
|
|
252
|
+
{'{}'}
|
|
253
|
+
</Btn>
|
|
254
|
+
<Btn
|
|
255
|
+
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
256
|
+
title={L.horizontalRule}
|
|
257
|
+
>
|
|
258
|
+
—
|
|
259
|
+
</Btn>
|
|
260
|
+
</>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Table and media buttons: insert table, iframe, and optional image upload.
|
|
266
|
+
*/
|
|
267
|
+
export interface TableAndMediaButtonsProps extends ToolbarGroupProps {
|
|
268
|
+
onImage?: () => void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function TableAndMediaButtons({
|
|
272
|
+
editor,
|
|
273
|
+
labels: L,
|
|
274
|
+
onImage,
|
|
275
|
+
}: TableAndMediaButtonsProps) {
|
|
276
|
+
const setIframe = () => {
|
|
277
|
+
const url = window.prompt(L.iframePrompt, '');
|
|
278
|
+
if (!url) return;
|
|
279
|
+
editor.chain().focus().setIframe({ src: url }).run();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<>
|
|
284
|
+
<Btn
|
|
285
|
+
onClick={() =>
|
|
286
|
+
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
|
287
|
+
}
|
|
288
|
+
title={L.table}
|
|
289
|
+
>
|
|
290
|
+
⊞
|
|
291
|
+
</Btn>
|
|
292
|
+
<Btn onClick={setIframe} title={L.iframe}>
|
|
293
|
+
🖽
|
|
294
|
+
</Btn>
|
|
295
|
+
{onImage && (
|
|
296
|
+
<Btn onClick={onImage} title={L.uploadImage}>
|
|
297
|
+
🖼
|
|
298
|
+
</Btn>
|
|
299
|
+
)}
|
|
300
|
+
</>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Import/export and document buttons: Word import/export, comments, find.
|
|
306
|
+
*/
|
|
307
|
+
export interface DocumentButtonsProps extends ToolbarGroupProps {
|
|
308
|
+
onImportDocx?: () => void;
|
|
309
|
+
onExportDocx?: () => void;
|
|
310
|
+
onAddComment?: () => void;
|
|
311
|
+
onToggleFind?: () => void;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function DocumentButtons({
|
|
315
|
+
editor,
|
|
316
|
+
labels: L,
|
|
317
|
+
onImportDocx,
|
|
318
|
+
onExportDocx,
|
|
319
|
+
onAddComment,
|
|
320
|
+
onToggleFind,
|
|
321
|
+
}: DocumentButtonsProps) {
|
|
322
|
+
return (
|
|
323
|
+
<>
|
|
324
|
+
{onImportDocx && (
|
|
325
|
+
<Btn onClick={onImportDocx} title={L.importDocx}>
|
|
326
|
+
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>↥W</span>
|
|
327
|
+
</Btn>
|
|
328
|
+
)}
|
|
329
|
+
{onExportDocx && (
|
|
330
|
+
<Btn onClick={onExportDocx} title={L.exportDocx}>
|
|
331
|
+
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>↧W</span>
|
|
332
|
+
</Btn>
|
|
333
|
+
)}
|
|
334
|
+
{onAddComment && (
|
|
335
|
+
<Btn
|
|
336
|
+
onClick={onAddComment}
|
|
337
|
+
disabled={editor.state.selection.empty}
|
|
338
|
+
title={L.addComment}
|
|
339
|
+
>
|
|
340
|
+
💬
|
|
341
|
+
</Btn>
|
|
342
|
+
)}
|
|
343
|
+
{onToggleFind && (
|
|
344
|
+
<Btn onClick={onToggleFind} title={L.find}>
|
|
345
|
+
🔍
|
|
346
|
+
</Btn>
|
|
347
|
+
)}
|
|
348
|
+
</>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* View mode buttons: HTML, Markdown, Fullscreen toggle.
|
|
354
|
+
*/
|
|
355
|
+
export interface ViewModeButtonsProps extends ToolbarGroupProps {
|
|
356
|
+
viewMode?: 'wysiwyg' | 'html' | 'markdown';
|
|
357
|
+
isFullscreen?: boolean;
|
|
358
|
+
onInsertHtml?: () => void;
|
|
359
|
+
onInsertMarkdown?: () => void;
|
|
360
|
+
onToggleFullscreen?: () => void;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function ViewModeButtons({
|
|
364
|
+
labels: L,
|
|
365
|
+
viewMode = 'wysiwyg',
|
|
366
|
+
isFullscreen,
|
|
367
|
+
onInsertHtml,
|
|
368
|
+
onInsertMarkdown,
|
|
369
|
+
onToggleFullscreen,
|
|
370
|
+
}: ViewModeButtonsProps) {
|
|
371
|
+
return (
|
|
372
|
+
<>
|
|
373
|
+
{onInsertMarkdown && (
|
|
374
|
+
<Btn
|
|
375
|
+
onClick={onInsertMarkdown}
|
|
376
|
+
title={L.insertMarkdown}
|
|
377
|
+
active={viewMode === 'markdown'}
|
|
378
|
+
>
|
|
379
|
+
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>md</span>
|
|
380
|
+
</Btn>
|
|
381
|
+
)}
|
|
382
|
+
{onInsertHtml && (
|
|
383
|
+
<Btn
|
|
384
|
+
onClick={onInsertHtml}
|
|
385
|
+
title={L.insertHtml}
|
|
386
|
+
active={viewMode === 'html'}
|
|
387
|
+
>
|
|
388
|
+
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>{'<>'}</span>
|
|
389
|
+
</Btn>
|
|
390
|
+
)}
|
|
391
|
+
{onToggleFullscreen && (
|
|
392
|
+
<Btn
|
|
393
|
+
onClick={onToggleFullscreen}
|
|
394
|
+
title={isFullscreen ? L.exitFullscreen : L.fullscreen}
|
|
395
|
+
>
|
|
396
|
+
{isFullscreen ? '⤓' : '⛶'}
|
|
397
|
+
</Btn>
|
|
398
|
+
)}
|
|
399
|
+
</>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { Editor } from '@tiptap/react';
|
|
3
|
+
|
|
4
|
+
interface FindReplaceBarProps {
|
|
5
|
+
editor: Editor;
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
labels: {
|
|
9
|
+
findPlaceholder: string;
|
|
10
|
+
replacePlaceholder: string;
|
|
11
|
+
findNext: string;
|
|
12
|
+
findPrev: string;
|
|
13
|
+
replace: string;
|
|
14
|
+
replaceAll: string;
|
|
15
|
+
close: string;
|
|
16
|
+
caseSensitive: string;
|
|
17
|
+
count: (current: number, total: number) => string;
|
|
18
|
+
none: string;
|
|
19
|
+
replacedCount: (n: number) => string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Match {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Inline find/replace bar that sits under the toolbar (mode=full).
|
|
30
|
+
*
|
|
31
|
+
* Implementation:
|
|
32
|
+
* - Walks the editor's text content with `state.doc.descendants`,
|
|
33
|
+
* collecting `(from, to)` ranges that match the needle.
|
|
34
|
+
* - "Find next/prev" sets the editor selection to the matched range
|
|
35
|
+
* and scrolls it into view.
|
|
36
|
+
* - "Replace" replaces the active range; "Replace all" walks the list
|
|
37
|
+
* in reverse (so earlier offsets stay valid) and applies each one.
|
|
38
|
+
* - Recomputes the match list on doc changes via the editor `update`
|
|
39
|
+
* event so navigation stays consistent after replacements.
|
|
40
|
+
*/
|
|
41
|
+
export function FindReplaceBar({ editor, open, onClose, labels }: FindReplaceBarProps) {
|
|
42
|
+
const [needle, setNeedle] = useState('');
|
|
43
|
+
const [replacement, setReplacement] = useState('');
|
|
44
|
+
const [caseSensitive, setCaseSensitive] = useState(false);
|
|
45
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
46
|
+
const [matches, setMatches] = useState<Match[]>([]);
|
|
47
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
48
|
+
|
|
49
|
+
// Pre-load the input with the current selection when the bar opens.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) return;
|
|
52
|
+
const { from, to } = editor.state.selection;
|
|
53
|
+
if (to > from) {
|
|
54
|
+
setNeedle(editor.state.doc.textBetween(from, to, ' '));
|
|
55
|
+
}
|
|
56
|
+
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
|
57
|
+
return () => clearTimeout(t);
|
|
58
|
+
}, [open, editor]);
|
|
59
|
+
|
|
60
|
+
const findMatches = useCallback(
|
|
61
|
+
(q: string, cs: boolean): Match[] => {
|
|
62
|
+
if (!q) return [];
|
|
63
|
+
const target = cs ? q : q.toLowerCase();
|
|
64
|
+
const found: Match[] = [];
|
|
65
|
+
editor.state.doc.descendants((node, pos) => {
|
|
66
|
+
if (!node.isText || !node.text) return;
|
|
67
|
+
const haystack = cs ? node.text : node.text.toLowerCase();
|
|
68
|
+
let idx = 0;
|
|
69
|
+
while ((idx = haystack.indexOf(target, idx)) !== -1) {
|
|
70
|
+
found.push({ from: pos + idx, to: pos + idx + q.length });
|
|
71
|
+
idx += q.length;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return found;
|
|
75
|
+
},
|
|
76
|
+
[editor],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Recompute on every editor update so concurrent edits don't desync.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!open) return;
|
|
82
|
+
const refresh = () => setMatches(findMatches(needle, caseSensitive));
|
|
83
|
+
refresh();
|
|
84
|
+
editor.on('update', refresh);
|
|
85
|
+
return () => {
|
|
86
|
+
editor.off('update', refresh);
|
|
87
|
+
};
|
|
88
|
+
}, [open, editor, needle, caseSensitive, findMatches]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (activeIndex >= matches.length) setActiveIndex(Math.max(0, matches.length - 1));
|
|
92
|
+
}, [matches.length, activeIndex]);
|
|
93
|
+
|
|
94
|
+
const focusMatch = useCallback(
|
|
95
|
+
(idx: number) => {
|
|
96
|
+
const m = matches[idx];
|
|
97
|
+
if (!m) return;
|
|
98
|
+
editor.chain().setTextSelection({ from: m.from, to: m.to }).scrollIntoView().run();
|
|
99
|
+
},
|
|
100
|
+
[editor, matches],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const goNext = useCallback(() => {
|
|
104
|
+
if (matches.length === 0) return;
|
|
105
|
+
const next = (activeIndex + 1) % matches.length;
|
|
106
|
+
setActiveIndex(next);
|
|
107
|
+
focusMatch(next);
|
|
108
|
+
}, [matches.length, activeIndex, focusMatch]);
|
|
109
|
+
|
|
110
|
+
const goPrev = useCallback(() => {
|
|
111
|
+
if (matches.length === 0) return;
|
|
112
|
+
const next = (activeIndex - 1 + matches.length) % matches.length;
|
|
113
|
+
setActiveIndex(next);
|
|
114
|
+
focusMatch(next);
|
|
115
|
+
}, [matches.length, activeIndex, focusMatch]);
|
|
116
|
+
|
|
117
|
+
const replaceOne = useCallback(() => {
|
|
118
|
+
const m = matches[activeIndex];
|
|
119
|
+
if (!m) return;
|
|
120
|
+
editor
|
|
121
|
+
.chain()
|
|
122
|
+
.focus()
|
|
123
|
+
.setTextSelection({ from: m.from, to: m.to })
|
|
124
|
+
.insertContent(replacement)
|
|
125
|
+
.run();
|
|
126
|
+
}, [editor, matches, activeIndex, replacement]);
|
|
127
|
+
|
|
128
|
+
const replaceAll = useCallback(() => {
|
|
129
|
+
if (matches.length === 0) return;
|
|
130
|
+
// Apply from the end so earlier (from, to) ranges remain valid.
|
|
131
|
+
const tr = editor.state.tr;
|
|
132
|
+
const sorted = [...matches].sort((a, b) => b.from - a.from);
|
|
133
|
+
for (const m of sorted) {
|
|
134
|
+
tr.insertText(replacement, m.from, m.to);
|
|
135
|
+
}
|
|
136
|
+
editor.view.dispatch(tr);
|
|
137
|
+
}, [editor, matches, replacement]);
|
|
138
|
+
|
|
139
|
+
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
140
|
+
if (e.key === 'Enter') {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
e.shiftKey ? goPrev() : goNext();
|
|
143
|
+
} else if (e.key === 'Escape') {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
onClose();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const counter = useMemo(() => {
|
|
150
|
+
if (!needle) return '';
|
|
151
|
+
if (matches.length === 0) return labels.none;
|
|
152
|
+
return labels.count(activeIndex + 1, matches.length);
|
|
153
|
+
}, [needle, matches, activeIndex, labels]);
|
|
154
|
+
|
|
155
|
+
if (!open) return null;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="maya-editor-find" role="search" aria-label="Find and replace">
|
|
159
|
+
<div className="maya-editor-find__row">
|
|
160
|
+
<input
|
|
161
|
+
ref={inputRef}
|
|
162
|
+
type="text"
|
|
163
|
+
className="maya-editor-find__input"
|
|
164
|
+
placeholder={labels.findPlaceholder}
|
|
165
|
+
value={needle}
|
|
166
|
+
onChange={(e) => setNeedle(e.target.value)}
|
|
167
|
+
onKeyDown={handleKey}
|
|
168
|
+
/>
|
|
169
|
+
<span className="maya-editor-find__counter" aria-live="polite">
|
|
170
|
+
{counter}
|
|
171
|
+
</span>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className="maya-editor-toolbar__btn"
|
|
175
|
+
onClick={goPrev}
|
|
176
|
+
disabled={matches.length === 0}
|
|
177
|
+
title={labels.findPrev}
|
|
178
|
+
aria-label={labels.findPrev}
|
|
179
|
+
>
|
|
180
|
+
‹
|
|
181
|
+
</button>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="maya-editor-toolbar__btn"
|
|
185
|
+
onClick={goNext}
|
|
186
|
+
disabled={matches.length === 0}
|
|
187
|
+
title={labels.findNext}
|
|
188
|
+
aria-label={labels.findNext}
|
|
189
|
+
>
|
|
190
|
+
›
|
|
191
|
+
</button>
|
|
192
|
+
<label
|
|
193
|
+
className={`maya-editor-toolbar__btn${caseSensitive ? ' is-active' : ''}`}
|
|
194
|
+
title={labels.caseSensitive}
|
|
195
|
+
>
|
|
196
|
+
<input
|
|
197
|
+
type="checkbox"
|
|
198
|
+
className="maya-editor-find__hidden"
|
|
199
|
+
checked={caseSensitive}
|
|
200
|
+
onChange={(e) => setCaseSensitive(e.target.checked)}
|
|
201
|
+
/>
|
|
202
|
+
Aa
|
|
203
|
+
</label>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className="maya-editor-toolbar__btn"
|
|
207
|
+
onClick={onClose}
|
|
208
|
+
title={labels.close}
|
|
209
|
+
aria-label={labels.close}
|
|
210
|
+
>
|
|
211
|
+
✕
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="maya-editor-find__row">
|
|
215
|
+
<input
|
|
216
|
+
type="text"
|
|
217
|
+
className="maya-editor-find__input"
|
|
218
|
+
placeholder={labels.replacePlaceholder}
|
|
219
|
+
value={replacement}
|
|
220
|
+
onChange={(e) => setReplacement(e.target.value)}
|
|
221
|
+
onKeyDown={(e) => {
|
|
222
|
+
if (e.key === 'Enter') {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
replaceOne();
|
|
225
|
+
goNext();
|
|
226
|
+
} else if (e.key === 'Escape') {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
onClose();
|
|
229
|
+
}
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
className="maya-editor-toolbar__btn"
|
|
235
|
+
onClick={replaceOne}
|
|
236
|
+
disabled={matches.length === 0}
|
|
237
|
+
title={labels.replace}
|
|
238
|
+
>
|
|
239
|
+
{labels.replace}
|
|
240
|
+
</button>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
className="maya-editor-toolbar__btn"
|
|
244
|
+
onClick={replaceAll}
|
|
245
|
+
disabled={matches.length === 0}
|
|
246
|
+
title={labels.replaceAll}
|
|
247
|
+
>
|
|
248
|
+
{labels.replaceAll}
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|