@ceedcv-maya/shared-editor-react 0.8.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,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, 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
|
|
|
@@ -31,10 +31,15 @@ export interface MayaEditorProps {
|
|
|
31
31
|
/** Debounced change callback (300ms). Payload depends on `output`. */
|
|
32
32
|
onChange?: (payload: string | TiptapDoc) => void;
|
|
33
33
|
/**
|
|
34
|
-
* Llamado
|
|
35
|
-
*
|
|
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
36
|
*/
|
|
37
|
-
onFlush?: () => void
|
|
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>;
|
|
38
43
|
/**
|
|
39
44
|
* Output shape: `'html'` (default) emits a sanitisation-ready string;
|
|
40
45
|
* `'json'` emits the full ProseMirror doc `{type:'doc', content:[…]}`,
|
|
@@ -102,6 +107,7 @@ export function MayaEditor({
|
|
|
102
107
|
onExportDocx,
|
|
103
108
|
commentsById,
|
|
104
109
|
onFlush,
|
|
110
|
+
editorFlushRef,
|
|
105
111
|
}: MayaEditorProps) {
|
|
106
112
|
const effectiveOutput: EditorOutput = output ?? (mode === 'full' ? 'json' : 'html');
|
|
107
113
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
@@ -126,8 +132,12 @@ export function MayaEditor({
|
|
|
126
132
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
127
133
|
const onChangeRef = useRef(onChange);
|
|
128
134
|
const onFlushRef = useRef(onFlush);
|
|
135
|
+
const viewModeRef = useRef(viewMode);
|
|
136
|
+
const sourceTextRef = useRef(sourceText);
|
|
129
137
|
onChangeRef.current = onChange;
|
|
130
138
|
onFlushRef.current = onFlush;
|
|
139
|
+
viewModeRef.current = viewMode;
|
|
140
|
+
sourceTextRef.current = sourceText;
|
|
131
141
|
|
|
132
142
|
const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
|
|
133
143
|
|
|
@@ -148,25 +158,49 @@ export function MayaEditor({
|
|
|
148
158
|
|
|
149
159
|
useEditorContent(editor, onChange, { output: effectiveOutput });
|
|
150
160
|
|
|
151
|
-
const
|
|
152
|
-
if (!editor) return;
|
|
153
|
-
const handler = onChangeRef.current;
|
|
154
|
-
if (!handler) return;
|
|
161
|
+
const readPayloadFromEditor = useCallback((): string | TiptapDoc | undefined => {
|
|
162
|
+
if (!editor) return undefined;
|
|
155
163
|
const rawPayload =
|
|
156
164
|
effectiveOutput === 'json'
|
|
157
165
|
? (editor.getJSON() as TiptapDoc)
|
|
158
166
|
: editor.getHTML();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
: rawPayload;
|
|
163
|
-
handler(payload);
|
|
167
|
+
return effectiveOutput === 'json'
|
|
168
|
+
? normalizeTiptapDocPayload(rawPayload)
|
|
169
|
+
: rawPayload;
|
|
164
170
|
}, [editor, effectiveOutput]);
|
|
165
171
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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]);
|
|
170
204
|
|
|
171
205
|
useEffect(() => {
|
|
172
206
|
if (editor && onEditorReady) onEditorReady(editor);
|
|
@@ -174,7 +208,9 @@ export function MayaEditor({
|
|
|
174
208
|
|
|
175
209
|
useEffect(() => {
|
|
176
210
|
if (!editor || !onFlush) return;
|
|
177
|
-
const onDestroy = () =>
|
|
211
|
+
const onDestroy = () => {
|
|
212
|
+
void requestFlush();
|
|
213
|
+
};
|
|
178
214
|
editor.on('destroy', onDestroy);
|
|
179
215
|
return () => {
|
|
180
216
|
editor.off('destroy', onDestroy);
|
|
@@ -306,8 +342,8 @@ export function MayaEditor({
|
|
|
306
342
|
.run();
|
|
307
343
|
};
|
|
308
344
|
|
|
309
|
-
const enterSource = (target: 'html' | 'markdown') => {
|
|
310
|
-
|
|
345
|
+
const enterSource = async (target: 'html' | 'markdown') => {
|
|
346
|
+
syncContentToParent();
|
|
311
347
|
const currentHtml = editor.getHTML();
|
|
312
348
|
const text = target === 'html' ? currentHtml : htmlToMarkdown(currentHtml);
|
|
313
349
|
setSourceText(text);
|
|
@@ -336,7 +372,7 @@ export function MayaEditor({
|
|
|
336
372
|
const html = sanitizeEditorHtml(markdownToHtml(sourceText));
|
|
337
373
|
setSourceText(html);
|
|
338
374
|
setViewMode('html');
|
|
339
|
-
} else enterSource('html');
|
|
375
|
+
} else void enterSource('html');
|
|
340
376
|
};
|
|
341
377
|
|
|
342
378
|
const toggleMarkdown = () => {
|
|
@@ -345,7 +381,7 @@ export function MayaEditor({
|
|
|
345
381
|
const md = htmlToMarkdown(sourceText);
|
|
346
382
|
setSourceText(md);
|
|
347
383
|
setViewMode('markdown');
|
|
348
|
-
} else enterSource('markdown');
|
|
384
|
+
} else void enterSource('markdown');
|
|
349
385
|
};
|
|
350
386
|
|
|
351
387
|
return (
|
|
@@ -356,7 +392,7 @@ export function MayaEditor({
|
|
|
356
392
|
if (!onFlush) return;
|
|
357
393
|
const next = e.relatedTarget as Node | null;
|
|
358
394
|
if (next && wrapperRef.current?.contains(next)) return;
|
|
359
|
-
requestFlush();
|
|
395
|
+
void requestFlush();
|
|
360
396
|
}}
|
|
361
397
|
>
|
|
362
398
|
{editorReady && (
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
canonicalTiptapContentJson,
|
|
4
4
|
isSemanticallyEmptyTiptapContent,
|
|
5
5
|
normalizeTiptapContentForCompare,
|
|
6
|
+
normalizeTiptapContentForPersistence,
|
|
6
7
|
tiptapContentEquals,
|
|
7
8
|
} from './tiptapContentSemantics';
|
|
8
9
|
|
|
@@ -33,4 +34,183 @@ describe('tiptapContentSemantics', () => {
|
|
|
33
34
|
const nodes = [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }];
|
|
34
35
|
expect(isSemanticallyEmptyTiptapContent(nodes)).toBe(false);
|
|
35
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
|
+
});
|
|
36
216
|
});
|
|
@@ -36,6 +36,29 @@ function inlineTextLength(nodes: unknown): number {
|
|
|
36
36
|
return length;
|
|
37
37
|
}
|
|
38
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
|
+
|
|
39
62
|
/** True for TipTap/BlockNote blocks with no visible text and no embedded media. */
|
|
40
63
|
export function isEmptyTiptapBlockNode(node: unknown): boolean {
|
|
41
64
|
const n = asNode(node);
|
|
@@ -68,7 +91,7 @@ export function isEmptyTiptapBlockNode(node: unknown): boolean {
|
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
// paragraph, heading, legacy BlockNote blocks, etc.
|
|
71
|
-
return
|
|
94
|
+
return !blockChildrenHaveMeaningfulContent(n.content ?? []);
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
function toContentArray(value: unknown): unknown[] {
|
|
@@ -80,11 +103,101 @@ function toContentArray(value: unknown): unknown[] {
|
|
|
80
103
|
return [];
|
|
81
104
|
}
|
|
82
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
|
+
|
|
83
196
|
/**
|
|
84
197
|
* Strips trailing empty paragraphs TipTap adds for cursor placement.
|
|
85
|
-
*
|
|
198
|
+
* Preserves the full node tree (text, marks, heading levels) for persistence.
|
|
86
199
|
*/
|
|
87
|
-
export function
|
|
200
|
+
export function normalizeTiptapContentForPersistence(value: unknown): unknown[] {
|
|
88
201
|
const nodes = [...toContentArray(value)];
|
|
89
202
|
while (nodes.length > 0 && isEmptyTiptapBlockNode(nodes[nodes.length - 1])) {
|
|
90
203
|
nodes.pop();
|
|
@@ -92,6 +205,18 @@ export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
|
|
|
92
205
|
return nodes;
|
|
93
206
|
}
|
|
94
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
|
+
|
|
95
220
|
/**
|
|
96
221
|
* True when content has no meaningful text or media (e.g. only `<p></p>` / `paragraph: []`).
|
|
97
222
|
*/
|
|
@@ -136,6 +261,6 @@ export function normalizeTiptapDocPayload(payload: string | TiptapDoc): string |
|
|
|
136
261
|
if (typeof payload === 'string') return payload;
|
|
137
262
|
return {
|
|
138
263
|
...payload,
|
|
139
|
-
content:
|
|
264
|
+
content: normalizeTiptapContentForPersistence(payload.content) as TiptapDoc['content'],
|
|
140
265
|
};
|
|
141
266
|
}
|