@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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +87 -0
- package/src/components/ColorPicker.tsx +100 -0
- package/src/components/CommentHoverPopover.tsx +82 -0
- package/src/components/EditorContentHtml.tsx +29 -0
- package/src/components/EditorToolbar.tsx +225 -0
- package/src/components/EditorToolbarButton.tsx +32 -0
- package/src/components/EditorToolbarGroups.tsx +401 -0
- package/src/components/FindReplaceBar.tsx +253 -0
- package/src/components/MayaEditor.tsx +379 -0
- package/src/components/SourceInputDialog.tsx +120 -0
- package/src/extensions/AlertBlock.ts +59 -0
- package/src/extensions/CommentMark.ts +57 -0
- package/src/extensions/IframeBlock.ts +76 -0
- package/src/extensions/Indent.ts +133 -0
- package/src/hooks/useEditorContent.ts +47 -0
- package/src/i18n/en.json +54 -0
- package/src/i18n/es.json +54 -0
- package/src/index.ts +47 -0
- package/src/lib/CommentAnchor.ts +68 -0
- package/src/lib/docxToHtml.ts +58 -0
- package/src/lib/dompurifyConfig.test.ts +98 -0
- package/src/lib/dompurifyConfig.ts +123 -0
- package/src/lib/editorExtensions.ts +73 -0
- package/src/lib/htmlToMarkdown.ts +166 -0
- package/src/lib/htmlToTiptapDoc.test.ts +52 -0
- package/src/lib/htmlToTiptapDoc.ts +26 -0
- package/src/lib/markdownToHtml.ts +234 -0
- package/src/lib/normalizeTableHtml.ts +74 -0
- package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
- package/src/lib/splitHtmlIntoBlocks.ts +136 -0
- package/src/serializers/BlockNoteToTiptap.ts +223 -0
- package/src/styles/maya-editor.css +538 -0
- package/src/types.ts +56 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical TipTap extension list for the Maya editor.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `MayaEditor` so the exact same schema can be reused outside
|
|
5
|
+
* the React component — e.g. by `htmlToTiptapDoc`, which spins up a headless
|
|
6
|
+
* editor and must produce JSON identical to what the live editor persists.
|
|
7
|
+
*
|
|
8
|
+
* Keep this the single source of truth: `MayaEditor` consumes it too.
|
|
9
|
+
*/
|
|
10
|
+
import { StarterKit } from '@tiptap/starter-kit';
|
|
11
|
+
import { Link } from '@tiptap/extension-link';
|
|
12
|
+
import { Underline } from '@tiptap/extension-underline';
|
|
13
|
+
import { Table } from '@tiptap/extension-table';
|
|
14
|
+
import { TableRow } from '@tiptap/extension-table-row';
|
|
15
|
+
import { TableHeader } from '@tiptap/extension-table-header';
|
|
16
|
+
import { TableCell } from '@tiptap/extension-table-cell';
|
|
17
|
+
import { Image } from '@tiptap/extension-image';
|
|
18
|
+
import { TaskList } from '@tiptap/extension-task-list';
|
|
19
|
+
import { TaskItem } from '@tiptap/extension-task-item';
|
|
20
|
+
import { Highlight } from '@tiptap/extension-highlight';
|
|
21
|
+
import { TextStyle } from '@tiptap/extension-text-style';
|
|
22
|
+
import { Color } from '@tiptap/extension-color';
|
|
23
|
+
import { TextAlign } from '@tiptap/extension-text-align';
|
|
24
|
+
import type { Extensions } from '@tiptap/core';
|
|
25
|
+
|
|
26
|
+
import { IframeBlock } from '../extensions/IframeBlock';
|
|
27
|
+
import { AlertBlock } from '../extensions/AlertBlock';
|
|
28
|
+
import { CommentMark } from '../extensions/CommentMark';
|
|
29
|
+
import { Indent } from '../extensions/Indent';
|
|
30
|
+
import type { EditorMode } from '../types';
|
|
31
|
+
|
|
32
|
+
export function buildMayaEditorExtensions(mode: EditorMode = 'full'): Extensions {
|
|
33
|
+
const base: Extensions = [
|
|
34
|
+
// StarterKit v3 already bundles Link + Underline (and other marks).
|
|
35
|
+
// Disable both so we can add our customised versions without
|
|
36
|
+
// tripping "Duplicate extension names" warnings.
|
|
37
|
+
StarterKit.configure({
|
|
38
|
+
link: false,
|
|
39
|
+
underline: false,
|
|
40
|
+
}),
|
|
41
|
+
Underline,
|
|
42
|
+
Link.configure({
|
|
43
|
+
openOnClick: false,
|
|
44
|
+
autolink: true,
|
|
45
|
+
protocols: ['http', 'https', 'mailto', 'tel'],
|
|
46
|
+
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
|
|
47
|
+
}),
|
|
48
|
+
TextStyle,
|
|
49
|
+
Color,
|
|
50
|
+
Highlight.configure({ multicolor: true }),
|
|
51
|
+
CommentMark,
|
|
52
|
+
];
|
|
53
|
+
if (mode === 'full') {
|
|
54
|
+
base.push(
|
|
55
|
+
TextAlign.configure({
|
|
56
|
+
types: ['heading', 'paragraph'],
|
|
57
|
+
alignments: ['left', 'center', 'right', 'justify'],
|
|
58
|
+
defaultAlignment: 'left',
|
|
59
|
+
}),
|
|
60
|
+
Indent,
|
|
61
|
+
Table.configure({ resizable: true }),
|
|
62
|
+
TableRow,
|
|
63
|
+
TableHeader,
|
|
64
|
+
TableCell,
|
|
65
|
+
Image,
|
|
66
|
+
TaskList,
|
|
67
|
+
TaskItem.configure({ nested: true }),
|
|
68
|
+
IframeBlock,
|
|
69
|
+
AlertBlock,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return base;
|
|
73
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HTML → Markdown converter for the editor's "Markdown view".
|
|
3
|
+
* Symmetric with `markdownToHtml`: supports the subset that round-trips
|
|
4
|
+
* through StarterKit + link/table/task-list extensions.
|
|
5
|
+
*
|
|
6
|
+
* Falls back to the raw text content for unknown elements so nothing is
|
|
7
|
+
* silently dropped from the source view.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function unwrapText(node: Node): string {
|
|
11
|
+
return node.textContent ?? '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function inlineToMarkdown(node: Node): string {
|
|
15
|
+
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
|
|
16
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
17
|
+
const el = node as HTMLElement;
|
|
18
|
+
const inner = Array.from(el.childNodes).map(inlineToMarkdown).join('');
|
|
19
|
+
|
|
20
|
+
switch (el.tagName) {
|
|
21
|
+
case 'BR': return '\n';
|
|
22
|
+
case 'STRONG':
|
|
23
|
+
case 'B': return `**${inner}**`;
|
|
24
|
+
case 'EM':
|
|
25
|
+
case 'I': return `*${inner}*`;
|
|
26
|
+
case 'U': return inner; // Markdown has no underline; preserve the text.
|
|
27
|
+
case 'S':
|
|
28
|
+
case 'DEL':
|
|
29
|
+
case 'STRIKE': return `~~${inner}~~`;
|
|
30
|
+
case 'CODE': return `\`${inner}\``;
|
|
31
|
+
case 'A': {
|
|
32
|
+
const href = el.getAttribute('href') ?? '';
|
|
33
|
+
return href ? `[${inner}](${href})` : inner;
|
|
34
|
+
}
|
|
35
|
+
case 'SPAN': return inner;
|
|
36
|
+
default: return inner;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function blockToMarkdown(el: Element, listDepth = 0): string {
|
|
41
|
+
const indent = ' '.repeat(listDepth);
|
|
42
|
+
|
|
43
|
+
switch (el.tagName) {
|
|
44
|
+
case 'H1':
|
|
45
|
+
case 'H2':
|
|
46
|
+
case 'H3':
|
|
47
|
+
case 'H4':
|
|
48
|
+
case 'H5':
|
|
49
|
+
case 'H6': {
|
|
50
|
+
const level = Number(el.tagName.substring(1));
|
|
51
|
+
return `${'#'.repeat(level)} ${inlineToMarkdown(el).trim()}\n\n`;
|
|
52
|
+
}
|
|
53
|
+
case 'P':
|
|
54
|
+
return `${inlineToMarkdown(el).trim()}\n\n`;
|
|
55
|
+
case 'BLOCKQUOTE': {
|
|
56
|
+
const inner = Array.from(el.children)
|
|
57
|
+
.map((c) => blockToMarkdown(c, listDepth).trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join('\n');
|
|
60
|
+
const text = inner || inlineToMarkdown(el).trim();
|
|
61
|
+
return text
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((l) => `> ${l}`)
|
|
64
|
+
.join('\n') + '\n\n';
|
|
65
|
+
}
|
|
66
|
+
case 'PRE': {
|
|
67
|
+
const code = el.querySelector('code');
|
|
68
|
+
const text = code ? unwrapText(code) : unwrapText(el);
|
|
69
|
+
return `\`\`\`\n${text}\n\`\`\`\n\n`;
|
|
70
|
+
}
|
|
71
|
+
case 'HR':
|
|
72
|
+
return '---\n\n';
|
|
73
|
+
case 'UL': {
|
|
74
|
+
const isTaskList = el.getAttribute('data-type') === 'taskList';
|
|
75
|
+
const out = Array.from(el.children).map((li) => {
|
|
76
|
+
if (isTaskList) {
|
|
77
|
+
const checked = (li as HTMLElement).getAttribute('data-checked') === 'true';
|
|
78
|
+
const text = Array.from(li.querySelectorAll('p'))
|
|
79
|
+
.map(inlineToMarkdown)
|
|
80
|
+
.join(' ')
|
|
81
|
+
.trim();
|
|
82
|
+
return `${indent}- [${checked ? 'x' : ' '}] ${text}`;
|
|
83
|
+
}
|
|
84
|
+
return listItemToMarkdown(li, '-', listDepth);
|
|
85
|
+
}).join('\n');
|
|
86
|
+
return out + '\n\n';
|
|
87
|
+
}
|
|
88
|
+
case 'OL': {
|
|
89
|
+
let i = 1;
|
|
90
|
+
const out = Array.from(el.children).map((li) => {
|
|
91
|
+
const marker = `${i++}.`;
|
|
92
|
+
return listItemToMarkdown(li, marker, listDepth);
|
|
93
|
+
}).join('\n');
|
|
94
|
+
return out + '\n\n';
|
|
95
|
+
}
|
|
96
|
+
case 'TABLE': {
|
|
97
|
+
const rows = Array.from(el.querySelectorAll('tr'));
|
|
98
|
+
if (rows.length === 0) return '';
|
|
99
|
+
const tableRows = rows.map((tr) => {
|
|
100
|
+
const cells = Array.from(tr.children).map((c) => inlineToMarkdown(c).trim().replace(/\n/g, ' '));
|
|
101
|
+
return `| ${cells.join(' | ')} |`;
|
|
102
|
+
});
|
|
103
|
+
// Insert a separator row after the first.
|
|
104
|
+
if (tableRows.length > 1) {
|
|
105
|
+
const sep = `| ${Array.from(rows[0].children).map(() => '---').join(' | ')} |`;
|
|
106
|
+
tableRows.splice(1, 0, sep);
|
|
107
|
+
}
|
|
108
|
+
return tableRows.join('\n') + '\n\n';
|
|
109
|
+
}
|
|
110
|
+
case 'IMG': {
|
|
111
|
+
const src = el.getAttribute('src') ?? '';
|
|
112
|
+
const alt = el.getAttribute('alt') ?? '';
|
|
113
|
+
return `\n\n`;
|
|
114
|
+
}
|
|
115
|
+
case 'IFRAME': {
|
|
116
|
+
const src = el.getAttribute('src') ?? '';
|
|
117
|
+
return `<iframe src="${src}"></iframe>\n\n`;
|
|
118
|
+
}
|
|
119
|
+
case 'ASIDE':
|
|
120
|
+
return Array.from(el.children).map((c) => blockToMarkdown(c, listDepth)).join('');
|
|
121
|
+
case 'FIGURE': {
|
|
122
|
+
const img = el.querySelector('img');
|
|
123
|
+
const caption = el.querySelector('figcaption');
|
|
124
|
+
if (img) {
|
|
125
|
+
const src = img.getAttribute('src') ?? '';
|
|
126
|
+
const alt = img.getAttribute('alt') ?? caption?.textContent ?? '';
|
|
127
|
+
return `\n\n`;
|
|
128
|
+
}
|
|
129
|
+
return inlineToMarkdown(el) + '\n\n';
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
return inlineToMarkdown(el) + '\n';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listItemToMarkdown(li: Element, marker: string, depth: number): string {
|
|
137
|
+
const indent = ' '.repeat(depth);
|
|
138
|
+
const blocks = Array.from(li.children);
|
|
139
|
+
if (blocks.length === 0) return `${indent}${marker} ${inlineToMarkdown(li).trim()}`;
|
|
140
|
+
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
for (const child of blocks) {
|
|
143
|
+
if (child.tagName === 'UL' || child.tagName === 'OL') {
|
|
144
|
+
const nested = blockToMarkdown(child, depth + 1).trimEnd();
|
|
145
|
+
if (nested) lines.push(nested);
|
|
146
|
+
} else {
|
|
147
|
+
const text = inlineToMarkdown(child).trim();
|
|
148
|
+
if (text) {
|
|
149
|
+
lines.push(lines.length === 0 ? `${indent}${marker} ${text}` : `${indent} ${text}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (lines.length === 0) lines.push(`${indent}${marker}`);
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function htmlToMarkdown(html: string): string {
|
|
158
|
+
if (typeof window === 'undefined' || typeof DOMParser === 'undefined') {
|
|
159
|
+
return html;
|
|
160
|
+
}
|
|
161
|
+
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html');
|
|
162
|
+
const root = doc.body.firstElementChild;
|
|
163
|
+
if (!root) return '';
|
|
164
|
+
const out = Array.from(root.children).map((el) => blockToMarkdown(el)).join('').trim();
|
|
165
|
+
return out + '\n';
|
|
166
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { htmlToTiptapDoc } from './htmlToTiptapDoc';
|
|
3
|
+
|
|
4
|
+
describe('htmlToTiptapDoc', () => {
|
|
5
|
+
it('converts a paragraph to a TipTap doc', () => {
|
|
6
|
+
const doc = htmlToTiptapDoc('<p>hello</p>');
|
|
7
|
+
expect(doc.type).toBe('doc');
|
|
8
|
+
const para = doc.content[0];
|
|
9
|
+
expect(para.type).toBe('paragraph');
|
|
10
|
+
expect(para.content?.[0]).toMatchObject({ type: 'text', text: 'hello' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('converts a heading and preserves its level', () => {
|
|
14
|
+
const doc = htmlToTiptapDoc('<h2>Sección</h2>');
|
|
15
|
+
expect(doc.content[0]).toMatchObject({ type: 'heading' });
|
|
16
|
+
expect(doc.content[0].attrs?.level).toBe(2);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('converts a bullet list into listItems', () => {
|
|
20
|
+
const doc = htmlToTiptapDoc('<ul><li>a</li><li>b</li></ul>');
|
|
21
|
+
const list = doc.content[0];
|
|
22
|
+
expect(list.type).toBe('bulletList');
|
|
23
|
+
expect(list.content).toHaveLength(2);
|
|
24
|
+
expect(list.content?.[0].type).toBe('listItem');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('converts a table into table nodes', () => {
|
|
28
|
+
const doc = htmlToTiptapDoc(
|
|
29
|
+
'<table><tbody><tr><td>a</td><td>b</td></tr></tbody></table>',
|
|
30
|
+
);
|
|
31
|
+
expect(doc.content[0].type).toBe('table');
|
|
32
|
+
const row = doc.content[0].content?.[0];
|
|
33
|
+
expect(row?.type).toBe('tableRow');
|
|
34
|
+
expect(row?.content).toHaveLength(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('preserves bold marks', () => {
|
|
38
|
+
const doc = htmlToTiptapDoc('<p>plain <strong>bold</strong></p>');
|
|
39
|
+
const marks = doc.content[0].content?.find((n) => n.text === 'bold')?.marks;
|
|
40
|
+
expect(marks?.some((m) => m.type === 'bold')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns a doc with a single empty paragraph for empty input', () => {
|
|
44
|
+
// TipTap normalises an empty doc to one empty paragraph (a valid doc
|
|
45
|
+
// must hold at least one block), so that — not [] — is the contract.
|
|
46
|
+
const doc = htmlToTiptapDoc('');
|
|
47
|
+
expect(doc.type).toBe('doc');
|
|
48
|
+
expect(doc.content).toHaveLength(1);
|
|
49
|
+
expect(doc.content[0].type).toBe('paragraph');
|
|
50
|
+
expect(doc.content[0].content ?? []).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert sanitised HTML to a ProseMirror/TipTap JSON doc using a headless
|
|
3
|
+
* editor configured with the Maya editor extensions, so the round-trip
|
|
4
|
+
* matches exactly what the live editor persists.
|
|
5
|
+
*
|
|
6
|
+
* Callers may pass their own `extensions` (e.g. a `mode='lite'` list); when
|
|
7
|
+
* omitted, the canonical `full` schema is used. The headless editor is
|
|
8
|
+
* created detached and destroyed before returning — no DOM node is mounted.
|
|
9
|
+
*/
|
|
10
|
+
import { Editor } from '@tiptap/core';
|
|
11
|
+
import type { Extensions } from '@tiptap/core';
|
|
12
|
+
|
|
13
|
+
import { buildMayaEditorExtensions } from './editorExtensions';
|
|
14
|
+
import type { TiptapDoc } from '../types';
|
|
15
|
+
|
|
16
|
+
export function htmlToTiptapDoc(html: string, extensions?: Extensions): TiptapDoc {
|
|
17
|
+
const editor = new Editor({
|
|
18
|
+
content: html ?? '',
|
|
19
|
+
extensions: extensions ?? buildMayaEditorExtensions('full'),
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
return editor.getJSON() as TiptapDoc;
|
|
23
|
+
} finally {
|
|
24
|
+
editor.destroy();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Markdown → HTML converter for the editor's "Insert Markdown"
|
|
3
|
+
* action. Supports the subset that maps cleanly to the ProseMirror schema
|
|
4
|
+
* we ship (StarterKit + extension-link + extension-table-ish lists +
|
|
5
|
+
* task lists). Anything else is preserved as plain text.
|
|
6
|
+
*
|
|
7
|
+
* Not a full CommonMark implementation — for a richer parse, callers can
|
|
8
|
+
* swap this for `marked`/`markdown-it` and feed the result to
|
|
9
|
+
* `editor.commands.insertContent`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function escapeHtml(s: string): string {
|
|
13
|
+
return s
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BLOCK_HTML_TAGS = new Set([
|
|
21
|
+
'address', 'article', 'aside', 'blockquote', 'div', 'dl', 'dd', 'dt',
|
|
22
|
+
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
|
|
23
|
+
'h4', 'h5', 'h6', 'header', 'hr', 'iframe', 'main', 'nav', 'noscript',
|
|
24
|
+
'ol', 'p', 'pre', 'section', 'table', 'tbody', 'td', 'tfoot', 'th',
|
|
25
|
+
'thead', 'tr', 'ul', 'video', 'img', 'caption', 'colgroup', 'col',
|
|
26
|
+
'label', 'input',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const VOID_HTML_TAGS = new Set([
|
|
30
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
31
|
+
'meta', 'param', 'source', 'track', 'wbr',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function isBlockHtmlTag(name: string): boolean {
|
|
35
|
+
return BLOCK_HTML_TAGS.has(name.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyInline(text: string): string {
|
|
39
|
+
let out = escapeHtml(text);
|
|
40
|
+
// Inline code first so its content isn't re-formatted.
|
|
41
|
+
out = out.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
|
|
42
|
+
// Bold + italic.
|
|
43
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
44
|
+
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
45
|
+
out = out.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
|
|
46
|
+
out = out.replace(/(?<!_)_([^_\n]+)_(?!_)/g, '<em>$1</em>');
|
|
47
|
+
// Strikethrough.
|
|
48
|
+
out = out.replace(/~~([^~]+)~~/g, '<s>$1</s>');
|
|
49
|
+
// Links — only http/https/mailto/tel; reject `javascript:` etc.
|
|
50
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
|
|
51
|
+
const safe = /^(https?:|mailto:|tel:|\/|#)/i.test(url) ? url : '#';
|
|
52
|
+
return `<a href="${safe}">${label}</a>`;
|
|
53
|
+
});
|
|
54
|
+
// Auto-link bare URLs (idempotent — won't double-link inside <a>).
|
|
55
|
+
out = out.replace(/(?<!href=")(?<!>)\b(https?:\/\/[^\s<]+)(?![^<]*<\/a>)/g, (m) => `<a href="${m}">${m}</a>`);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function markdownToHtml(markdown: string): string {
|
|
60
|
+
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < lines.length) {
|
|
65
|
+
const line = lines[i];
|
|
66
|
+
|
|
67
|
+
// Fenced code block (``` … ```).
|
|
68
|
+
const fenceMatch = line.match(/^```(\w+)?\s*$/);
|
|
69
|
+
if (fenceMatch) {
|
|
70
|
+
i++;
|
|
71
|
+
const code: string[] = [];
|
|
72
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
73
|
+
code.push(lines[i]);
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
i++; // skip closing fence
|
|
77
|
+
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ATX heading.
|
|
82
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
83
|
+
if (heading) {
|
|
84
|
+
const level = heading[1].length;
|
|
85
|
+
out.push(`<h${level}>${applyInline(heading[2].trim())}</h${level}>`);
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Horizontal rule.
|
|
91
|
+
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
92
|
+
out.push('<hr>');
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// GitHub-Flavoured pipe table.
|
|
98
|
+
// | h1 | h2 |
|
|
99
|
+
// | -- | -- |
|
|
100
|
+
// | c1 | c2 |
|
|
101
|
+
if (
|
|
102
|
+
/^\s*\|.*\|\s*$/.test(line) &&
|
|
103
|
+
i + 1 < lines.length &&
|
|
104
|
+
/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1])
|
|
105
|
+
) {
|
|
106
|
+
const split = (row: string) =>
|
|
107
|
+
row
|
|
108
|
+
.trim()
|
|
109
|
+
.replace(/^\|/, '')
|
|
110
|
+
.replace(/\|$/, '')
|
|
111
|
+
.split('|')
|
|
112
|
+
.map((c) => c.trim());
|
|
113
|
+
const headers = split(line);
|
|
114
|
+
i += 2; // header + separator
|
|
115
|
+
const rows: string[][] = [];
|
|
116
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
|
|
117
|
+
rows.push(split(lines[i]));
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
let table = '<table><tbody><tr>';
|
|
121
|
+
for (const h of headers) table += `<th>${applyInline(h)}</th>`;
|
|
122
|
+
table += '</tr>';
|
|
123
|
+
for (const r of rows) {
|
|
124
|
+
table += '<tr>';
|
|
125
|
+
for (const c of r) table += `<td>${applyInline(c)}</td>`;
|
|
126
|
+
table += '</tr>';
|
|
127
|
+
}
|
|
128
|
+
table += '</tbody></table>';
|
|
129
|
+
out.push(table);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Raw HTML block — line that opens with a known block-level tag is
|
|
134
|
+
// passed through verbatim up to its closing tag (or until a blank line).
|
|
135
|
+
// Lets `htmlToMarkdown` round-trip elements that have no markdown
|
|
136
|
+
// analogue (iframe, aside.alert, figure/figcaption, span with style).
|
|
137
|
+
const rawTag = line.match(/^<\s*([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/);
|
|
138
|
+
if (rawTag && isBlockHtmlTag(rawTag[1])) {
|
|
139
|
+
const tag = rawTag[1].toLowerCase();
|
|
140
|
+
const isSelfClosing = /\/\s*>\s*$/.test(line) || VOID_HTML_TAGS.has(tag);
|
|
141
|
+
const buf: string[] = [line];
|
|
142
|
+
i++;
|
|
143
|
+
if (!isSelfClosing) {
|
|
144
|
+
const closeRe = new RegExp(`</\\s*${tag}\\s*>`, 'i');
|
|
145
|
+
while (i < lines.length && !closeRe.test(lines[i - 1])) {
|
|
146
|
+
if (lines[i].trim() === '') break;
|
|
147
|
+
buf.push(lines[i]);
|
|
148
|
+
if (closeRe.test(lines[i])) {
|
|
149
|
+
i++;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
out.push(buf.join('\n'));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Blockquote — group consecutive `> ` lines.
|
|
160
|
+
if (/^>\s?/.test(line)) {
|
|
161
|
+
const buf: string[] = [];
|
|
162
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
163
|
+
buf.push(lines[i].replace(/^>\s?/, ''));
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
out.push(`<blockquote><p>${applyInline(buf.join(' '))}</p></blockquote>`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Task list — `- [ ]` / `- [x]`.
|
|
171
|
+
if (/^\s*[-*]\s+\[[ xX]\]\s+/.test(line)) {
|
|
172
|
+
out.push('<ul data-type="taskList">');
|
|
173
|
+
while (i < lines.length && /^\s*[-*]\s+\[[ xX]\]\s+/.test(lines[i])) {
|
|
174
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/);
|
|
175
|
+
if (!m) break;
|
|
176
|
+
const checked = m[1].toLowerCase() === 'x';
|
|
177
|
+
out.push(
|
|
178
|
+
`<li data-type="taskItem" data-checked="${checked}"><label><input type="checkbox" ${checked ? 'checked' : ''}><span></span></label><div><p>${applyInline(m[2])}</p></div></li>`,
|
|
179
|
+
);
|
|
180
|
+
i++;
|
|
181
|
+
}
|
|
182
|
+
out.push('</ul>');
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Unordered list.
|
|
187
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
188
|
+
out.push('<ul>');
|
|
189
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
|
190
|
+
const m = lines[i].match(/^\s*[-*+]\s+(.*)$/);
|
|
191
|
+
if (!m) break;
|
|
192
|
+
out.push(`<li><p>${applyInline(m[1])}</p></li>`);
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
out.push('</ul>');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Ordered list.
|
|
200
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
201
|
+
out.push('<ol>');
|
|
202
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
|
203
|
+
const m = lines[i].match(/^\s*\d+\.\s+(.*)$/);
|
|
204
|
+
if (!m) break;
|
|
205
|
+
out.push(`<li><p>${applyInline(m[1])}</p></li>`);
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
out.push('</ol>');
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Blank line.
|
|
213
|
+
if (line.trim() === '') {
|
|
214
|
+
i++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Paragraph — coalesce consecutive non-blank lines (single `\n` becomes a space).
|
|
219
|
+
const para: string[] = [line];
|
|
220
|
+
i++;
|
|
221
|
+
while (
|
|
222
|
+
i < lines.length &&
|
|
223
|
+
lines[i].trim() !== '' &&
|
|
224
|
+
!/^(#{1,6}\s|>|```|\s*[-*+]\s|\s*\d+\.\s|\s*(-{3,}|\*{3,}|_{3,})\s*$|\s*\|)/.test(lines[i]) &&
|
|
225
|
+
!/^<\s*[a-zA-Z]/.test(lines[i])
|
|
226
|
+
) {
|
|
227
|
+
para.push(lines[i]);
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
out.push(`<p>${applyInline(para.join(' '))}</p>`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return out.join('');
|
|
234
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-processes pasted/imported HTML so that complex table structures
|
|
3
|
+
* survive TipTap's table parser.
|
|
4
|
+
*
|
|
5
|
+
* TipTap's schema is:
|
|
6
|
+
* table → tableRow → (tableHeader | tableCell)
|
|
7
|
+
*
|
|
8
|
+
* It does NOT model `<caption>`, `<colgroup>`/`<col>`, or `<tfoot>` as
|
|
9
|
+
* distinct nodes. Without preprocessing, those elements silently
|
|
10
|
+
* disappear during `editor.commands.setContent`.
|
|
11
|
+
*
|
|
12
|
+
* Transformations applied:
|
|
13
|
+
* - `<caption>` is lifted out of the table and rendered as a
|
|
14
|
+
* `<p><strong><em>…</em></strong></p>` placed immediately before the
|
|
15
|
+
* table (visually equivalent to a caption, schema-compatible).
|
|
16
|
+
* - `<tfoot>` rows are appended to `<tbody>` (TipTap doesn't care
|
|
17
|
+
* about the section wrapper — only about the `<tr>` children).
|
|
18
|
+
* - `<colgroup>` / `<col>` are stripped (TipTap manages column sizing
|
|
19
|
+
* dynamically via `Table.configure({ resizable: true })`).
|
|
20
|
+
* - `<th scope="row">` is preserved as `<th>` (TipTap renders any
|
|
21
|
+
* `<th>` as a `tableHeader` regardless of scope).
|
|
22
|
+
*
|
|
23
|
+
* Anything outside `<table>` is left untouched.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeTableHtml(html: string): string {
|
|
26
|
+
if (typeof window === 'undefined' || typeof DOMParser === 'undefined') {
|
|
27
|
+
return html;
|
|
28
|
+
}
|
|
29
|
+
const parsed = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html');
|
|
30
|
+
const root = parsed.body.firstElementChild;
|
|
31
|
+
if (!root) return html;
|
|
32
|
+
|
|
33
|
+
const tables = Array.from(root.querySelectorAll('table'));
|
|
34
|
+
for (const table of tables) {
|
|
35
|
+
// 1. Caption → paragraph injected right before the table.
|
|
36
|
+
const caption = table.querySelector(':scope > caption');
|
|
37
|
+
if (caption) {
|
|
38
|
+
const text = caption.textContent?.trim() ?? '';
|
|
39
|
+
if (text) {
|
|
40
|
+
const captionPara = parsed.createElement('p');
|
|
41
|
+
const strong = parsed.createElement('strong');
|
|
42
|
+
const em = parsed.createElement('em');
|
|
43
|
+
em.textContent = text;
|
|
44
|
+
strong.appendChild(em);
|
|
45
|
+
captionPara.appendChild(strong);
|
|
46
|
+
table.parentNode?.insertBefore(captionPara, table);
|
|
47
|
+
}
|
|
48
|
+
caption.remove();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. <tfoot> rows → appended to <tbody>.
|
|
52
|
+
const tfoots = Array.from(table.querySelectorAll(':scope > tfoot'));
|
|
53
|
+
for (const tfoot of tfoots) {
|
|
54
|
+
let tbody = table.querySelector(':scope > tbody');
|
|
55
|
+
if (!tbody) {
|
|
56
|
+
tbody = parsed.createElement('tbody');
|
|
57
|
+
table.appendChild(tbody);
|
|
58
|
+
}
|
|
59
|
+
while (tfoot.firstElementChild) {
|
|
60
|
+
tbody.appendChild(tfoot.firstElementChild);
|
|
61
|
+
}
|
|
62
|
+
tfoot.remove();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. <colgroup>/<col> stripped — handled by Table.configure({ resizable }).
|
|
66
|
+
table.querySelectorAll(':scope > colgroup').forEach((cg) => cg.remove());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Safe to use innerHTML here because:
|
|
70
|
+
// 1. The input HTML is parsed fresh from a local string (not user-controlled)
|
|
71
|
+
// 2. Manipulations are DOM API only (no string injection)
|
|
72
|
+
// 3. Output is always passed through sanitizeEditorHtml before editor render
|
|
73
|
+
return root.innerHTML;
|
|
74
|
+
}
|