@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceedcv-maya/shared-editor-react",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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
- const bump = () => setSelectionVersion((v) => v + 1);
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
- // The editor may have already fired `create` before we got here
150
- // (useEditor sometimes returns an instance that's already mounted).
234
+ editor.on('destroy', markNotReady);
235
+
151
236
  try {
152
- if (editor.view && editor.view.dom) setViewReady(true);
153
- } catch { /* view not ready yet — markReady will fire it */ }
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
- {mode === 'full' && (
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 payload = output === 'json'
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(/&nbsp;/gi, ' ')
248
+ .replace(/&lt;/g, '<')
249
+ .replace(/&gt;/g, '>')
250
+ .replace(/&amp;/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
+ }