@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,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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
198
|
+
editor.on('destroy', markNotReady);
|
|
199
|
+
|
|
151
200
|
try {
|
|
152
|
-
if (editor.view
|
|
153
|
-
} catch {
|
|
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
|
-
|
|
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
|
|
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(/ /gi, ' ')
|
|
123
|
+
.replace(/</g, '<')
|
|
124
|
+
.replace(/>/g, '>')
|
|
125
|
+
.replace(/&/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
|
+
}
|