@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceedcv-maya/shared-editor-react",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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 al perder el foco del editor, antes de HTML/Markdown o al destruir
35
- * la instancia. El padre suele enlazarlo a `forceSave` del autoguardado.
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 syncContentToParent = useCallback(() => {
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
- const payload =
160
- effectiveOutput === 'json'
161
- ? normalizeTiptapDocPayload(rawPayload)
162
- : rawPayload;
163
- handler(payload);
167
+ return effectiveOutput === 'json'
168
+ ? normalizeTiptapDocPayload(rawPayload)
169
+ : rawPayload;
164
170
  }, [editor, effectiveOutput]);
165
171
 
166
- const requestFlush = useCallback(() => {
167
- syncContentToParent();
168
- onFlushRef.current?.();
169
- }, [syncContentToParent]);
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 = () => requestFlush();
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
- requestFlush();
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
@@ -32,6 +32,7 @@ export {
32
32
  isSemanticallyEmptyEditorHtml,
33
33
  isSemanticallyEmptyTiptapContent,
34
34
  normalizeTiptapContentForCompare,
35
+ normalizeTiptapContentForPersistence,
35
36
  normalizeTiptapDocPayload,
36
37
  tiptapContentEquals,
37
38
  } from './lib/tiptapContentSemantics';
@@ -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 inlineTextLength(n.content ?? []) === 0;
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
- * Returns a new array; does not mutate the input.
198
+ * Preserves the full node tree (text, marks, heading levels) for persistence.
86
199
  */
87
- export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
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: normalizeTiptapContentForCompare(payload.content) as TiptapDoc['content'],
264
+ content: normalizeTiptapContentForPersistence(payload.content) as TiptapDoc['content'],
140
265
  };
141
266
  }