@ceedcv-maya/shared-editor-react 0.7.0 → 0.8.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.8.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 } 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,11 @@ 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 al perder el foco del editor, antes de HTML/Markdown o al destruir
35
+ * la instancia. El padre suele enlazarlo a `forceSave` del autoguardado.
36
+ */
37
+ onFlush?: () => void;
31
38
  /**
32
39
  * Output shape: `'html'` (default) emits a sanitisation-ready string;
33
40
  * `'json'` emits the full ProseMirror doc `{type:'doc', content:[…]}`,
@@ -94,6 +101,7 @@ export function MayaEditor({
94
101
  onCreateComment,
95
102
  onExportDocx,
96
103
  commentsById,
104
+ onFlush,
97
105
  }: MayaEditorProps) {
98
106
  const effectiveOutput: EditorOutput = output ?? (mode === 'full' ? 'json' : 'html');
99
107
  const [isFullscreen, setIsFullscreen] = useState(false);
@@ -115,6 +123,11 @@ export function MayaEditor({
115
123
  const [viewReady, setViewReady] = useState(false);
116
124
  const fileInputRef = useRef<HTMLInputElement | null>(null);
117
125
  const docxInputRef = useRef<HTMLInputElement | null>(null);
126
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
127
+ const onChangeRef = useRef(onChange);
128
+ const onFlushRef = useRef(onFlush);
129
+ onChangeRef.current = onChange;
130
+ onFlushRef.current = onFlush;
118
131
 
119
132
  const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
120
133
 
@@ -135,26 +148,67 @@ export function MayaEditor({
135
148
 
136
149
  useEditorContent(editor, onChange, { output: effectiveOutput });
137
150
 
151
+ const syncContentToParent = useCallback(() => {
152
+ if (!editor) return;
153
+ const handler = onChangeRef.current;
154
+ if (!handler) return;
155
+ const rawPayload =
156
+ effectiveOutput === 'json'
157
+ ? (editor.getJSON() as TiptapDoc)
158
+ : editor.getHTML();
159
+ const payload =
160
+ effectiveOutput === 'json'
161
+ ? normalizeTiptapDocPayload(rawPayload)
162
+ : rawPayload;
163
+ handler(payload);
164
+ }, [editor, effectiveOutput]);
165
+
166
+ const requestFlush = useCallback(() => {
167
+ syncContentToParent();
168
+ onFlushRef.current?.();
169
+ }, [syncContentToParent]);
170
+
138
171
  useEffect(() => {
139
172
  if (editor && onEditorReady) onEditorReady(editor);
140
173
  }, [editor, onEditorReady]);
141
174
 
142
175
  useEffect(() => {
176
+ if (!editor || !onFlush) return;
177
+ const onDestroy = () => requestFlush();
178
+ editor.on('destroy', onDestroy);
179
+ return () => {
180
+ editor.off('destroy', onDestroy);
181
+ };
182
+ }, [editor, onFlush, requestFlush]);
183
+
184
+ useEffect(() => {
185
+ setViewReady(false);
143
186
  if (!editor) return;
144
- const bump = () => setSelectionVersion((v) => v + 1);
187
+
188
+ const bump = () => {
189
+ if (!isEditorReady(editor)) return;
190
+ setSelectionVersion((v) => v + 1);
191
+ };
145
192
  const markReady = () => setViewReady(true);
193
+ const markNotReady = () => setViewReady(false);
194
+
146
195
  editor.on('selectionUpdate', bump);
147
196
  editor.on('transaction', bump);
148
197
  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).
198
+ editor.on('destroy', markNotReady);
199
+
151
200
  try {
152
- if (editor.view && editor.view.dom) setViewReady(true);
153
- } catch { /* view not ready yet — markReady will fire it */ }
201
+ if (editor.view?.dom) setViewReady(true);
202
+ } catch {
203
+ /* view not ready yet — markReady will fire on create */
204
+ }
205
+
154
206
  return () => {
155
207
  editor.off('selectionUpdate', bump);
156
208
  editor.off('transaction', bump);
157
209
  editor.off('create', markReady);
210
+ editor.off('destroy', markNotReady);
211
+ setViewReady(false);
158
212
  };
159
213
  }, [editor]);
160
214
 
@@ -214,6 +268,8 @@ export function MayaEditor({
214
268
 
215
269
  if (!editor) return null;
216
270
 
271
+ const editorReady = viewReady && isEditorReady(editor);
272
+
217
273
  const handlePickImage = async (file: File) => {
218
274
  if (!uploadFile) return;
219
275
  try {
@@ -251,6 +307,7 @@ export function MayaEditor({
251
307
  };
252
308
 
253
309
  const enterSource = (target: 'html' | 'markdown') => {
310
+ requestFlush();
254
311
  const currentHtml = editor.getHTML();
255
312
  const text = target === 'html' ? currentHtml : htmlToMarkdown(currentHtml);
256
313
  setSourceText(text);
@@ -293,8 +350,16 @@ export function MayaEditor({
293
350
 
294
351
  return (
295
352
  <div
353
+ ref={wrapperRef}
296
354
  className={`maya-editor-wrapper${isFullscreen ? ' is-fullscreen' : ''}${isDark ? ' is-dark' : ''}`}
355
+ onBlur={(e) => {
356
+ if (!onFlush) return;
357
+ const next = e.relatedTarget as Node | null;
358
+ if (next && wrapperRef.current?.contains(next)) return;
359
+ requestFlush();
360
+ }}
297
361
  >
362
+ {editorReady && (
298
363
  <EditorToolbar
299
364
  editor={editor}
300
365
  mode={mode}
@@ -312,7 +377,8 @@ export function MayaEditor({
312
377
  onToggleFind={mode === 'full' ? () => setFindOpen((v) => !v) : undefined}
313
378
  labels={toolbarLabels}
314
379
  />
315
- {mode === 'full' && (
380
+ )}
381
+ {mode === 'full' && editorReady && (
316
382
  <FindReplaceBar
317
383
  editor={editor}
318
384
  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,17 @@ 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
+ normalizeTiptapDocPayload,
36
+ tiptapContentEquals,
37
+ } from './lib/tiptapContentSemantics';
27
38
  export { docxToHtml, docxToHtmlResult } from './lib/docxToHtml';
28
39
  export type { DocxConversionMessage, DocxConversionResult } from './lib/docxToHtml';
29
40
  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,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ canonicalTiptapContentJson,
4
+ isSemanticallyEmptyTiptapContent,
5
+ normalizeTiptapContentForCompare,
6
+ tiptapContentEquals,
7
+ } from './tiptapContentSemantics';
8
+
9
+ describe('tiptapContentSemantics', () => {
10
+ it('treats a lone empty paragraph as semantically empty', () => {
11
+ const empty = [{ type: 'paragraph', content: [] }];
12
+ expect(isSemanticallyEmptyTiptapContent(empty)).toBe(true);
13
+ expect(normalizeTiptapContentForCompare(empty)).toEqual([]);
14
+ });
15
+
16
+ it('strips trailing empty paragraph so phantom TipTap nodes do not differ', () => {
17
+ const withText = [{ type: 'paragraph', content: [{ type: 'text', text: 'Hola' }] }];
18
+ const withPhantom = [
19
+ ...withText,
20
+ { type: 'paragraph', content: [] },
21
+ ];
22
+ expect(tiptapContentEquals(withText, withPhantom)).toBe(true);
23
+ expect(canonicalTiptapContentJson(withPhantom)).toBe(canonicalTiptapContentJson(withText));
24
+ });
25
+
26
+ it('detects real edits after normalization', () => {
27
+ const a = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }];
28
+ const b = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }];
29
+ expect(tiptapContentEquals(a, b)).toBe(false);
30
+ });
31
+
32
+ it('keeps non-empty images as filled', () => {
33
+ const nodes = [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }];
34
+ expect(isSemanticallyEmptyTiptapContent(nodes)).toBe(false);
35
+ });
36
+ });
@@ -0,0 +1,141 @@
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 for TipTap/BlockNote blocks with no visible text and no embedded media. */
40
+ export function isEmptyTiptapBlockNode(node: unknown): boolean {
41
+ const n = asNode(node);
42
+ if (!n?.type) return true;
43
+
44
+ if (MEANINGFUL_BLOCK_TYPES.has(n.type)) {
45
+ if (n.type === 'horizontalRule') return false;
46
+ if (n.type === 'image') {
47
+ return !String(n.attrs?.src ?? '').trim();
48
+ }
49
+ if (n.type === 'codeBlock') {
50
+ return inlineTextLength(n.content ?? []) === 0;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ if (n.type === 'bulletList' || n.type === 'orderedList' || n.type === 'taskList') {
56
+ const items = Array.isArray(n.content) ? n.content : [];
57
+ return items.length === 0 || items.every((item) => isEmptyTiptapBlockNode(item));
58
+ }
59
+
60
+ if (n.type === 'listItem' || n.type === 'taskItem') {
61
+ const inner = Array.isArray(n.content) ? n.content : [];
62
+ return inner.length === 0 || inner.every((child) => isEmptyTiptapBlockNode(child));
63
+ }
64
+
65
+ if (n.type === 'blockquote') {
66
+ const inner = Array.isArray(n.content) ? n.content : [];
67
+ return inner.length === 0 || inner.every((child) => isEmptyTiptapBlockNode(child));
68
+ }
69
+
70
+ // paragraph, heading, legacy BlockNote blocks, etc.
71
+ return inlineTextLength(n.content ?? []) === 0;
72
+ }
73
+
74
+ function toContentArray(value: unknown): unknown[] {
75
+ if (value == null) return [];
76
+ if (Array.isArray(value)) return value;
77
+ if (isRecord(value) && value.type === 'doc' && Array.isArray(value.content)) {
78
+ return value.content;
79
+ }
80
+ return [];
81
+ }
82
+
83
+ /**
84
+ * Strips trailing empty paragraphs TipTap adds for cursor placement.
85
+ * Returns a new array; does not mutate the input.
86
+ */
87
+ export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
88
+ const nodes = [...toContentArray(value)];
89
+ while (nodes.length > 0 && isEmptyTiptapBlockNode(nodes[nodes.length - 1])) {
90
+ nodes.pop();
91
+ }
92
+ return nodes;
93
+ }
94
+
95
+ /**
96
+ * True when content has no meaningful text or media (e.g. only `<p></p>` / `paragraph: []`).
97
+ */
98
+ export function isSemanticallyEmptyTiptapContent(value: unknown): boolean {
99
+ const nodes = normalizeTiptapContentForCompare(value);
100
+ if (nodes.length === 0) return true;
101
+ return nodes.every((node) => isEmptyTiptapBlockNode(node));
102
+ }
103
+
104
+ export function canonicalTiptapContentJson(value: unknown): string {
105
+ try {
106
+ return JSON.stringify(normalizeTiptapContentForCompare(value));
107
+ } catch {
108
+ return '';
109
+ }
110
+ }
111
+
112
+ export function tiptapContentEquals(a: unknown, b: unknown): boolean {
113
+ return canonicalTiptapContentJson(a) === canonicalTiptapContentJson(b);
114
+ }
115
+
116
+ /**
117
+ * Visible character count of editor HTML (ignores tags). Matches logs comment validation.
118
+ */
119
+ export function htmlVisibleTextLength(html: string): number {
120
+ const text = html
121
+ .replace(/<[^>]+>/g, '')
122
+ .replace(/&nbsp;/gi, ' ')
123
+ .replace(/&lt;/g, '<')
124
+ .replace(/&gt;/g, '>')
125
+ .replace(/&amp;/g, '&')
126
+ .trim();
127
+ return text.length;
128
+ }
129
+
130
+ export function isSemanticallyEmptyEditorHtml(html: string): boolean {
131
+ return htmlVisibleTextLength(html) === 0;
132
+ }
133
+
134
+ /** Payload shape for MayaEditor `output="json"`. */
135
+ export function normalizeTiptapDocPayload(payload: string | TiptapDoc): string | TiptapDoc {
136
+ if (typeof payload === 'string') return payload;
137
+ return {
138
+ ...payload,
139
+ content: normalizeTiptapContentForCompare(payload.content) as TiptapDoc['content'],
140
+ };
141
+ }