@ceedcv-maya/shared-editor-react 0.9.0 → 0.11.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/package.json +4 -1
- package/src/components/EditorContentHtml.tsx +1 -0
- package/src/components/EditorContentJson.tsx +29 -0
- package/src/components/EditorIcons.tsx +368 -0
- package/src/components/EditorToolbar.tsx +137 -52
- package/src/components/EditorToolbarGroups.tsx +53 -29
- package/src/components/MayaEditor.tsx +36 -0
- package/src/index.ts +5 -4
- package/src/lib/buildToolbarLabels.test.ts +43 -0
- package/src/lib/buildToolbarLabels.ts +38 -0
- package/src/lib/looksLikeMarkdown.test.ts +36 -0
- package/src/lib/looksLikeMarkdown.ts +37 -0
- package/src/lib/markdownIngestion.test.ts +44 -0
- package/src/lib/markdownToHtml.test.ts +25 -0
- package/src/lib/markdownToHtml.ts +4 -1
- package/src/lib/renderTiptapJson.test.ts +82 -0
- package/src/lib/renderTiptapJson.ts +72 -0
- package/src/lib/tableMenuActions.test.ts +88 -0
- package/src/lib/tableMenuActions.ts +99 -0
- package/src/lib/tiptapContentSemantics.ts +1 -1
- package/src/mammoth-browser.d.ts +3 -0
- package/src/parity/fingerprint.ts +75 -0
- package/src/parity/parity.test.ts +51 -0
- package/src/styles/maya-content.css +160 -0
- package/src/styles/maya-editor.css +40 -0
- package/src/i18n/en.json +0 -54
- package/src/i18n/es.json +0 -54
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* EditorToolbar button groups extracted into focused subcomponents.
|
|
3
3
|
* Each group handles a logically distinct set of formatting or block operations.
|
|
4
4
|
*/
|
|
5
|
+
import { Fragment } from 'react';
|
|
5
6
|
import type { Editor } from '@tiptap/react';
|
|
6
7
|
import type { ToolbarLabels } from './EditorToolbar';
|
|
7
8
|
import { ColorPicker } from './ColorPicker';
|
|
8
9
|
import { Btn } from './EditorToolbarButton';
|
|
10
|
+
import { EditorIcon } from './EditorIcons';
|
|
11
|
+
import { tableMenuActions } from '../lib/tableMenuActions';
|
|
9
12
|
|
|
10
13
|
export interface ToolbarGroupProps {
|
|
11
14
|
editor: Editor;
|
|
@@ -35,31 +38,31 @@ export function FormattingButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
35
38
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
36
39
|
title={L.bold}
|
|
37
40
|
>
|
|
38
|
-
<
|
|
41
|
+
<EditorIcon name="bold" />
|
|
39
42
|
</Btn>
|
|
40
43
|
<Btn
|
|
41
44
|
active={editor.isActive('italic')}
|
|
42
45
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
43
46
|
title={L.italic}
|
|
44
47
|
>
|
|
45
|
-
<
|
|
48
|
+
<EditorIcon name="italic" />
|
|
46
49
|
</Btn>
|
|
47
50
|
<Btn
|
|
48
51
|
active={editor.isActive('underline')}
|
|
49
52
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
50
53
|
title={L.underline}
|
|
51
54
|
>
|
|
52
|
-
<
|
|
55
|
+
<EditorIcon name="underline" />
|
|
53
56
|
</Btn>
|
|
54
57
|
<Btn
|
|
55
58
|
active={editor.isActive('code')}
|
|
56
59
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
57
60
|
title={L.code}
|
|
58
61
|
>
|
|
59
|
-
|
|
62
|
+
<EditorIcon name="code" />
|
|
60
63
|
</Btn>
|
|
61
64
|
<Btn active={editor.isActive('link')} onClick={setLink} title={L.link}>
|
|
62
|
-
|
|
65
|
+
<EditorIcon name="link" />
|
|
63
66
|
</Btn>
|
|
64
67
|
</>
|
|
65
68
|
);
|
|
@@ -76,13 +79,13 @@ export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupPro
|
|
|
76
79
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
77
80
|
title={L.strike}
|
|
78
81
|
>
|
|
79
|
-
<
|
|
82
|
+
<EditorIcon name="strike" />
|
|
80
83
|
</Btn>
|
|
81
84
|
|
|
82
85
|
<ColorPicker
|
|
83
86
|
title={L.textColor}
|
|
84
87
|
value={(editor.getAttributes('textStyle').color as string | undefined) ?? null}
|
|
85
|
-
glyph={<
|
|
88
|
+
glyph={<EditorIcon name="textColor" />}
|
|
86
89
|
clearLabel={L.colorDefault}
|
|
87
90
|
onSelect={(c) => {
|
|
88
91
|
if (c === null) editor.chain().focus().unsetColor().run();
|
|
@@ -92,7 +95,7 @@ export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupPro
|
|
|
92
95
|
<ColorPicker
|
|
93
96
|
title={L.backgroundColor}
|
|
94
97
|
value={(editor.getAttributes('highlight').color as string | undefined) ?? null}
|
|
95
|
-
glyph={<
|
|
98
|
+
glyph={<EditorIcon name="highlight" />}
|
|
96
99
|
clearLabel={L.colorDefault}
|
|
97
100
|
onSelect={(c) => {
|
|
98
101
|
if (c === null) editor.chain().focus().unsetHighlight().run();
|
|
@@ -114,28 +117,28 @@ export function AlignmentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
114
117
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
115
118
|
title={L.alignLeft}
|
|
116
119
|
>
|
|
117
|
-
|
|
120
|
+
<EditorIcon name="alignLeft" />
|
|
118
121
|
</Btn>
|
|
119
122
|
<Btn
|
|
120
123
|
active={editor.isActive({ textAlign: 'center' })}
|
|
121
124
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
122
125
|
title={L.alignCenter}
|
|
123
126
|
>
|
|
124
|
-
|
|
127
|
+
<EditorIcon name="alignCenter" />
|
|
125
128
|
</Btn>
|
|
126
129
|
<Btn
|
|
127
130
|
active={editor.isActive({ textAlign: 'right' })}
|
|
128
131
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
|
129
132
|
title={L.alignRight}
|
|
130
133
|
>
|
|
131
|
-
|
|
134
|
+
<EditorIcon name="alignRight" />
|
|
132
135
|
</Btn>
|
|
133
136
|
<Btn
|
|
134
137
|
active={editor.isActive({ textAlign: 'justify' })}
|
|
135
138
|
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
|
136
139
|
title={L.alignJustify}
|
|
137
140
|
>
|
|
138
|
-
|
|
141
|
+
<EditorIcon name="alignJustify" />
|
|
139
142
|
</Btn>
|
|
140
143
|
</>
|
|
141
144
|
);
|
|
@@ -159,7 +162,7 @@ export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
159
162
|
}}
|
|
160
163
|
title={L.indent}
|
|
161
164
|
>
|
|
162
|
-
|
|
165
|
+
<EditorIcon name="indent" />
|
|
163
166
|
</Btn>
|
|
164
167
|
<Btn
|
|
165
168
|
onClick={() => {
|
|
@@ -173,7 +176,7 @@ export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
173
176
|
}}
|
|
174
177
|
title={L.outdent}
|
|
175
178
|
>
|
|
176
|
-
|
|
179
|
+
<EditorIcon name="outdent" />
|
|
177
180
|
</Btn>
|
|
178
181
|
</>
|
|
179
182
|
);
|
|
@@ -221,41 +224,41 @@ export function ListAndBlockButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
221
224
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
222
225
|
title={L.bulletList}
|
|
223
226
|
>
|
|
224
|
-
|
|
227
|
+
<EditorIcon name="bulletList" />
|
|
225
228
|
</Btn>
|
|
226
229
|
<Btn
|
|
227
230
|
active={editor.isActive('orderedList')}
|
|
228
231
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
229
232
|
title={L.orderedList}
|
|
230
233
|
>
|
|
231
|
-
|
|
234
|
+
<EditorIcon name="orderedList" />
|
|
232
235
|
</Btn>
|
|
233
236
|
<Btn
|
|
234
237
|
active={editor.isActive('taskList')}
|
|
235
238
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
|
236
239
|
title={L.taskList}
|
|
237
240
|
>
|
|
238
|
-
|
|
241
|
+
<EditorIcon name="taskList" />
|
|
239
242
|
</Btn>
|
|
240
243
|
<Btn
|
|
241
244
|
active={editor.isActive('blockquote')}
|
|
242
245
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
243
246
|
title={L.blockquote}
|
|
244
247
|
>
|
|
245
|
-
|
|
248
|
+
<EditorIcon name="blockquote" />
|
|
246
249
|
</Btn>
|
|
247
250
|
<Btn
|
|
248
251
|
active={editor.isActive('codeBlock')}
|
|
249
252
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
250
253
|
title={L.codeBlock}
|
|
251
254
|
>
|
|
252
|
-
|
|
255
|
+
<EditorIcon name="codeBlock" />
|
|
253
256
|
</Btn>
|
|
254
257
|
<Btn
|
|
255
258
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
256
259
|
title={L.horizontalRule}
|
|
257
260
|
>
|
|
258
|
-
|
|
261
|
+
<EditorIcon name="horizontalRule" />
|
|
259
262
|
</Btn>
|
|
260
263
|
</>
|
|
261
264
|
);
|
|
@@ -287,20 +290,41 @@ export function TableAndMediaButtons({
|
|
|
287
290
|
}
|
|
288
291
|
title={L.table}
|
|
289
292
|
>
|
|
290
|
-
|
|
293
|
+
<EditorIcon name="table" />
|
|
291
294
|
</Btn>
|
|
292
295
|
<Btn onClick={setIframe} title={L.iframe}>
|
|
293
|
-
|
|
296
|
+
<EditorIcon name="iframe" />
|
|
294
297
|
</Btn>
|
|
295
298
|
{onImage && (
|
|
296
299
|
<Btn onClick={onImage} title={L.uploadImage}>
|
|
297
|
-
|
|
300
|
+
<EditorIcon name="image" />
|
|
298
301
|
</Btn>
|
|
299
302
|
)}
|
|
300
303
|
</>
|
|
301
304
|
);
|
|
302
305
|
}
|
|
303
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Contextual table-editing buttons: add/delete rows & columns, toggle header,
|
|
309
|
+
* delete table. Rendered in the main toolbar only while the selection is inside
|
|
310
|
+
* a table (the caller gates on `editor.isActive('table')`), so it appears and
|
|
311
|
+
* hides in place instead of floating over the content.
|
|
312
|
+
*/
|
|
313
|
+
export function TableButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
314
|
+
return (
|
|
315
|
+
<>
|
|
316
|
+
{tableMenuActions(editor, L).map((a) => (
|
|
317
|
+
<Fragment key={a.key}>
|
|
318
|
+
{a.separatorBefore && <span className="maya-editor-toolbar__sep" aria-hidden />}
|
|
319
|
+
<Btn onClick={a.run} title={a.title} disabled={a.disabled}>
|
|
320
|
+
<EditorIcon name={a.icon} />
|
|
321
|
+
</Btn>
|
|
322
|
+
</Fragment>
|
|
323
|
+
))}
|
|
324
|
+
</>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
304
328
|
/**
|
|
305
329
|
* Import/export and document buttons: Word import/export, comments, find.
|
|
306
330
|
*/
|
|
@@ -323,12 +347,12 @@ export function DocumentButtons({
|
|
|
323
347
|
<>
|
|
324
348
|
{onImportDocx && (
|
|
325
349
|
<Btn onClick={onImportDocx} title={L.importDocx}>
|
|
326
|
-
<
|
|
350
|
+
<EditorIcon name="importDocx" />
|
|
327
351
|
</Btn>
|
|
328
352
|
)}
|
|
329
353
|
{onExportDocx && (
|
|
330
354
|
<Btn onClick={onExportDocx} title={L.exportDocx}>
|
|
331
|
-
<
|
|
355
|
+
<EditorIcon name="exportDocx" />
|
|
332
356
|
</Btn>
|
|
333
357
|
)}
|
|
334
358
|
{onAddComment && (
|
|
@@ -337,12 +361,12 @@ export function DocumentButtons({
|
|
|
337
361
|
disabled={editor.state.selection.empty}
|
|
338
362
|
title={L.addComment}
|
|
339
363
|
>
|
|
340
|
-
|
|
364
|
+
<EditorIcon name="comment" />
|
|
341
365
|
</Btn>
|
|
342
366
|
)}
|
|
343
367
|
{onToggleFind && (
|
|
344
368
|
<Btn onClick={onToggleFind} title={L.find}>
|
|
345
|
-
|
|
369
|
+
<EditorIcon name="find" />
|
|
346
370
|
</Btn>
|
|
347
371
|
)}
|
|
348
372
|
</>
|
|
@@ -385,7 +409,7 @@ export function ViewModeButtons({
|
|
|
385
409
|
title={L.insertHtml}
|
|
386
410
|
active={viewMode === 'html'}
|
|
387
411
|
>
|
|
388
|
-
<
|
|
412
|
+
<EditorIcon name="htmlSource" />
|
|
389
413
|
</Btn>
|
|
390
414
|
)}
|
|
391
415
|
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
@@ -394,7 +418,7 @@ export function ViewModeButtons({
|
|
|
394
418
|
onClick={onToggleFullscreen}
|
|
395
419
|
title={isFullscreen ? L.exitFullscreen : L.fullscreen}
|
|
396
420
|
>
|
|
397
|
-
{isFullscreen ? '
|
|
421
|
+
<EditorIcon name={isFullscreen ? 'exitFullscreen' : 'fullscreen'} />
|
|
398
422
|
</Btn>
|
|
399
423
|
)}
|
|
400
424
|
</>
|
|
@@ -9,6 +9,7 @@ import { useEditorContent, type EditorOutput } from '../hooks/useEditorContent';
|
|
|
9
9
|
import { sanitizeEditorHtml } from '../lib/dompurifyConfig';
|
|
10
10
|
import { markdownToHtml } from '../lib/markdownToHtml';
|
|
11
11
|
import { htmlToMarkdown } from '../lib/htmlToMarkdown';
|
|
12
|
+
import { looksLikeMarkdown } from '../lib/looksLikeMarkdown';
|
|
12
13
|
import { normalizeTableHtml } from '../lib/normalizeTableHtml';
|
|
13
14
|
import { docxToHtml } from '../lib/docxToHtml';
|
|
14
15
|
import type { EditorMode, TiptapDoc } from '../types';
|
|
@@ -141,6 +142,10 @@ export function MayaEditor({
|
|
|
141
142
|
|
|
142
143
|
const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
|
|
143
144
|
|
|
145
|
+
// Stable handle to the editor for use inside editorProps callbacks, which are
|
|
146
|
+
// defined before `useEditor` returns.
|
|
147
|
+
const editorRef = useRef<Editor | null>(null);
|
|
148
|
+
|
|
144
149
|
const editor = useEditor({
|
|
145
150
|
extensions,
|
|
146
151
|
content: initialContent ?? '',
|
|
@@ -153,9 +158,28 @@ export function MayaEditor({
|
|
|
153
158
|
// Reshape pasted HTML so complex tables (caption/tfoot/colgroup)
|
|
154
159
|
// survive TipTap's parser. See `normalizeTableHtml` for details.
|
|
155
160
|
transformPastedHTML: (html) => normalizeTableHtml(html),
|
|
161
|
+
// Plain-text paste that is actually Markdown is converted to structured
|
|
162
|
+
// nodes instead of being stored as a literal text node (which would show
|
|
163
|
+
// "## " / "**bold**" verbatim in previews). Rich paste carries text/html
|
|
164
|
+
// and is left to `transformPastedHTML` / the default handler.
|
|
165
|
+
handlePaste: (_view, event) => {
|
|
166
|
+
const cb = event.clipboardData;
|
|
167
|
+
if (!cb) return false;
|
|
168
|
+
if (cb.getData('text/html')) return false;
|
|
169
|
+
const text = cb.getData('text/plain');
|
|
170
|
+
if (!text || !looksLikeMarkdown(text)) return false;
|
|
171
|
+
const activeEditor = editorRef.current;
|
|
172
|
+
if (!activeEditor) return false;
|
|
173
|
+
const html = sanitizeEditorHtml(normalizeTableHtml(markdownToHtml(text)));
|
|
174
|
+
if (!html) return false;
|
|
175
|
+
activeEditor.commands.insertContent(html);
|
|
176
|
+
return true;
|
|
177
|
+
},
|
|
156
178
|
},
|
|
157
179
|
});
|
|
158
180
|
|
|
181
|
+
editorRef.current = editor;
|
|
182
|
+
|
|
159
183
|
useEditorContent(editor, onChange, { output: effectiveOutput });
|
|
160
184
|
|
|
161
185
|
const readPayloadFromEditor = useCallback((): string | TiptapDoc | undefined => {
|
|
@@ -302,6 +326,18 @@ export function MayaEditor({
|
|
|
302
326
|
onFullscreenChange(isFullscreen);
|
|
303
327
|
}, [isFullscreen, onFullscreenChange]);
|
|
304
328
|
|
|
329
|
+
// Mirror fullscreen to a global `editor-fullscreen` class on <html> so the
|
|
330
|
+
// host AppLayout can hide its fixed sidebar and drop the content margin while
|
|
331
|
+
// the editor covers the viewport. Self-contained: works even when the host
|
|
332
|
+
// doesn't handle `onFullscreenChange` (e.g. the inline editors in the
|
|
333
|
+
// continuous document view) — otherwise the fixed editor sits *under* the
|
|
334
|
+
// still-visible sidebar and the left half is hidden.
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
const root = document.documentElement;
|
|
337
|
+
root.classList.toggle('editor-fullscreen', isFullscreen);
|
|
338
|
+
return () => root.classList.remove('editor-fullscreen');
|
|
339
|
+
}, [isFullscreen]);
|
|
340
|
+
|
|
305
341
|
if (!editor) return null;
|
|
306
342
|
|
|
307
343
|
const editorReady = viewReady && isEditorReady(editor);
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export { MayaEditor } from './components/MayaEditor';
|
|
2
2
|
export type { MayaEditorProps } from './components/MayaEditor';
|
|
3
3
|
export { EditorContentHtml } from './components/EditorContentHtml';
|
|
4
|
-
export {
|
|
4
|
+
export { EditorContentJson } from './components/EditorContentJson';
|
|
5
|
+
export { EditorToolbar, DEFAULT_LABELS } from './components/EditorToolbar';
|
|
5
6
|
export type { ToolbarLabels } from './components/EditorToolbar';
|
|
7
|
+
export { buildToolbarLabels } from './lib/buildToolbarLabels';
|
|
8
|
+
export type { TranslateFn } from './lib/buildToolbarLabels';
|
|
6
9
|
|
|
7
10
|
export { IframeBlock } from './extensions/IframeBlock';
|
|
8
11
|
export { AlertBlock } from './extensions/AlertBlock';
|
|
@@ -23,6 +26,7 @@ export { normalizeTableHtml } from './lib/normalizeTableHtml';
|
|
|
23
26
|
export { splitHtmlIntoBlocks } from './lib/splitHtmlIntoBlocks';
|
|
24
27
|
export type { BlockChunk, BlockChunkType } from './lib/splitHtmlIntoBlocks';
|
|
25
28
|
export { htmlToTiptapDoc } from './lib/htmlToTiptapDoc';
|
|
29
|
+
export { renderTiptapJsonToHtml } from './lib/renderTiptapJson';
|
|
26
30
|
export { buildMayaEditorExtensions } from './lib/editorExtensions';
|
|
27
31
|
export { isEditorReady } from './lib/isEditorReady';
|
|
28
32
|
export {
|
|
@@ -50,6 +54,3 @@ export type {
|
|
|
50
54
|
TiptapDoc,
|
|
51
55
|
AnchoredComment,
|
|
52
56
|
} from './types';
|
|
53
|
-
|
|
54
|
-
export { default as esTranslations } from './i18n/es.json';
|
|
55
|
-
export { default as enTranslations } from './i18n/en.json';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildToolbarLabels } from './buildToolbarLabels';
|
|
3
|
+
import { DEFAULT_LABELS } from '../components/EditorToolbar';
|
|
4
|
+
|
|
5
|
+
describe('buildToolbarLabels', () => {
|
|
6
|
+
it('maps every label key through t("editor.<key>")', () => {
|
|
7
|
+
const dict: Record<string, string> = {
|
|
8
|
+
'editor.bold': 'Negrita',
|
|
9
|
+
'editor.italic': 'Cursiva',
|
|
10
|
+
'editor.groupFont': 'Fuente',
|
|
11
|
+
'editor.tableAddColumnAfter': 'Insertar columna a la derecha',
|
|
12
|
+
};
|
|
13
|
+
const t = (k: string) => dict[k] ?? k;
|
|
14
|
+
|
|
15
|
+
const labels = buildToolbarLabels(t);
|
|
16
|
+
expect(labels.bold).toBe('Negrita');
|
|
17
|
+
expect(labels.italic).toBe('Cursiva');
|
|
18
|
+
expect(labels.groupFont).toBe('Fuente');
|
|
19
|
+
expect(labels.tableAddColumnAfter).toBe('Insertar columna a la derecha');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('falls back to the English default when a key is missing (t returns the key)', () => {
|
|
23
|
+
const t = (k: string) => k; // i18next miss behaviour: returns the key
|
|
24
|
+
const labels = buildToolbarLabels(t);
|
|
25
|
+
expect(labels.bold).toBe(DEFAULT_LABELS.bold);
|
|
26
|
+
expect(labels.groupParagraph).toBe(DEFAULT_LABELS.groupParagraph);
|
|
27
|
+
// never leaks a raw "editor.*" key
|
|
28
|
+
expect(labels.bold).not.toContain('editor.');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('honours a custom prefix', () => {
|
|
32
|
+
const t = (k: string) => (k === 'tt.bold' ? 'B!' : k);
|
|
33
|
+
const labels = buildToolbarLabels(t, 'tt');
|
|
34
|
+
expect(labels.bold).toBe('B!');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a fully-populated object (all default keys present)', () => {
|
|
38
|
+
const labels = buildToolbarLabels((k) => k);
|
|
39
|
+
for (const key of Object.keys(DEFAULT_LABELS)) {
|
|
40
|
+
expect(labels[key as keyof typeof labels]).toBeTruthy();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a fully-populated {@link ToolbarLabels} from a translation function.
|
|
3
|
+
*
|
|
4
|
+
* The editor itself is i18n-agnostic (it just takes a `toolbarLabels` prop), so
|
|
5
|
+
* each consumer wires its own i18n. This helper keeps that wiring to one line:
|
|
6
|
+
* pass the app's `t` (bound to the namespace that holds the shared `editor`
|
|
7
|
+
* canon — usually `common`) and it maps every label key to `t('editor.<key>')`.
|
|
8
|
+
*
|
|
9
|
+
* const { t } = useTranslation('common');
|
|
10
|
+
* <MayaEditor toolbarLabels={buildToolbarLabels(t)} … />
|
|
11
|
+
*
|
|
12
|
+
* Any key missing from the active locale falls back to the English default
|
|
13
|
+
* (i18next returns the key unchanged for a miss, which we detect and replace),
|
|
14
|
+
* so a partially-translated locale never shows raw `editor.*` keys.
|
|
15
|
+
*/
|
|
16
|
+
import { DEFAULT_LABELS } from '../components/EditorToolbar';
|
|
17
|
+
import type { ToolbarLabels } from '../components/EditorToolbar';
|
|
18
|
+
|
|
19
|
+
/** Minimal shape of an i18next `t` — only the (key) → string call is used. */
|
|
20
|
+
export type TranslateFn = (key: string) => string;
|
|
21
|
+
|
|
22
|
+
export function buildToolbarLabels(
|
|
23
|
+
t: TranslateFn,
|
|
24
|
+
prefix = 'editor',
|
|
25
|
+
): ToolbarLabels {
|
|
26
|
+
const out: ToolbarLabels = { ...DEFAULT_LABELS };
|
|
27
|
+
|
|
28
|
+
(Object.keys(DEFAULT_LABELS) as (keyof ToolbarLabels)[]).forEach((key) => {
|
|
29
|
+
const fullKey = `${prefix}.${key}`;
|
|
30
|
+
const value = t(fullKey);
|
|
31
|
+
// i18next returns the key itself on a miss → keep the English default.
|
|
32
|
+
if (value && value !== fullKey) {
|
|
33
|
+
out[key] = value;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { looksLikeMarkdown } from './looksLikeMarkdown';
|
|
3
|
+
|
|
4
|
+
describe('looksLikeMarkdown', () => {
|
|
5
|
+
it('detects ATX headings', () => {
|
|
6
|
+
expect(looksLikeMarkdown('## Programa del curso')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('detects ordered and unordered lists', () => {
|
|
10
|
+
expect(looksLikeMarkdown('1. **Introducción a Laravel**')).toBe(true);
|
|
11
|
+
expect(looksLikeMarkdown('- item uno\n- item dos')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('detects fenced code and blockquotes and tables', () => {
|
|
15
|
+
expect(looksLikeMarkdown('```\ncode\n```')).toBe(true);
|
|
16
|
+
expect(looksLikeMarkdown('> cita')).toBe(true);
|
|
17
|
+
expect(looksLikeMarkdown('| a | b |\n| --- | --- |')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does NOT flag intentional pipe text without a delimiter row', () => {
|
|
21
|
+
expect(looksLikeMarkdown('| Total 1º ASIR | 30 | 1000 | 265')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('detects inline bold/code/links', () => {
|
|
25
|
+
expect(looksLikeMarkdown('texto con **negrita** dentro')).toBe(true);
|
|
26
|
+
expect(looksLikeMarkdown('usa `composer install` aquí')).toBe(true);
|
|
27
|
+
expect(looksLikeMarkdown('ver [docs](https://x.y)')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('leaves ordinary prose untouched', () => {
|
|
31
|
+
expect(looksLikeMarkdown('Esto es un párrafo normal sin formato.')).toBe(false);
|
|
32
|
+
expect(looksLikeMarkdown('Precio: 5 * 3 = 15')).toBe(false);
|
|
33
|
+
expect(looksLikeMarkdown('C# y F# son lenguajes')).toBe(false);
|
|
34
|
+
expect(looksLikeMarkdown('')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic: does this plain text contain Markdown syntax worth converting to
|
|
3
|
+
* structured nodes?
|
|
4
|
+
*
|
|
5
|
+
* Used by the editor paste handler (convert pasted Markdown → TipTap nodes
|
|
6
|
+
* instead of storing it as a literal text node) and mirrored by the backend
|
|
7
|
+
* data-repair detection. Kept conservative so ordinary prose is NOT rewritten:
|
|
8
|
+
* a single stray `*` or `#` is ignored; we require a real block marker at the
|
|
9
|
+
* start of a line or an unambiguous inline pair.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BLOCK_PATTERNS: RegExp[] = [
|
|
13
|
+
/^#{1,6}\s+\S/m, // ATX heading: "## Title"
|
|
14
|
+
/^\s*[-*+]\s+\S/m, // unordered list item: "- item"
|
|
15
|
+
/^\s*\d+\.\s+\S/m, // ordered list item: "1. item"
|
|
16
|
+
/^\s*>\s+\S/m, // blockquote: "> quote"
|
|
17
|
+
/^```/m, // fenced code block
|
|
18
|
+
// GFM table: require a delimiter row (|---|), so intentional pipe text like
|
|
19
|
+
// "| Total | 30 |" (no delimiter) is NOT flagged as markdown.
|
|
20
|
+
/\|\s*:?-{3,}|-{3,}:?\s*\|/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const INLINE_PATTERNS: RegExp[] = [
|
|
24
|
+
/\*\*[^\s*][^*]*\*\*/, // **bold**
|
|
25
|
+
/__[^\s_][^_]*__/, // __bold__
|
|
26
|
+
/~~[^\s~][^~]*~~/, // ~~strike~~
|
|
27
|
+
/`[^`\n]+`/, // `inline code`
|
|
28
|
+
/\[[^\]]+\]\([^)\s]+\)/, // [text](url)
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function looksLikeMarkdown(text: string): boolean {
|
|
32
|
+
if (!text || text.length < 2) return false;
|
|
33
|
+
if (BLOCK_PATTERNS.some((re) => re.test(text))) return true;
|
|
34
|
+
// Require an inline marker to actually be present; a lone "*" won't match the
|
|
35
|
+
// paired patterns above, so prose stays untouched.
|
|
36
|
+
return INLINE_PATTERNS.some((re) => re.test(text));
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the paste/ingestion contract used by MayaEditor's `handlePaste`:
|
|
3
|
+
* Markdown text → HTML (markdownToHtml) → TipTap nodes (htmlToTiptapDoc) must
|
|
4
|
+
* yield STRUCTURED nodes, never a single literal text node. This is the fix for
|
|
5
|
+
* the "## " / "**bold**" rendered verbatim bug (a data/ingestion problem).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { markdownToHtml } from './markdownToHtml';
|
|
9
|
+
import { htmlToTiptapDoc } from './htmlToTiptapDoc';
|
|
10
|
+
import { looksLikeMarkdown } from './looksLikeMarkdown';
|
|
11
|
+
|
|
12
|
+
function ingest(md: string) {
|
|
13
|
+
return htmlToTiptapDoc(markdownToHtml(md));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('markdown ingestion pipeline', () => {
|
|
17
|
+
it('turns a heading line into a heading node, not literal "## "', () => {
|
|
18
|
+
const md = '## Programa del curso';
|
|
19
|
+
expect(looksLikeMarkdown(md)).toBe(true);
|
|
20
|
+
const doc = ingest(md);
|
|
21
|
+
const heading = doc.content.find((n) => n.type === 'heading');
|
|
22
|
+
expect(heading).toBeTruthy();
|
|
23
|
+
expect(heading?.attrs?.level).toBe(2);
|
|
24
|
+
const text = JSON.stringify(doc);
|
|
25
|
+
expect(text).not.toContain('## Programa');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('turns an ordered list with bold into list + strong nodes', () => {
|
|
29
|
+
const doc = ingest('1. **Introducción a Laravel 13.**');
|
|
30
|
+
const list = doc.content.find((n) => n.type === 'orderedList');
|
|
31
|
+
expect(list).toBeTruthy();
|
|
32
|
+
const json = JSON.stringify(doc);
|
|
33
|
+
expect(json).toContain('"bold"');
|
|
34
|
+
expect(json).not.toContain('**Introducción');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('turns inline bold into a strong node', () => {
|
|
38
|
+
const doc = ingest('CICLO FORMATIVO DE **NOMBRE_DEL_CICLO**');
|
|
39
|
+
const json = JSON.stringify(doc);
|
|
40
|
+
expect(json).toContain('"bold"');
|
|
41
|
+
expect(json).toContain('NOMBRE_DEL_CICLO');
|
|
42
|
+
expect(json).not.toContain('**NOMBRE_DEL_CICLO**');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { markdownToHtml } from './markdownToHtml';
|
|
3
|
+
|
|
4
|
+
describe('markdownToHtml', () => {
|
|
5
|
+
it('bolds **text** and emphasises *text*', () => {
|
|
6
|
+
expect(markdownToHtml('**b**')).toContain('<strong>b</strong>');
|
|
7
|
+
expect(markdownToHtml('*i*')).toContain('<em>i</em>');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('does NOT emphasise intra-word underscores (CommonMark)', () => {
|
|
11
|
+
// Regression: placeholders like NOMBRE_DEL_CICLO must survive intact.
|
|
12
|
+
const html = markdownToHtml('CICLO DE **NOMBRE_DEL_CICLO**');
|
|
13
|
+
expect(html).toContain('NOMBRE_DEL_CICLO');
|
|
14
|
+
expect(html).not.toContain('<em>DEL</em>');
|
|
15
|
+
expect(html).toContain('<strong>NOMBRE_DEL_CICLO</strong>');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('still emphasises standalone _word_', () => {
|
|
19
|
+
expect(markdownToHtml('hola _mundo_ chau')).toContain('<em>mundo</em>');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders ATX headings', () => {
|
|
23
|
+
expect(markdownToHtml('## Programa')).toMatch(/<h2[^>]*>Programa<\/h2>/);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -43,7 +43,10 @@ function applyInline(text: string): string {
|
|
|
43
43
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
44
44
|
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
45
45
|
out = out.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
|
|
46
|
-
|
|
46
|
+
// Underscore emphasis must not fire intra-word (CommonMark): `a_b_c` stays
|
|
47
|
+
// literal, so placeholders like `NOMBRE_DEL_CICLO` survive. Require a
|
|
48
|
+
// non-word boundary on both sides.
|
|
49
|
+
out = out.replace(/(?<![\w_])_([^_\n]+?)_(?![\w_])/g, '<em>$1</em>');
|
|
47
50
|
// Strikethrough.
|
|
48
51
|
out = out.replace(/~~([^~]+)~~/g, '<s>$1</s>');
|
|
49
52
|
// Links — only http/https/mailto/tel; reject `javascript:` etc.
|