@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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/package.json +87 -0
  4. package/src/components/ColorPicker.tsx +100 -0
  5. package/src/components/CommentHoverPopover.tsx +82 -0
  6. package/src/components/EditorContentHtml.tsx +29 -0
  7. package/src/components/EditorToolbar.tsx +225 -0
  8. package/src/components/EditorToolbarButton.tsx +32 -0
  9. package/src/components/EditorToolbarGroups.tsx +401 -0
  10. package/src/components/FindReplaceBar.tsx +253 -0
  11. package/src/components/MayaEditor.tsx +379 -0
  12. package/src/components/SourceInputDialog.tsx +120 -0
  13. package/src/extensions/AlertBlock.ts +59 -0
  14. package/src/extensions/CommentMark.ts +57 -0
  15. package/src/extensions/IframeBlock.ts +76 -0
  16. package/src/extensions/Indent.ts +133 -0
  17. package/src/hooks/useEditorContent.ts +47 -0
  18. package/src/i18n/en.json +54 -0
  19. package/src/i18n/es.json +54 -0
  20. package/src/index.ts +47 -0
  21. package/src/lib/CommentAnchor.ts +68 -0
  22. package/src/lib/docxToHtml.ts +58 -0
  23. package/src/lib/dompurifyConfig.test.ts +98 -0
  24. package/src/lib/dompurifyConfig.ts +123 -0
  25. package/src/lib/editorExtensions.ts +73 -0
  26. package/src/lib/htmlToMarkdown.ts +166 -0
  27. package/src/lib/htmlToTiptapDoc.test.ts +52 -0
  28. package/src/lib/htmlToTiptapDoc.ts +26 -0
  29. package/src/lib/markdownToHtml.ts +234 -0
  30. package/src/lib/normalizeTableHtml.ts +74 -0
  31. package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
  32. package/src/lib/splitHtmlIntoBlocks.ts +136 -0
  33. package/src/serializers/BlockNoteToTiptap.ts +223 -0
  34. package/src/styles/maya-editor.css +538 -0
  35. package/src/types.ts +56 -0
  36. 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
+ }