@ceedcv-maya/shared-editor-react 0.6.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/README.md +1 -5
- package/package.json +2 -3
- package/src/components/EditorToolbar.tsx +3 -1
- package/src/components/EditorToolbarGroups.tsx +7 -6
- package/src/components/MayaEditor.tsx +73 -7
- package/src/hooks/useEditorContent.ts +28 -1
- package/src/index.ts +11 -4
- package/src/lib/isEditorReady.ts +13 -0
- package/src/lib/tiptapContentSemantics.test.ts +36 -0
- package/src/lib/tiptapContentSemantics.ts +141 -0
- package/src/types.ts +0 -25
- package/src/serializers/BlockNoteToTiptap.ts +0 -223
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Unified TipTap editor for the Maya ecosystem.
|
|
|
4
4
|
|
|
5
5
|
## Components
|
|
6
6
|
|
|
7
|
-
- **`<MayaEditor mode="lite" | "full" />`** — single editor with two visual modes. `lite` for short comments and alerts; `full` for templates and documents
|
|
7
|
+
- **`<MayaEditor mode="lite" | "full" />`** — single editor with two visual modes. `lite` for short comments and alerts; `full` for templates and documents.
|
|
8
8
|
- **`<EditorContentHtml html />`** — read-only renderer with DOMPurify sanitisation (aligned with the server-side `TiptapHtmlRenderer`).
|
|
9
9
|
- **`<EditorToolbar />`** — toolbar builder, used internally by `MayaEditor` and exposed for custom integrations.
|
|
10
10
|
|
|
@@ -14,10 +14,6 @@ Unified TipTap editor for the Maya ecosystem.
|
|
|
14
14
|
- `AlertBlock` — variants info / warning / success / danger.
|
|
15
15
|
- `CommentMark` — anchored-comment mark (paired with `AnchoredCommentController` server-side).
|
|
16
16
|
|
|
17
|
-
## Conversion
|
|
18
|
-
|
|
19
|
-
- `convertBlockNoteToTiptap(blocks)` — legacy → ProseMirror conversion. Mirror of the PHP `Maya\Editor\Renderers\BlockNoteToTiptap`.
|
|
20
|
-
|
|
21
17
|
## Document import & block splitting
|
|
22
18
|
|
|
23
19
|
Helpers behind the "Import from Word → blocks" flow (`DocxBlockSplitter` in maya_dms):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ceedcv-maya/shared-editor-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -57,12 +57,11 @@
|
|
|
57
57
|
"publishConfig": {
|
|
58
58
|
"access": "public"
|
|
59
59
|
},
|
|
60
|
-
"description": "Unified rich-text editor for Maya: MayaEditor (TipTap) with lite/full modes,
|
|
60
|
+
"description": "Unified rich-text editor for Maya: MayaEditor (TipTap) with lite/full modes, HTML SSR renderer, anchored-comments support.",
|
|
61
61
|
"keywords": [
|
|
62
62
|
"react",
|
|
63
63
|
"tiptap",
|
|
64
64
|
"prosemirror",
|
|
65
|
-
"blocknote",
|
|
66
65
|
"editor",
|
|
67
66
|
"wysiwyg",
|
|
68
67
|
"ceedcv",
|
|
@@ -198,7 +198,8 @@ export function EditorToolbar({
|
|
|
198
198
|
<ListAndBlockButtons editor={editor} labels={L} />
|
|
199
199
|
<TableAndMediaButtons editor={editor} labels={L} onImage={onImage} />
|
|
200
200
|
|
|
201
|
-
|
|
201
|
+
|
|
202
|
+
<div className="flex items-end gap-0.5 shrink-0 pl-1 border-l border-ui-border dark:border-ui-dark-border ml-auto">
|
|
202
203
|
<DocumentButtons
|
|
203
204
|
editor={editor}
|
|
204
205
|
labels={L}
|
|
@@ -218,6 +219,7 @@ export function EditorToolbar({
|
|
|
218
219
|
onInsertMarkdown={onInsertMarkdown}
|
|
219
220
|
onToggleFullscreen={onToggleFullscreen}
|
|
220
221
|
/>
|
|
222
|
+
</div>
|
|
221
223
|
</>
|
|
222
224
|
)}
|
|
223
225
|
</div>
|
|
@@ -388,13 +388,14 @@ export function ViewModeButtons({
|
|
|
388
388
|
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>{'<>'}</span>
|
|
389
389
|
</Btn>
|
|
390
390
|
)}
|
|
391
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
391
392
|
{onToggleFullscreen && (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
393
|
+
<Btn
|
|
394
|
+
onClick={onToggleFullscreen}
|
|
395
|
+
title={isFullscreen ? L.exitFullscreen : L.fullscreen}
|
|
396
|
+
>
|
|
397
|
+
{isFullscreen ? '🗗' : '🗖'}
|
|
398
|
+
</Btn>
|
|
398
399
|
)}
|
|
399
400
|
</>
|
|
400
401
|
);
|
|
@@ -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
|
@@ -12,7 +12,6 @@ export { Indent } from './extensions/Indent';
|
|
|
12
12
|
export type { IndentOptions } from './extensions/Indent';
|
|
13
13
|
export { ColorPicker } from './components/ColorPicker';
|
|
14
14
|
|
|
15
|
-
export { convertBlockNoteToTiptap } from './serializers/BlockNoteToTiptap';
|
|
16
15
|
export { useEditorContent } from './hooks/useEditorContent';
|
|
17
16
|
export { getAnchorRange, setAnchorRange, rebaseAnchors } from './lib/CommentAnchor';
|
|
18
17
|
export type { AnchorRange } from './lib/CommentAnchor';
|
|
@@ -25,6 +24,17 @@ export { splitHtmlIntoBlocks } from './lib/splitHtmlIntoBlocks';
|
|
|
25
24
|
export type { BlockChunk, BlockChunkType } from './lib/splitHtmlIntoBlocks';
|
|
26
25
|
export { htmlToTiptapDoc } from './lib/htmlToTiptapDoc';
|
|
27
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';
|
|
28
38
|
export { docxToHtml, docxToHtmlResult } from './lib/docxToHtml';
|
|
29
39
|
export type { DocxConversionMessage, DocxConversionResult } from './lib/docxToHtml';
|
|
30
40
|
export { SourceInputDialog } from './components/SourceInputDialog';
|
|
@@ -38,9 +48,6 @@ export type {
|
|
|
38
48
|
TiptapNode,
|
|
39
49
|
TiptapDoc,
|
|
40
50
|
AnchoredComment,
|
|
41
|
-
BlockNoteBlock,
|
|
42
|
-
BlockNoteInline,
|
|
43
|
-
BlockNoteStyles,
|
|
44
51
|
} from './types';
|
|
45
52
|
|
|
46
53
|
export { default as esTranslations } from './i18n/es.json';
|
|
@@ -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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -29,28 +29,3 @@ export interface AnchoredComment {
|
|
|
29
29
|
anchorIsValid: boolean;
|
|
30
30
|
anchorLastSyncedAt: string | null;
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
export interface BlockNoteStyles {
|
|
34
|
-
bold?: boolean;
|
|
35
|
-
italic?: boolean;
|
|
36
|
-
underline?: boolean;
|
|
37
|
-
strike?: boolean;
|
|
38
|
-
code?: boolean;
|
|
39
|
-
textColor?: string;
|
|
40
|
-
backgroundColor?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface BlockNoteInline {
|
|
44
|
-
type: string;
|
|
45
|
-
text?: string;
|
|
46
|
-
styles?: BlockNoteStyles;
|
|
47
|
-
href?: string;
|
|
48
|
-
content?: BlockNoteInline[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface BlockNoteBlock {
|
|
52
|
-
type: string;
|
|
53
|
-
props?: Record<string, unknown>;
|
|
54
|
-
content?: BlockNoteInline[] | { rows?: Array<{ cells: unknown[] }> };
|
|
55
|
-
children?: BlockNoteBlock[];
|
|
56
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JS mirror of Maya\Editor\Renderers\BlockNoteToTiptap (PHP).
|
|
3
|
-
*
|
|
4
|
-
* Same input/output as the PHP version — used for round-trip oracle tests
|
|
5
|
-
* (BlockNote → Tiptap → HTML via both renderers must produce identical
|
|
6
|
-
* output) and at-runtime when the frontend reads legacy `content` columns
|
|
7
|
-
* during the migration window.
|
|
8
|
-
*/
|
|
9
|
-
import type {
|
|
10
|
-
BlockNoteBlock,
|
|
11
|
-
BlockNoteInline,
|
|
12
|
-
BlockNoteStyles,
|
|
13
|
-
TiptapDoc,
|
|
14
|
-
TiptapMark,
|
|
15
|
-
TiptapNode,
|
|
16
|
-
} from '../types';
|
|
17
|
-
|
|
18
|
-
type ListBlockType = 'bulletListItem' | 'numberedListItem' | 'checkListItem';
|
|
19
|
-
|
|
20
|
-
const LIST_TO_LIST_NODE: Record<ListBlockType, 'bulletList' | 'orderedList' | 'taskList'> = {
|
|
21
|
-
bulletListItem: 'bulletList',
|
|
22
|
-
numberedListItem: 'orderedList',
|
|
23
|
-
checkListItem: 'taskList',
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export function convertBlockNoteToTiptap(blocks: BlockNoteBlock[]): TiptapDoc {
|
|
27
|
-
const content: TiptapNode[] = [];
|
|
28
|
-
let i = 0;
|
|
29
|
-
const n = blocks.length;
|
|
30
|
-
while (i < n) {
|
|
31
|
-
const block = blocks[i];
|
|
32
|
-
if (!block || typeof block !== 'object') {
|
|
33
|
-
i++;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
const type = String(block.type ?? 'paragraph');
|
|
37
|
-
|
|
38
|
-
if (type === 'bulletListItem' || type === 'numberedListItem' || type === 'checkListItem') {
|
|
39
|
-
const listType = LIST_TO_LIST_NODE[type as ListBlockType];
|
|
40
|
-
const items: TiptapNode[] = [];
|
|
41
|
-
while (
|
|
42
|
-
i < n &&
|
|
43
|
-
blocks[i] &&
|
|
44
|
-
(blocks[i].type === type)
|
|
45
|
-
) {
|
|
46
|
-
items.push(convertListItem(blocks[i], type as ListBlockType));
|
|
47
|
-
i++;
|
|
48
|
-
}
|
|
49
|
-
content.push({ type: listType, content: items });
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
content.push(convertBlock(block));
|
|
54
|
-
i++;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { type: 'doc', content };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function convertBlock(block: BlockNoteBlock): TiptapNode {
|
|
61
|
-
const type = String(block.type ?? 'paragraph');
|
|
62
|
-
const props = (block.props ?? {}) as Record<string, unknown>;
|
|
63
|
-
const inline = convertInline((block.content as BlockNoteInline[] | undefined) ?? []);
|
|
64
|
-
const attrs = propsToAttrs(props);
|
|
65
|
-
|
|
66
|
-
switch (type) {
|
|
67
|
-
case 'heading': {
|
|
68
|
-
const lvl = Math.max(1, Math.min(6, Number(props.level ?? 2) || 2));
|
|
69
|
-
return { type: 'heading', attrs: { ...attrs, level: lvl }, content: inline };
|
|
70
|
-
}
|
|
71
|
-
case 'paragraph':
|
|
72
|
-
return { type: 'paragraph', attrs, content: inline };
|
|
73
|
-
case 'quote':
|
|
74
|
-
return {
|
|
75
|
-
type: 'blockquote',
|
|
76
|
-
attrs,
|
|
77
|
-
content: [{ type: 'paragraph', content: inline }],
|
|
78
|
-
};
|
|
79
|
-
case 'codeBlock':
|
|
80
|
-
return { type: 'codeBlock', attrs, content: inline };
|
|
81
|
-
case 'image':
|
|
82
|
-
return {
|
|
83
|
-
type: 'image',
|
|
84
|
-
attrs: {
|
|
85
|
-
src: String(props.url ?? ''),
|
|
86
|
-
alt: String(props.caption ?? ''),
|
|
87
|
-
caption: String(props.caption ?? ''),
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
case 'table':
|
|
91
|
-
return convertTable((block.content as { rows?: unknown[] } | undefined) ?? {});
|
|
92
|
-
default:
|
|
93
|
-
return {
|
|
94
|
-
type: 'paragraph',
|
|
95
|
-
attrs: { ...attrs, 'data-original-type': type },
|
|
96
|
-
content: inline,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function convertListItem(block: BlockNoteBlock, blockType: ListBlockType): TiptapNode {
|
|
102
|
-
const props = (block.props ?? {}) as Record<string, unknown>;
|
|
103
|
-
const inline = convertInline((block.content as BlockNoteInline[] | undefined) ?? []);
|
|
104
|
-
const attrs = propsToAttrs(props);
|
|
105
|
-
|
|
106
|
-
const itemContent: TiptapNode[] = [{ type: 'paragraph', content: inline }];
|
|
107
|
-
|
|
108
|
-
const children = block.children ?? [];
|
|
109
|
-
if (children.length > 0) {
|
|
110
|
-
const childDoc = convertBlockNoteToTiptap(children);
|
|
111
|
-
for (const childNode of childDoc.content) {
|
|
112
|
-
itemContent.push(childNode);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (blockType === 'checkListItem') {
|
|
117
|
-
return {
|
|
118
|
-
type: 'taskItem',
|
|
119
|
-
attrs: { ...attrs, checked: !!props.checked },
|
|
120
|
-
content: itemContent,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return { type: 'listItem', attrs, content: itemContent };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function convertTable(content: { rows?: unknown[] }): TiptapNode {
|
|
128
|
-
const rows = Array.isArray(content.rows) ? content.rows : [];
|
|
129
|
-
const proseRows: TiptapNode[] = [];
|
|
130
|
-
let isFirstRow = true;
|
|
131
|
-
for (const row of rows) {
|
|
132
|
-
if (!row || typeof row !== 'object' || !Array.isArray((row as { cells?: unknown[] }).cells)) {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
const cells: TiptapNode[] = [];
|
|
136
|
-
for (const cell of (row as { cells: unknown[] }).cells) {
|
|
137
|
-
let cellContent: TiptapNode[] = [];
|
|
138
|
-
const cellAttrs: Record<string, unknown> = {};
|
|
139
|
-
if (cell && typeof cell === 'object') {
|
|
140
|
-
const cellObj = cell as { content?: unknown; props?: { colspan?: unknown; rowspan?: unknown } };
|
|
141
|
-
if (Array.isArray(cellObj.content)) {
|
|
142
|
-
cellContent = convertInline(cellObj.content as BlockNoteInline[]);
|
|
143
|
-
if (cellObj.props) {
|
|
144
|
-
if (cellObj.props.colspan != null) {
|
|
145
|
-
cellAttrs.colspan = Number(cellObj.props.colspan) || 1;
|
|
146
|
-
}
|
|
147
|
-
if (cellObj.props.rowspan != null) {
|
|
148
|
-
cellAttrs.rowspan = Number(cellObj.props.rowspan) || 1;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
} else if (Array.isArray(cell)) {
|
|
152
|
-
cellContent = convertInline(cell as BlockNoteInline[]);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
cells.push({
|
|
156
|
-
type: isFirstRow ? 'tableHeader' : 'tableCell',
|
|
157
|
-
attrs: cellAttrs,
|
|
158
|
-
content: [{ type: 'paragraph', content: cellContent }],
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
proseRows.push({ type: 'tableRow', content: cells });
|
|
162
|
-
isFirstRow = false;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return { type: 'table', content: proseRows };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function convertInline(content: BlockNoteInline[]): TiptapNode[] {
|
|
169
|
-
const out: TiptapNode[] = [];
|
|
170
|
-
for (const span of content) {
|
|
171
|
-
if (!span || typeof span !== 'object') continue;
|
|
172
|
-
const type = String(span.type ?? 'text');
|
|
173
|
-
if (type === 'text') {
|
|
174
|
-
const text = String(span.text ?? '');
|
|
175
|
-
if (text === '') continue;
|
|
176
|
-
const marks = stylesToMarks(span.styles ?? {});
|
|
177
|
-
const node: TiptapNode = { type: 'text', text };
|
|
178
|
-
if (marks.length > 0) node.marks = marks;
|
|
179
|
-
out.push(node);
|
|
180
|
-
} else if (type === 'link') {
|
|
181
|
-
const href = String(span.href ?? '');
|
|
182
|
-
const linkMark: TiptapMark = { type: 'link', attrs: { href } };
|
|
183
|
-
for (const inner of span.content ?? []) {
|
|
184
|
-
if (!inner || inner.type !== 'text') continue;
|
|
185
|
-
const text = String(inner.text ?? '');
|
|
186
|
-
if (text === '') continue;
|
|
187
|
-
const marks = [...stylesToMarks(inner.styles ?? {}), linkMark];
|
|
188
|
-
out.push({ type: 'text', text, marks });
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return out;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function stylesToMarks(styles: BlockNoteStyles): TiptapMark[] {
|
|
196
|
-
const marks: TiptapMark[] = [];
|
|
197
|
-
if (styles.bold) marks.push({ type: 'bold' });
|
|
198
|
-
if (styles.italic) marks.push({ type: 'italic' });
|
|
199
|
-
if (styles.underline) marks.push({ type: 'underline' });
|
|
200
|
-
if (styles.strike) marks.push({ type: 'strike' });
|
|
201
|
-
if (styles.code) marks.push({ type: 'code' });
|
|
202
|
-
if (styles.textColor && styles.textColor !== 'default') {
|
|
203
|
-
marks.push({ type: 'textStyle', attrs: { color: styles.textColor } });
|
|
204
|
-
}
|
|
205
|
-
if (styles.backgroundColor && styles.backgroundColor !== 'default') {
|
|
206
|
-
marks.push({ type: 'highlight', attrs: { color: styles.backgroundColor } });
|
|
207
|
-
}
|
|
208
|
-
return marks;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function propsToAttrs(props: Record<string, unknown>): Record<string, unknown> {
|
|
212
|
-
const attrs: Record<string, unknown> = {};
|
|
213
|
-
if (props.textColor && props.textColor !== 'default') {
|
|
214
|
-
attrs.textColor = String(props.textColor);
|
|
215
|
-
}
|
|
216
|
-
if (props.backgroundColor && props.backgroundColor !== 'default') {
|
|
217
|
-
attrs.backgroundColor = String(props.backgroundColor);
|
|
218
|
-
}
|
|
219
|
-
if (props.textAlignment) {
|
|
220
|
-
attrs.textAlign = String(props.textAlignment);
|
|
221
|
-
}
|
|
222
|
-
return attrs;
|
|
223
|
-
}
|