@ceedcv-maya/shared-editor-react 0.6.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/package.json +87 -0
  4. package/src/components/ColorPicker.tsx +100 -0
  5. package/src/components/CommentHoverPopover.tsx +82 -0
  6. package/src/components/EditorContentHtml.tsx +29 -0
  7. package/src/components/EditorToolbar.tsx +225 -0
  8. package/src/components/EditorToolbarButton.tsx +32 -0
  9. package/src/components/EditorToolbarGroups.tsx +401 -0
  10. package/src/components/FindReplaceBar.tsx +253 -0
  11. package/src/components/MayaEditor.tsx +379 -0
  12. package/src/components/SourceInputDialog.tsx +120 -0
  13. package/src/extensions/AlertBlock.ts +59 -0
  14. package/src/extensions/CommentMark.ts +57 -0
  15. package/src/extensions/IframeBlock.ts +76 -0
  16. package/src/extensions/Indent.ts +133 -0
  17. package/src/hooks/useEditorContent.ts +47 -0
  18. package/src/i18n/en.json +54 -0
  19. package/src/i18n/es.json +54 -0
  20. package/src/index.ts +47 -0
  21. package/src/lib/CommentAnchor.ts +68 -0
  22. package/src/lib/docxToHtml.ts +58 -0
  23. package/src/lib/dompurifyConfig.test.ts +98 -0
  24. package/src/lib/dompurifyConfig.ts +123 -0
  25. package/src/lib/editorExtensions.ts +73 -0
  26. package/src/lib/htmlToMarkdown.ts +166 -0
  27. package/src/lib/htmlToTiptapDoc.test.ts +52 -0
  28. package/src/lib/htmlToTiptapDoc.ts +26 -0
  29. package/src/lib/markdownToHtml.ts +234 -0
  30. package/src/lib/normalizeTableHtml.ts +74 -0
  31. package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
  32. package/src/lib/splitHtmlIntoBlocks.ts +136 -0
  33. package/src/serializers/BlockNoteToTiptap.ts +223 -0
  34. package/src/styles/maya-editor.css +538 -0
  35. package/src/types.ts +56 -0
  36. package/tsconfig.json +20 -0
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { splitHtmlIntoBlocks } from './splitHtmlIntoBlocks';
3
+
4
+ describe('splitHtmlIntoBlocks', () => {
5
+ it('returns one chunk per top-level paragraph', () => {
6
+ const html = '<p>uno</p><p>dos</p><p>tres</p><p>cuatro</p><p>cinco</p>';
7
+ const chunks = splitHtmlIntoBlocks(html);
8
+ expect(chunks).toHaveLength(5);
9
+ expect(chunks.every((c) => c.type === 'paragraph')).toBe(true);
10
+ expect(chunks.map((c) => c.index)).toEqual([0, 1, 2, 3, 4]);
11
+ expect(chunks[0].text).toBe('uno');
12
+ });
13
+
14
+ it('classifies heading, paragraph, table and list', () => {
15
+ const html =
16
+ '<h1>Título</h1><p>Intro</p><table><tr><td>a</td></tr></table><ul><li>x</li></ul>';
17
+ const chunks = splitHtmlIntoBlocks(html);
18
+ expect(chunks.map((c) => c.type)).toEqual(['heading', 'paragraph', 'table', 'list']);
19
+ expect(chunks[0].level).toBe(1);
20
+ });
21
+
22
+ it('captures heading level for h1-h6', () => {
23
+ const chunks = splitHtmlIntoBlocks('<h3>tres</h3><h6>seis</h6>');
24
+ expect(chunks[0]).toMatchObject({ type: 'heading', level: 3 });
25
+ expect(chunks[1]).toMatchObject({ type: 'heading', level: 6 });
26
+ });
27
+
28
+ it('flags whitespace-only / <br>-only paragraphs as empty', () => {
29
+ const chunks = splitHtmlIntoBlocks('<p> </p><p><br></p><p>real</p>');
30
+ expect(chunks[0].isEmpty).toBe(true);
31
+ expect(chunks[1].isEmpty).toBe(true);
32
+ expect(chunks[2].isEmpty).toBe(false);
33
+ });
34
+
35
+ it('does not flag a paragraph with an image as empty', () => {
36
+ const chunks = splitHtmlIntoBlocks('<p><img src="x.png"></p>');
37
+ expect(chunks[0].isEmpty).toBe(false);
38
+ });
39
+
40
+ it('explodes a bullet list into one chunk per item', () => {
41
+ const html = '<ul><li>uno</li><li>dos</li><li>tres</li></ul>';
42
+ const chunks = splitHtmlIntoBlocks(html);
43
+ expect(chunks).toHaveLength(3);
44
+ expect(chunks.every((c) => c.type === 'list')).toBe(true);
45
+ expect(chunks.map((c) => c.text)).toEqual(['uno', 'dos', 'tres']);
46
+ // Each item stays a valid, convertible list.
47
+ expect(chunks[0].html).toBe('<ul><li>uno</li></ul>');
48
+ expect(chunks.map((c) => c.index)).toEqual([0, 1, 2]);
49
+ });
50
+
51
+ it('preserves ol vs ul tag when exploding list items', () => {
52
+ const chunks = splitHtmlIntoBlocks('<ol><li>a</li><li>b</li></ol>');
53
+ expect(chunks[0].html).toBe('<ol><li>a</li></ol>');
54
+ expect(chunks[1].html).toBe('<ol><li>b</li></ol>');
55
+ });
56
+
57
+ it('keeps document order across headings, paragraphs and exploded lists', () => {
58
+ const html = '<h1>T</h1><ul><li>p1</li><li>p2</li></ul><p>fin</p>';
59
+ const chunks = splitHtmlIntoBlocks(html);
60
+ expect(chunks.map((c) => c.type)).toEqual(['heading', 'list', 'list', 'paragraph']);
61
+ expect(chunks.map((c) => c.index)).toEqual([0, 1, 2, 3]);
62
+ });
63
+
64
+ it('maps unknown / wrapper elements to "other"', () => {
65
+ const chunks = splitHtmlIntoBlocks('<div><p>anidado</p></div>');
66
+ expect(chunks).toHaveLength(1);
67
+ expect(chunks[0].type).toBe('other');
68
+ });
69
+
70
+ it('preserves outerHTML for round-trip', () => {
71
+ const chunks = splitHtmlIntoBlocks('<h2>Sección</h2>');
72
+ expect(chunks[0].html).toBe('<h2>Sección</h2>');
73
+ });
74
+
75
+ it('truncates long text snippets to 200 chars', () => {
76
+ const long = 'a'.repeat(500);
77
+ const chunks = splitHtmlIntoBlocks(`<p>${long}</p>`);
78
+ expect(chunks[0].text.length).toBe(200);
79
+ expect(chunks[0].text.endsWith('…')).toBe(true);
80
+ });
81
+
82
+ it('returns [] for empty or whitespace input', () => {
83
+ expect(splitHtmlIntoBlocks('')).toEqual([]);
84
+ expect(splitHtmlIntoBlocks(' ')).toEqual([]);
85
+ });
86
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Split an HTML fragment into top-level "block chunks".
3
+ *
4
+ * Domain-agnostic, no React. Walks the top-level children of the parsed
5
+ * `<body>` and maps each element to a {@link BlockChunk}, preserving its
6
+ * outer HTML for a lossless round-trip into the editor. Used by the
7
+ * docx-to-blocks importer: mammoth → HTML → chunks → user grouping → blocks.
8
+ *
9
+ * Structural containers (tables, lists, figures) are kept atomic — never
10
+ * split — so the TipTap schema stays valid when each chunk is converted.
11
+ */
12
+
13
+ export type BlockChunkType =
14
+ | 'heading'
15
+ | 'paragraph'
16
+ | 'list'
17
+ | 'table'
18
+ | 'figure'
19
+ | 'blockquote'
20
+ | 'codeBlock'
21
+ | 'horizontalRule'
22
+ | 'other';
23
+
24
+ export interface BlockChunk {
25
+ /** Stable index in document order. */
26
+ index: number;
27
+ /** Semantic type for filtering + icon. */
28
+ type: BlockChunkType;
29
+ /** Heading level when `type === 'heading'` (1-6). */
30
+ level?: number;
31
+ /** HTML serialisation of the element (preserved for round-trip). */
32
+ html: string;
33
+ /** Plain-text snippet, max 200 chars, for the list label. */
34
+ text: string;
35
+ /** True for empty `<p>`/`<br>`-only paragraphs (callers may skip). */
36
+ isEmpty: boolean;
37
+ }
38
+
39
+ const TEXT_SNIPPET_MAX = 200;
40
+
41
+ function classify(tagName: string): { type: BlockChunkType; level?: number } {
42
+ const tag = tagName.toLowerCase();
43
+ const headingMatch = /^h([1-6])$/.exec(tag);
44
+ if (headingMatch) return { type: 'heading', level: Number(headingMatch[1]) };
45
+ switch (tag) {
46
+ case 'p':
47
+ return { type: 'paragraph' };
48
+ case 'ul':
49
+ case 'ol':
50
+ return { type: 'list' };
51
+ case 'table':
52
+ return { type: 'table' };
53
+ case 'figure':
54
+ return { type: 'figure' };
55
+ case 'blockquote':
56
+ return { type: 'blockquote' };
57
+ case 'pre':
58
+ return { type: 'codeBlock' };
59
+ case 'hr':
60
+ return { type: 'horizontalRule' };
61
+ default:
62
+ return { type: 'other' };
63
+ }
64
+ }
65
+
66
+ function snippet(text: string): string {
67
+ const collapsed = text.replace(/\s+/g, ' ').trim();
68
+ return collapsed.length > TEXT_SNIPPET_MAX
69
+ ? `${collapsed.slice(0, TEXT_SNIPPET_MAX - 1)}…`
70
+ : collapsed;
71
+ }
72
+
73
+ /**
74
+ * A paragraph is "empty" when it carries no text and no meaningful content
75
+ * (only whitespace and/or `<br>` elements). Mammoth emits these as Word
76
+ * paragraph separators — pure visual noise the importer offers to skip.
77
+ */
78
+ function isEmptyParagraph(el: Element): boolean {
79
+ if ((el.textContent ?? '').trim() !== '') return false;
80
+ // Any non-<br> element child (e.g. <img>) makes it non-empty.
81
+ return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'br');
82
+ }
83
+
84
+ /**
85
+ * Wrap a single `<li>` back in its parent list tag so the chunk is still a
86
+ * valid, convertible list (a bare `<li>` is not a valid top-level block).
87
+ */
88
+ function wrapListItem(listTag: string, li: Element): string {
89
+ return `<${listTag}>${li.outerHTML}</${listTag}>`;
90
+ }
91
+
92
+ export function splitHtmlIntoBlocks(html: string): BlockChunk[] {
93
+ if (!html || !html.trim()) return [];
94
+
95
+ const doc = new DOMParser().parseFromString(html, 'text/html');
96
+ const children = Array.from(doc.body.children);
97
+
98
+ const chunks: BlockChunk[] = [];
99
+ let index = 0;
100
+
101
+ for (const el of children) {
102
+ const { type, level } = classify(el.tagName);
103
+
104
+ // Explode lists: one chunk per <li> so the user can assign each bullet
105
+ // to a different block. Each chunk re-wraps the item in its list tag.
106
+ if (type === 'list') {
107
+ const tag = el.tagName.toLowerCase();
108
+ const items = Array.from(el.children).filter((c) => c.tagName.toLowerCase() === 'li');
109
+ if (items.length > 0) {
110
+ for (const li of items) {
111
+ chunks.push({
112
+ index: index++,
113
+ type: 'list',
114
+ html: wrapListItem(tag, li),
115
+ text: snippet(li.textContent ?? ''),
116
+ isEmpty: (li.textContent ?? '').trim() === '',
117
+ });
118
+ }
119
+ continue;
120
+ }
121
+ // Empty list — fall through to the generic single-chunk path.
122
+ }
123
+
124
+ const isEmpty = type === 'paragraph' && isEmptyParagraph(el);
125
+ chunks.push({
126
+ index: index++,
127
+ type,
128
+ ...(level !== undefined ? { level } : {}),
129
+ html: el.outerHTML,
130
+ text: snippet(el.textContent ?? ''),
131
+ isEmpty,
132
+ });
133
+ }
134
+
135
+ return chunks;
136
+ }
@@ -0,0 +1,223 @@
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
+ }