@ceedcv-maya/shared-editor-react 0.7.0 → 0.9.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
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
|
|
2
2
|
import { EditorContent, useEditor } from '@tiptap/react';
|
|
3
3
|
import type { Editor } from '@tiptap/react';
|
|
4
4
|
|
|
5
5
|
import { buildMayaEditorExtensions } from '../lib/editorExtensions';
|
|
6
|
+
import { normalizeTiptapDocPayload } from '../lib/tiptapContentSemantics';
|
|
7
|
+
import { isEditorReady } from '../lib/isEditorReady';
|
|
6
8
|
import { useEditorContent, type EditorOutput } from '../hooks/useEditorContent';
|
|
7
9
|
import { sanitizeEditorHtml } from '../lib/dompurifyConfig';
|
|
8
10
|
import { markdownToHtml } from '../lib/markdownToHtml';
|
|
@@ -28,6 +30,16 @@ export interface MayaEditorProps {
|
|
|
28
30
|
mode?: EditorMode;
|
|
29
31
|
/** Debounced change callback (300ms). Payload depends on `output`. */
|
|
30
32
|
onChange?: (payload: string | TiptapDoc) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Llamado tras sincronizar el contenido (blur, cambio de bloque, destroy).
|
|
35
|
+
* Recibe el payload ya leído del editor; el padre suele enlazarlo a `forceSave`.
|
|
36
|
+
*/
|
|
37
|
+
onFlush?: (payload?: string | TiptapDoc) => void | Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Ref opcional para invocar flush+sync desde fuera (p. ej. antes de cambiar de bloque),
|
|
40
|
+
* evitando perder el último keystroke por el debounce de `onChange`.
|
|
41
|
+
*/
|
|
42
|
+
editorFlushRef?: MutableRefObject<(() => void | Promise<void>) | null>;
|
|
31
43
|
/**
|
|
32
44
|
* Output shape: `'html'` (default) emits a sanitisation-ready string;
|
|
33
45
|
* `'json'` emits the full ProseMirror doc `{type:'doc', content:[…]}`,
|
|
@@ -94,6 +106,8 @@ export function MayaEditor({
|
|
|
94
106
|
onCreateComment,
|
|
95
107
|
onExportDocx,
|
|
96
108
|
commentsById,
|
|
109
|
+
onFlush,
|
|
110
|
+
editorFlushRef,
|
|
97
111
|
}: MayaEditorProps) {
|
|
98
112
|
const effectiveOutput: EditorOutput = output ?? (mode === 'full' ? 'json' : 'html');
|
|
99
113
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
@@ -115,6 +129,15 @@ export function MayaEditor({
|
|
|
115
129
|
const [viewReady, setViewReady] = useState(false);
|
|
116
130
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
117
131
|
const docxInputRef = useRef<HTMLInputElement | null>(null);
|
|
132
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
133
|
+
const onChangeRef = useRef(onChange);
|
|
134
|
+
const onFlushRef = useRef(onFlush);
|
|
135
|
+
const viewModeRef = useRef(viewMode);
|
|
136
|
+
const sourceTextRef = useRef(sourceText);
|
|
137
|
+
onChangeRef.current = onChange;
|
|
138
|
+
onFlushRef.current = onFlush;
|
|
139
|
+
viewModeRef.current = viewMode;
|
|
140
|
+
sourceTextRef.current = sourceText;
|
|
118
141
|
|
|
119
142
|
const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
|
|
120
143
|
|
|
@@ -135,26 +158,93 @@ export function MayaEditor({
|
|
|
135
158
|
|
|
136
159
|
useEditorContent(editor, onChange, { output: effectiveOutput });
|
|
137
160
|
|
|
161
|
+
const readPayloadFromEditor = useCallback((): string | TiptapDoc | undefined => {
|
|
162
|
+
if (!editor) return undefined;
|
|
163
|
+
const rawPayload =
|
|
164
|
+
effectiveOutput === 'json'
|
|
165
|
+
? (editor.getJSON() as TiptapDoc)
|
|
166
|
+
: editor.getHTML();
|
|
167
|
+
return effectiveOutput === 'json'
|
|
168
|
+
? normalizeTiptapDocPayload(rawPayload)
|
|
169
|
+
: rawPayload;
|
|
170
|
+
}, [editor, effectiveOutput]);
|
|
171
|
+
|
|
172
|
+
const syncContentToParent = useCallback((): string | TiptapDoc | undefined => {
|
|
173
|
+
const payload = readPayloadFromEditor();
|
|
174
|
+
if (payload === undefined) return undefined;
|
|
175
|
+
onChangeRef.current?.(payload);
|
|
176
|
+
return payload;
|
|
177
|
+
}, [readPayloadFromEditor]);
|
|
178
|
+
|
|
179
|
+
const requestFlush = useCallback(async () => {
|
|
180
|
+
if (!editor) return;
|
|
181
|
+
|
|
182
|
+
const mode = viewModeRef.current;
|
|
183
|
+
if (mode !== 'wysiwyg') {
|
|
184
|
+
const rawHtml =
|
|
185
|
+
mode === 'markdown'
|
|
186
|
+
? markdownToHtml(sourceTextRef.current)
|
|
187
|
+
: sourceTextRef.current;
|
|
188
|
+
const html = sanitizeEditorHtml(normalizeTableHtml(rawHtml));
|
|
189
|
+
editor.commands.setContent(html, { emitUpdate: false });
|
|
190
|
+
setViewMode('wysiwyg');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const payload = syncContentToParent();
|
|
194
|
+
await onFlushRef.current?.(payload);
|
|
195
|
+
}, [editor, syncContentToParent]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!editorFlushRef) return;
|
|
199
|
+
editorFlushRef.current = requestFlush;
|
|
200
|
+
return () => {
|
|
201
|
+
editorFlushRef.current = null;
|
|
202
|
+
};
|
|
203
|
+
}, [editorFlushRef, requestFlush]);
|
|
204
|
+
|
|
138
205
|
useEffect(() => {
|
|
139
206
|
if (editor && onEditorReady) onEditorReady(editor);
|
|
140
207
|
}, [editor, onEditorReady]);
|
|
141
208
|
|
|
142
209
|
useEffect(() => {
|
|
210
|
+
if (!editor || !onFlush) return;
|
|
211
|
+
const onDestroy = () => {
|
|
212
|
+
void requestFlush();
|
|
213
|
+
};
|
|
214
|
+
editor.on('destroy', onDestroy);
|
|
215
|
+
return () => {
|
|
216
|
+
editor.off('destroy', onDestroy);
|
|
217
|
+
};
|
|
218
|
+
}, [editor, onFlush, requestFlush]);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
setViewReady(false);
|
|
143
222
|
if (!editor) return;
|
|
144
|
-
|
|
223
|
+
|
|
224
|
+
const bump = () => {
|
|
225
|
+
if (!isEditorReady(editor)) return;
|
|
226
|
+
setSelectionVersion((v) => v + 1);
|
|
227
|
+
};
|
|
145
228
|
const markReady = () => setViewReady(true);
|
|
229
|
+
const markNotReady = () => setViewReady(false);
|
|
230
|
+
|
|
146
231
|
editor.on('selectionUpdate', bump);
|
|
147
232
|
editor.on('transaction', bump);
|
|
148
233
|
editor.on('create', markReady);
|
|
149
|
-
|
|
150
|
-
|
|
234
|
+
editor.on('destroy', markNotReady);
|
|
235
|
+
|
|
151
236
|
try {
|
|
152
|
-
if (editor.view
|
|
153
|
-
} catch {
|
|
237
|
+
if (editor.view?.dom) setViewReady(true);
|
|
238
|
+
} catch {
|
|
239
|
+
/* view not ready yet — markReady will fire on create */
|
|
240
|
+
}
|
|
241
|
+
|
|
154
242
|
return () => {
|
|
155
243
|
editor.off('selectionUpdate', bump);
|
|
156
244
|
editor.off('transaction', bump);
|
|
157
245
|
editor.off('create', markReady);
|
|
246
|
+
editor.off('destroy', markNotReady);
|
|
247
|
+
setViewReady(false);
|
|
158
248
|
};
|
|
159
249
|
}, [editor]);
|
|
160
250
|
|
|
@@ -214,6 +304,8 @@ export function MayaEditor({
|
|
|
214
304
|
|
|
215
305
|
if (!editor) return null;
|
|
216
306
|
|
|
307
|
+
const editorReady = viewReady && isEditorReady(editor);
|
|
308
|
+
|
|
217
309
|
const handlePickImage = async (file: File) => {
|
|
218
310
|
if (!uploadFile) return;
|
|
219
311
|
try {
|
|
@@ -250,7 +342,8 @@ export function MayaEditor({
|
|
|
250
342
|
.run();
|
|
251
343
|
};
|
|
252
344
|
|
|
253
|
-
const enterSource = (target: 'html' | 'markdown') => {
|
|
345
|
+
const enterSource = async (target: 'html' | 'markdown') => {
|
|
346
|
+
syncContentToParent();
|
|
254
347
|
const currentHtml = editor.getHTML();
|
|
255
348
|
const text = target === 'html' ? currentHtml : htmlToMarkdown(currentHtml);
|
|
256
349
|
setSourceText(text);
|
|
@@ -279,7 +372,7 @@ export function MayaEditor({
|
|
|
279
372
|
const html = sanitizeEditorHtml(markdownToHtml(sourceText));
|
|
280
373
|
setSourceText(html);
|
|
281
374
|
setViewMode('html');
|
|
282
|
-
} else enterSource('html');
|
|
375
|
+
} else void enterSource('html');
|
|
283
376
|
};
|
|
284
377
|
|
|
285
378
|
const toggleMarkdown = () => {
|
|
@@ -288,13 +381,21 @@ export function MayaEditor({
|
|
|
288
381
|
const md = htmlToMarkdown(sourceText);
|
|
289
382
|
setSourceText(md);
|
|
290
383
|
setViewMode('markdown');
|
|
291
|
-
} else enterSource('markdown');
|
|
384
|
+
} else void enterSource('markdown');
|
|
292
385
|
};
|
|
293
386
|
|
|
294
387
|
return (
|
|
295
388
|
<div
|
|
389
|
+
ref={wrapperRef}
|
|
296
390
|
className={`maya-editor-wrapper${isFullscreen ? ' is-fullscreen' : ''}${isDark ? ' is-dark' : ''}`}
|
|
391
|
+
onBlur={(e) => {
|
|
392
|
+
if (!onFlush) return;
|
|
393
|
+
const next = e.relatedTarget as Node | null;
|
|
394
|
+
if (next && wrapperRef.current?.contains(next)) return;
|
|
395
|
+
void requestFlush();
|
|
396
|
+
}}
|
|
297
397
|
>
|
|
398
|
+
{editorReady && (
|
|
298
399
|
<EditorToolbar
|
|
299
400
|
editor={editor}
|
|
300
401
|
mode={mode}
|
|
@@ -312,7 +413,8 @@ export function MayaEditor({
|
|
|
312
413
|
onToggleFind={mode === 'full' ? () => setFindOpen((v) => !v) : undefined}
|
|
313
414
|
labels={toolbarLabels}
|
|
314
415
|
/>
|
|
315
|
-
|
|
416
|
+
)}
|
|
417
|
+
{mode === 'full' && editorReady && (
|
|
316
418
|
<FindReplaceBar
|
|
317
419
|
editor={editor}
|
|
318
420
|
open={findOpen}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import type { Editor } from '@tiptap/react';
|
|
3
3
|
import type { TiptapDoc } from '../types';
|
|
4
|
+
import {
|
|
5
|
+
canonicalTiptapContentJson,
|
|
6
|
+
isSemanticallyEmptyEditorHtml,
|
|
7
|
+
normalizeTiptapDocPayload,
|
|
8
|
+
} from '../lib/tiptapContentSemantics';
|
|
4
9
|
|
|
5
10
|
export type EditorOutput = 'html' | 'json';
|
|
6
11
|
|
|
@@ -24,16 +29,38 @@ export function useEditorContent(
|
|
|
24
29
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
25
30
|
const handlerRef = useRef(onChange);
|
|
26
31
|
handlerRef.current = onChange;
|
|
32
|
+
const lastEmittedRef = useRef<string | null>(null);
|
|
27
33
|
|
|
28
34
|
useEffect(() => {
|
|
29
35
|
if (!editor) return;
|
|
36
|
+
lastEmittedRef.current = null;
|
|
30
37
|
|
|
31
38
|
const handleUpdate = () => {
|
|
32
39
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
33
40
|
timeoutRef.current = setTimeout(() => {
|
|
34
|
-
const
|
|
41
|
+
const rawPayload = output === 'json'
|
|
35
42
|
? (editor.getJSON() as TiptapDoc)
|
|
36
43
|
: editor.getHTML();
|
|
44
|
+
const payload = output === 'json'
|
|
45
|
+
? normalizeTiptapDocPayload(rawPayload)
|
|
46
|
+
: rawPayload;
|
|
47
|
+
|
|
48
|
+
const fingerprint = output === 'json'
|
|
49
|
+
? canonicalTiptapContentJson((payload as TiptapDoc).content)
|
|
50
|
+
: payload;
|
|
51
|
+
|
|
52
|
+
if (typeof fingerprint === 'string' && fingerprint === lastEmittedRef.current) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (output === 'html' && typeof payload === 'string' && isSemanticallyEmptyEditorHtml(payload)) {
|
|
57
|
+
if (lastEmittedRef.current === '') return;
|
|
58
|
+
lastEmittedRef.current = '';
|
|
59
|
+
handlerRef.current?.(payload);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lastEmittedRef.current = typeof fingerprint === 'string' ? fingerprint : null;
|
|
37
64
|
handlerRef.current?.(payload);
|
|
38
65
|
}, delayMs);
|
|
39
66
|
};
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,18 @@ export { splitHtmlIntoBlocks } from './lib/splitHtmlIntoBlocks';
|
|
|
24
24
|
export type { BlockChunk, BlockChunkType } from './lib/splitHtmlIntoBlocks';
|
|
25
25
|
export { htmlToTiptapDoc } from './lib/htmlToTiptapDoc';
|
|
26
26
|
export { buildMayaEditorExtensions } from './lib/editorExtensions';
|
|
27
|
+
export { isEditorReady } from './lib/isEditorReady';
|
|
28
|
+
export {
|
|
29
|
+
canonicalTiptapContentJson,
|
|
30
|
+
htmlVisibleTextLength,
|
|
31
|
+
isEmptyTiptapBlockNode,
|
|
32
|
+
isSemanticallyEmptyEditorHtml,
|
|
33
|
+
isSemanticallyEmptyTiptapContent,
|
|
34
|
+
normalizeTiptapContentForCompare,
|
|
35
|
+
normalizeTiptapContentForPersistence,
|
|
36
|
+
normalizeTiptapDocPayload,
|
|
37
|
+
tiptapContentEquals,
|
|
38
|
+
} from './lib/tiptapContentSemantics';
|
|
27
39
|
export { docxToHtml, docxToHtmlResult } from './lib/docxToHtml';
|
|
28
40
|
export type { DocxConversionMessage, DocxConversionResult } from './lib/docxToHtml';
|
|
29
41
|
export { SourceInputDialog } from './components/SourceInputDialog';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TipTap puede exponer una instancia con `commandManager === null` tras destroy
|
|
5
|
+
* o antes del evento `create`. Solo la UI que invoca comandos debe usar esto.
|
|
6
|
+
*/
|
|
7
|
+
export function isEditorReady(editor: Editor | null | undefined): editor is Editor {
|
|
8
|
+
if (!editor) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return !editor.isDestroyed;
|
|
13
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
canonicalTiptapContentJson,
|
|
4
|
+
isSemanticallyEmptyTiptapContent,
|
|
5
|
+
normalizeTiptapContentForCompare,
|
|
6
|
+
normalizeTiptapContentForPersistence,
|
|
7
|
+
tiptapContentEquals,
|
|
8
|
+
} from './tiptapContentSemantics';
|
|
9
|
+
|
|
10
|
+
describe('tiptapContentSemantics', () => {
|
|
11
|
+
it('treats a lone empty paragraph as semantically empty', () => {
|
|
12
|
+
const empty = [{ type: 'paragraph', content: [] }];
|
|
13
|
+
expect(isSemanticallyEmptyTiptapContent(empty)).toBe(true);
|
|
14
|
+
expect(normalizeTiptapContentForCompare(empty)).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('strips trailing empty paragraph so phantom TipTap nodes do not differ', () => {
|
|
18
|
+
const withText = [{ type: 'paragraph', content: [{ type: 'text', text: 'Hola' }] }];
|
|
19
|
+
const withPhantom = [
|
|
20
|
+
...withText,
|
|
21
|
+
{ type: 'paragraph', content: [] },
|
|
22
|
+
];
|
|
23
|
+
expect(tiptapContentEquals(withText, withPhantom)).toBe(true);
|
|
24
|
+
expect(canonicalTiptapContentJson(withPhantom)).toBe(canonicalTiptapContentJson(withText));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('detects real edits after normalization', () => {
|
|
28
|
+
const a = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }];
|
|
29
|
+
const b = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }];
|
|
30
|
+
expect(tiptapContentEquals(a, b)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('keeps non-empty images as filled', () => {
|
|
34
|
+
const nodes = [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }];
|
|
35
|
+
expect(isSemanticallyEmptyTiptapContent(nodes)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores table colwidth attrs added by the editor on open', () => {
|
|
39
|
+
const fromTemplate = [
|
|
40
|
+
{
|
|
41
|
+
type: 'table',
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: 'tableRow',
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: 'tableCell',
|
|
48
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
const afterEditor = [
|
|
56
|
+
{
|
|
57
|
+
type: 'table',
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: 'tableRow',
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: 'tableCell',
|
|
64
|
+
attrs: { colwidth: [120] },
|
|
65
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
expect(tiptapContentEquals(fromTemplate, afterEditor)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('ignores volatile image attrs added by the editor on open', () => {
|
|
76
|
+
const fromTemplate = [{ type: 'image', attrs: { src: 'https://example.com/x.png', alt: 'Logo' } }];
|
|
77
|
+
const afterEditor = [{
|
|
78
|
+
type: 'image',
|
|
79
|
+
attrs: {
|
|
80
|
+
src: 'https://example.com/x.png',
|
|
81
|
+
alt: 'Logo',
|
|
82
|
+
width: 400,
|
|
83
|
+
height: 200,
|
|
84
|
+
class: 'ProseMirror-selectednode',
|
|
85
|
+
},
|
|
86
|
+
}];
|
|
87
|
+
expect(tiptapContentEquals(fromTemplate, afterEditor)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('treats tableHeader cells as tableCell for template parity', () => {
|
|
91
|
+
const fromTemplate = [{
|
|
92
|
+
type: 'table',
|
|
93
|
+
content: [{
|
|
94
|
+
type: 'tableRow',
|
|
95
|
+
content: [{
|
|
96
|
+
type: 'tableCell',
|
|
97
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
98
|
+
}],
|
|
99
|
+
}],
|
|
100
|
+
}];
|
|
101
|
+
const afterEditor = [{
|
|
102
|
+
type: 'table',
|
|
103
|
+
content: [{
|
|
104
|
+
type: 'tableRow',
|
|
105
|
+
content: [{
|
|
106
|
+
type: 'tableHeader',
|
|
107
|
+
attrs: { colwidth: [90] },
|
|
108
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
109
|
+
}],
|
|
110
|
+
}],
|
|
111
|
+
}];
|
|
112
|
+
expect(tiptapContentEquals(fromTemplate, afterEditor)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not treat paragraph with nested image as semantically empty', () => {
|
|
116
|
+
const nodes = [{
|
|
117
|
+
type: 'paragraph',
|
|
118
|
+
content: [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }],
|
|
119
|
+
}];
|
|
120
|
+
expect(isSemanticallyEmptyTiptapContent(nodes)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('preserves heading level and text for persistence', () => {
|
|
124
|
+
const heading = [{
|
|
125
|
+
type: 'heading',
|
|
126
|
+
attrs: { level: 1 },
|
|
127
|
+
content: [{ type: 'text', text: 'Título H1' }],
|
|
128
|
+
}];
|
|
129
|
+
expect(normalizeTiptapContentForPersistence(heading)).toEqual(heading);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('compare normalization keeps text on inline nodes', () => {
|
|
133
|
+
const nodes = [{
|
|
134
|
+
type: 'heading',
|
|
135
|
+
attrs: { level: 1 },
|
|
136
|
+
content: [{ type: 'text', text: 'Hola' }],
|
|
137
|
+
}];
|
|
138
|
+
expect(normalizeTiptapContentForCompare(nodes)).toEqual(nodes);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('ignores phantom empty paragraphs inside table cells', () => {
|
|
142
|
+
const template = [{
|
|
143
|
+
type: 'table',
|
|
144
|
+
content: [{
|
|
145
|
+
type: 'tableRow',
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'tableCell',
|
|
148
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'c' }] }],
|
|
149
|
+
}],
|
|
150
|
+
}],
|
|
151
|
+
}];
|
|
152
|
+
const afterEditor = [{
|
|
153
|
+
type: 'table',
|
|
154
|
+
content: [{
|
|
155
|
+
type: 'tableRow',
|
|
156
|
+
content: [{
|
|
157
|
+
type: 'tableCell',
|
|
158
|
+
attrs: { colwidth: [100] },
|
|
159
|
+
content: [
|
|
160
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'c' }] },
|
|
161
|
+
{ type: 'paragraph', content: [] },
|
|
162
|
+
],
|
|
163
|
+
}],
|
|
164
|
+
}],
|
|
165
|
+
}];
|
|
166
|
+
expect(tiptapContentEquals(template, afterEditor)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('ignores empty list items added by pressing Enter without content', () => {
|
|
170
|
+
const template = [{
|
|
171
|
+
type: 'bulletList',
|
|
172
|
+
content: [{
|
|
173
|
+
type: 'listItem',
|
|
174
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
175
|
+
}],
|
|
176
|
+
}];
|
|
177
|
+
const afterEditor = [{
|
|
178
|
+
type: 'bulletList',
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: 'listItem',
|
|
182
|
+
content: [{ type: 'paragraph', content: [] }],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
type: 'listItem',
|
|
186
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
}];
|
|
190
|
+
expect(tiptapContentEquals(template, afterEditor)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('ignores trailing empty list item at end of list', () => {
|
|
194
|
+
const template = [{
|
|
195
|
+
type: 'bulletList',
|
|
196
|
+
content: [{
|
|
197
|
+
type: 'listItem',
|
|
198
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
199
|
+
}],
|
|
200
|
+
}];
|
|
201
|
+
const afterEditor = [{
|
|
202
|
+
type: 'bulletList',
|
|
203
|
+
content: [
|
|
204
|
+
{
|
|
205
|
+
type: 'listItem',
|
|
206
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
type: 'listItem',
|
|
210
|
+
content: [{ type: 'paragraph', content: [] }],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
}];
|
|
214
|
+
expect(tiptapContentEquals(template, afterEditor)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { TiptapDoc, TiptapNode } from '../types';
|
|
2
|
+
|
|
3
|
+
const MEANINGFUL_BLOCK_TYPES = new Set([
|
|
4
|
+
'image',
|
|
5
|
+
'table',
|
|
6
|
+
'iframeBlock',
|
|
7
|
+
'alertBlock',
|
|
8
|
+
'horizontalRule',
|
|
9
|
+
'codeBlock',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function asNode(value: unknown): TiptapNode | null {
|
|
17
|
+
return isRecord(value) && typeof value.type === 'string' ? (value as TiptapNode) : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inlineTextLength(nodes: unknown): number {
|
|
21
|
+
if (!Array.isArray(nodes)) return 0;
|
|
22
|
+
|
|
23
|
+
let length = 0;
|
|
24
|
+
for (const raw of nodes) {
|
|
25
|
+
const node = asNode(raw);
|
|
26
|
+
if (!node) continue;
|
|
27
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
28
|
+
length += node.text.replace(/\u00a0/g, ' ').trim().length;
|
|
29
|
+
} else if (node.type === 'hardBreak') {
|
|
30
|
+
continue;
|
|
31
|
+
} else if (Array.isArray(node.content)) {
|
|
32
|
+
length += inlineTextLength(node.content);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when inline/block children carry text or non-empty media (e.g. image in paragraph). */
|
|
40
|
+
function blockChildrenHaveMeaningfulContent(nodes: unknown): boolean {
|
|
41
|
+
if (!Array.isArray(nodes)) return false;
|
|
42
|
+
|
|
43
|
+
for (const raw of nodes) {
|
|
44
|
+
const node = asNode(raw);
|
|
45
|
+
if (!node) continue;
|
|
46
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
47
|
+
if (node.text.replace(/\u00a0/g, ' ').trim().length > 0) return true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (node.type === 'hardBreak') continue;
|
|
51
|
+
if (MEANINGFUL_BLOCK_TYPES.has(node.type) && !isEmptyTiptapBlockNode(node)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(node.content) && blockChildrenHaveMeaningfulContent(node.content)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True for TipTap/BlockNote blocks with no visible text and no embedded media. */
|
|
63
|
+
export function isEmptyTiptapBlockNode(node: unknown): boolean {
|
|
64
|
+
const n = asNode(node);
|
|
65
|
+
if (!n?.type) return true;
|
|
66
|
+
|
|
67
|
+
if (MEANINGFUL_BLOCK_TYPES.has(n.type)) {
|
|
68
|
+
if (n.type === 'horizontalRule') return false;
|
|
69
|
+
if (n.type === 'image') {
|
|
70
|
+
return !String(n.attrs?.src ?? '').trim();
|
|
71
|
+
}
|
|
72
|
+
if (n.type === 'codeBlock') {
|
|
73
|
+
return inlineTextLength(n.content ?? []) === 0;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (n.type === 'bulletList' || n.type === 'orderedList' || n.type === 'taskList') {
|
|
79
|
+
const items = Array.isArray(n.content) ? n.content : [];
|
|
80
|
+
return items.length === 0 || items.every((item) => isEmptyTiptapBlockNode(item));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (n.type === 'listItem' || n.type === 'taskItem') {
|
|
84
|
+
const inner = Array.isArray(n.content) ? n.content : [];
|
|
85
|
+
return inner.length === 0 || inner.every((child) => isEmptyTiptapBlockNode(child));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (n.type === 'blockquote') {
|
|
89
|
+
const inner = Array.isArray(n.content) ? n.content : [];
|
|
90
|
+
return inner.length === 0 || inner.every((child) => isEmptyTiptapBlockNode(child));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// paragraph, heading, legacy BlockNote blocks, etc.
|
|
94
|
+
return !blockChildrenHaveMeaningfulContent(n.content ?? []);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toContentArray(value: unknown): unknown[] {
|
|
98
|
+
if (value == null) return [];
|
|
99
|
+
if (Array.isArray(value)) return value;
|
|
100
|
+
if (isRecord(value) && value.type === 'doc' && Array.isArray(value.content)) {
|
|
101
|
+
return value.content;
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Atributos que TipTap añade al abrir/guardar y no representan edición del usuario. */
|
|
107
|
+
const VOLATILE_NODE_ATTR_KEYS = new Set([
|
|
108
|
+
'colwidth',
|
|
109
|
+
'columnSizing',
|
|
110
|
+
'data-colwidth',
|
|
111
|
+
'width',
|
|
112
|
+
'height',
|
|
113
|
+
'style',
|
|
114
|
+
'class',
|
|
115
|
+
'title',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const IMAGE_COMPARE_ATTR_KEYS = ['src', 'alt'] as const;
|
|
119
|
+
|
|
120
|
+
function stripTrailingEmptyBlocks(nodes: unknown[]): unknown[] {
|
|
121
|
+
const out = JSON.parse(JSON.stringify(nodes));
|
|
122
|
+
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canonicalizeNodeAttrs(type: string, rawAttrs: unknown): Record<string, unknown> | undefined {
|
|
127
|
+
if (!isRecord(rawAttrs)) return undefined;
|
|
128
|
+
|
|
129
|
+
let attrs: Record<string, unknown> = { ...rawAttrs };
|
|
130
|
+
if (type === 'image') {
|
|
131
|
+
const picked: Record<string, unknown> = {};
|
|
132
|
+
for (const key of IMAGE_COMPARE_ATTR_KEYS) {
|
|
133
|
+
const value = attrs[key];
|
|
134
|
+
if (value != null && String(value).trim() !== '') {
|
|
135
|
+
picked[key] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return Object.keys(picked).length > 0 ? picked : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const key of VOLATILE_NODE_ATTR_KEYS) {
|
|
142
|
+
delete attrs[key];
|
|
143
|
+
}
|
|
144
|
+
if (type === 'tableCell' && attrs.colspan === 1) delete attrs.colspan;
|
|
145
|
+
if (type === 'tableCell' && attrs.rowspan === 1) delete attrs.rowspan;
|
|
146
|
+
|
|
147
|
+
return Object.keys(attrs).length > 0 ? attrs : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Canonicalize and drop phantom empty nodes at every depth (cells, list items, etc.). */
|
|
151
|
+
function canonicalizeNodeForCompare(node: unknown): unknown | null {
|
|
152
|
+
const n = asNode(node);
|
|
153
|
+
if (!n?.type) return null;
|
|
154
|
+
|
|
155
|
+
if (n.type === 'text') {
|
|
156
|
+
const text = typeof n.text === 'string' ? n.text : '';
|
|
157
|
+
const hasMarks = Array.isArray(n.marks) && n.marks.length > 0;
|
|
158
|
+
if (text.replace(/\u00a0/g, ' ').trim() === '' && !hasMarks) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
type: 'text',
|
|
163
|
+
...(typeof n.text === 'string' ? { text: n.text } : {}),
|
|
164
|
+
...(hasMarks ? { marks: n.marks } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// TipTap may emit tableHeader where the template stored tableCell.
|
|
169
|
+
const rawType = n.type;
|
|
170
|
+
const type = rawType === 'tableHeader' ? 'tableCell' : rawType;
|
|
171
|
+
const attrs = canonicalizeNodeAttrs(type, n.attrs);
|
|
172
|
+
|
|
173
|
+
let content: unknown[] | undefined;
|
|
174
|
+
if (Array.isArray(n.content)) {
|
|
175
|
+
let children = n.content
|
|
176
|
+
.map((child) => canonicalizeNodeForCompare(child))
|
|
177
|
+
.filter((child): child is unknown => child != null);
|
|
178
|
+
|
|
179
|
+
if (rawType === 'bulletList' || rawType === 'orderedList' || rawType === 'taskList') {
|
|
180
|
+
children = children.filter((child) => !isEmptyTiptapBlockNode(child));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
children = stripTrailingEmptyBlocks(children);
|
|
184
|
+
content = children.length > 0 ? children : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const out = {
|
|
188
|
+
type,
|
|
189
|
+
...(attrs ? { attrs } : {}),
|
|
190
|
+
...(content !== undefined ? { content } : {}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return isEmptyTiptapBlockNode(out) ? null : out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Strips trailing empty paragraphs TipTap adds for cursor placement.
|
|
198
|
+
* Preserves the full node tree (text, marks, heading levels) for persistence.
|
|
199
|
+
*/
|
|
200
|
+
export function normalizeTiptapContentForPersistence(value: unknown): unknown[] {
|
|
201
|
+
const nodes = [...toContentArray(value)];
|
|
202
|
+
while (nodes.length > 0 && isEmptyTiptapBlockNode(nodes[nodes.length - 1])) {
|
|
203
|
+
nodes.pop();
|
|
204
|
+
}
|
|
205
|
+
return nodes;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Canonical form for semantic diff/compare (strips volatile attrs, trailing empties).
|
|
210
|
+
* Returns a new array; does not mutate the input.
|
|
211
|
+
*/
|
|
212
|
+
export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
|
|
213
|
+
const nodes = toContentArray(value)
|
|
214
|
+
.map((node) => canonicalizeNodeForCompare(node))
|
|
215
|
+
.filter((node): node is unknown => node != null);
|
|
216
|
+
|
|
217
|
+
return stripTrailingEmptyBlocks(nodes);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* True when content has no meaningful text or media (e.g. only `<p></p>` / `paragraph: []`).
|
|
222
|
+
*/
|
|
223
|
+
export function isSemanticallyEmptyTiptapContent(value: unknown): boolean {
|
|
224
|
+
const nodes = normalizeTiptapContentForCompare(value);
|
|
225
|
+
if (nodes.length === 0) return true;
|
|
226
|
+
return nodes.every((node) => isEmptyTiptapBlockNode(node));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function canonicalTiptapContentJson(value: unknown): string {
|
|
230
|
+
try {
|
|
231
|
+
return JSON.stringify(normalizeTiptapContentForCompare(value));
|
|
232
|
+
} catch {
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function tiptapContentEquals(a: unknown, b: unknown): boolean {
|
|
238
|
+
return canonicalTiptapContentJson(a) === canonicalTiptapContentJson(b);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Visible character count of editor HTML (ignores tags). Matches logs comment validation.
|
|
243
|
+
*/
|
|
244
|
+
export function htmlVisibleTextLength(html: string): number {
|
|
245
|
+
const text = html
|
|
246
|
+
.replace(/<[^>]+>/g, '')
|
|
247
|
+
.replace(/ /gi, ' ')
|
|
248
|
+
.replace(/</g, '<')
|
|
249
|
+
.replace(/>/g, '>')
|
|
250
|
+
.replace(/&/g, '&')
|
|
251
|
+
.trim();
|
|
252
|
+
return text.length;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function isSemanticallyEmptyEditorHtml(html: string): boolean {
|
|
256
|
+
return htmlVisibleTextLength(html) === 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Payload shape for MayaEditor `output="json"`. */
|
|
260
|
+
export function normalizeTiptapDocPayload(payload: string | TiptapDoc): string | TiptapDoc {
|
|
261
|
+
if (typeof payload === 'string') return payload;
|
|
262
|
+
return {
|
|
263
|
+
...payload,
|
|
264
|
+
content: normalizeTiptapContentForPersistence(payload.content) as TiptapDoc['content'],
|
|
265
|
+
};
|
|
266
|
+
}
|