@ceedcv-maya/shared-editor-react 0.9.0 → 0.11.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 +4 -1
- package/src/components/EditorContentHtml.tsx +1 -0
- package/src/components/EditorContentJson.tsx +29 -0
- package/src/components/EditorIcons.tsx +368 -0
- package/src/components/EditorToolbar.tsx +137 -52
- package/src/components/EditorToolbarGroups.tsx +53 -29
- package/src/components/MayaEditor.tsx +36 -0
- package/src/index.ts +5 -4
- package/src/lib/buildToolbarLabels.test.ts +43 -0
- package/src/lib/buildToolbarLabels.ts +38 -0
- package/src/lib/looksLikeMarkdown.test.ts +36 -0
- package/src/lib/looksLikeMarkdown.ts +37 -0
- package/src/lib/markdownIngestion.test.ts +44 -0
- package/src/lib/markdownToHtml.test.ts +25 -0
- package/src/lib/markdownToHtml.ts +4 -1
- package/src/lib/renderTiptapJson.test.ts +82 -0
- package/src/lib/renderTiptapJson.ts +72 -0
- package/src/lib/tableMenuActions.test.ts +88 -0
- package/src/lib/tableMenuActions.ts +99 -0
- package/src/lib/tiptapContentSemantics.ts +1 -1
- package/src/mammoth-browser.d.ts +3 -0
- package/src/parity/fingerprint.ts +75 -0
- package/src/parity/parity.test.ts +51 -0
- package/src/styles/maya-content.css +160 -0
- package/src/styles/maya-editor.css +40 -0
- package/src/i18n/en.json +0 -54
- package/src/i18n/es.json +0 -54
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderTiptapJsonToHtml } from './renderTiptapJson';
|
|
3
|
+
import type { TiptapDoc } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('renderTiptapJsonToHtml', () => {
|
|
6
|
+
it('renders a wrapped doc', () => {
|
|
7
|
+
const doc: TiptapDoc = {
|
|
8
|
+
type: 'doc',
|
|
9
|
+
content: [
|
|
10
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'hola' }] },
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
const html = renderTiptapJsonToHtml(doc);
|
|
14
|
+
expect(html).toContain('<p');
|
|
15
|
+
expect(html).toContain('hola');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('accepts a bare content array (the shape MayaEditor emits)', () => {
|
|
19
|
+
const html = renderTiptapJsonToHtml([
|
|
20
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Sección' }] },
|
|
21
|
+
]);
|
|
22
|
+
expect(html).toMatch(/<h2[^>]*>.*Sección.*<\/h2>/s);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders bold marks as <strong>', () => {
|
|
26
|
+
const html = renderTiptapJsonToHtml([
|
|
27
|
+
{
|
|
28
|
+
type: 'paragraph',
|
|
29
|
+
content: [
|
|
30
|
+
{ type: 'text', text: 'plain ' },
|
|
31
|
+
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
expect(html).toMatch(/<strong[^>]*>bold<\/strong>/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders a code block without interpreting markdown inside it', () => {
|
|
39
|
+
const html = renderTiptapJsonToHtml([
|
|
40
|
+
{ type: 'codeBlock', content: [{ type: 'text', text: '## not a heading' }] },
|
|
41
|
+
]);
|
|
42
|
+
expect(html).toContain('<pre');
|
|
43
|
+
expect(html).toContain('## not a heading');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does NOT turn literal markdown text into formatting (data, not render)', () => {
|
|
47
|
+
// Regression guard: markdown stored as plain text must render literally —
|
|
48
|
+
// this is why the fix lives at ingestion, not in the renderer.
|
|
49
|
+
const html = renderTiptapJsonToHtml([
|
|
50
|
+
{ type: 'paragraph', content: [{ type: 'text', text: '## Programa **negrita**' }] },
|
|
51
|
+
]);
|
|
52
|
+
expect(html).toContain('## Programa **negrita**');
|
|
53
|
+
expect(html).not.toContain('<h2');
|
|
54
|
+
expect(html).not.toContain('<strong>');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders a table', () => {
|
|
58
|
+
const html = renderTiptapJsonToHtml([
|
|
59
|
+
{
|
|
60
|
+
type: 'table',
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: 'tableRow',
|
|
64
|
+
content: [
|
|
65
|
+
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
|
|
66
|
+
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
expect(html).toContain('<table');
|
|
73
|
+
expect(html).toContain('A');
|
|
74
|
+
expect(html).toContain('B');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns empty string for null/empty content', () => {
|
|
78
|
+
expect(renderTiptapJsonToHtml(null)).toBe('');
|
|
79
|
+
expect(renderTiptapJsonToHtml([])).toBe('');
|
|
80
|
+
expect(renderTiptapJsonToHtml(undefined)).toBe('');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a stored TipTap/ProseMirror JSON document to an HTML string using
|
|
3
|
+
* TipTap's official static renderer.
|
|
4
|
+
*
|
|
5
|
+
* This replaces hand-rolled JSON→HTML walkers (which drift from the live
|
|
6
|
+
* editor and from the server-side `TiptapHtmlRenderer.php`). The static
|
|
7
|
+
* renderer builds the schema from the canonical extension set and runs each
|
|
8
|
+
* extension's `renderHTML`, so custom nodes (AlertBlock, IframeBlock, …) and
|
|
9
|
+
* marks render exactly as the editor defines them — a single source of truth
|
|
10
|
+
* on the JS side.
|
|
11
|
+
*
|
|
12
|
+
* The returned HTML is NOT sanitised; pass it through `EditorContentHtml`
|
|
13
|
+
* (DOMPurify) or use the `EditorContentJson` component for read-only views.
|
|
14
|
+
*
|
|
15
|
+
* Accepts both shapes persisted by the platform:
|
|
16
|
+
* - a wrapped doc `{ type: 'doc', content: [...] }`
|
|
17
|
+
* - a bare content array (`MayaEditor` emits `onChange(doc.content)`)
|
|
18
|
+
*
|
|
19
|
+
* Static-renderer caveats (per TipTap docs): no editor instance is created, so
|
|
20
|
+
* `addProseMirrorPlugins`/`onCreate`/transaction hooks do not run, and the
|
|
21
|
+
* output can differ slightly from the live editor for nodeView-backed nodes.
|
|
22
|
+
*/
|
|
23
|
+
import { renderToHTMLString } from '@tiptap/static-renderer';
|
|
24
|
+
import type { Extensions } from '@tiptap/core';
|
|
25
|
+
|
|
26
|
+
import { buildMayaEditorExtensions } from './editorExtensions';
|
|
27
|
+
import type { TiptapDoc, TiptapNode } from '../types';
|
|
28
|
+
|
|
29
|
+
function looksLikeTiptapDoc(value: unknown): value is TiptapDoc {
|
|
30
|
+
return (
|
|
31
|
+
!!value &&
|
|
32
|
+
typeof value === 'object' &&
|
|
33
|
+
!Array.isArray(value) &&
|
|
34
|
+
(value as { type?: unknown }).type === 'doc' &&
|
|
35
|
+
Array.isArray((value as { content?: unknown }).content)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function looksLikeTiptapContentArray(value: unknown): value is TiptapNode[] {
|
|
40
|
+
return (
|
|
41
|
+
Array.isArray(value) &&
|
|
42
|
+
value.length > 0 &&
|
|
43
|
+
value.every((n) => !!n && typeof n === 'object')
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalise the accepted input shapes to a wrapped doc, or `null` if empty. */
|
|
48
|
+
function toDoc(content: unknown): TiptapDoc | null {
|
|
49
|
+
if (content == null) return null;
|
|
50
|
+
if (looksLikeTiptapDoc(content)) return content;
|
|
51
|
+
if (looksLikeTiptapContentArray(content)) {
|
|
52
|
+
return { type: 'doc', content };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderTiptapJsonToHtml(
|
|
58
|
+
content: TiptapDoc | TiptapNode[] | unknown,
|
|
59
|
+
extensions?: Extensions,
|
|
60
|
+
): string {
|
|
61
|
+
const doc = toDoc(content);
|
|
62
|
+
if (!doc) return '';
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return renderToHTMLString({
|
|
66
|
+
content: doc,
|
|
67
|
+
extensions: extensions ?? buildMayaEditorExtensions('full'),
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import { buildMayaEditorExtensions } from './editorExtensions';
|
|
4
|
+
import { tableMenuActions } from './tableMenuActions';
|
|
5
|
+
|
|
6
|
+
function makeEditor(): Editor {
|
|
7
|
+
return new Editor({ extensions: buildMayaEditorExtensions('full') });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Count tableRow / cell nodes in the current doc. */
|
|
11
|
+
function tableShape(editor: Editor): { rows: number; cols: number } {
|
|
12
|
+
let rows = 0;
|
|
13
|
+
let cols = 0;
|
|
14
|
+
editor.state.doc.descendants((node) => {
|
|
15
|
+
if (node.type.name === 'tableRow') {
|
|
16
|
+
rows += 1;
|
|
17
|
+
if (rows === 1) cols = node.childCount; // cells in first row
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
return { rows, cols };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let current: Editor | null = null;
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
current?.destroy();
|
|
27
|
+
current = null;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('tableMenuActions', () => {
|
|
31
|
+
it('returns all actions disabled when the selection is not in a table', () => {
|
|
32
|
+
const editor = (current = makeEditor());
|
|
33
|
+
editor.commands.setContent('<p>plain paragraph</p>');
|
|
34
|
+
const actions = tableMenuActions(editor);
|
|
35
|
+
expect(actions.length).toBe(8);
|
|
36
|
+
expect(actions.every((a) => a.disabled)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('enables actions and adds a column when the cursor is inside a table', () => {
|
|
40
|
+
const editor = (current = makeEditor());
|
|
41
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
42
|
+
const before = tableShape(editor);
|
|
43
|
+
|
|
44
|
+
const addCol = tableMenuActions(editor).find((a) => a.key === 'addColumnAfter')!;
|
|
45
|
+
expect(addCol.disabled).toBe(false);
|
|
46
|
+
addCol.run();
|
|
47
|
+
|
|
48
|
+
expect(tableShape(editor).cols).toBe(before.cols + 1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('adds a row', () => {
|
|
52
|
+
const editor = (current = makeEditor());
|
|
53
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
54
|
+
const before = tableShape(editor);
|
|
55
|
+
|
|
56
|
+
tableMenuActions(editor).find((a) => a.key === 'addRowAfter')!.run();
|
|
57
|
+
|
|
58
|
+
expect(tableShape(editor).rows).toBe(before.rows + 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('deletes a column', () => {
|
|
62
|
+
const editor = (current = makeEditor());
|
|
63
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 3, withHeaderRow: true }).run();
|
|
64
|
+
const before = tableShape(editor);
|
|
65
|
+
|
|
66
|
+
tableMenuActions(editor).find((a) => a.key === 'deleteColumn')!.run();
|
|
67
|
+
|
|
68
|
+
expect(tableShape(editor).cols).toBe(before.cols - 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('deletes the whole table', () => {
|
|
72
|
+
const editor = (current = makeEditor());
|
|
73
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
74
|
+
expect(editor.isActive('table')).toBe(true);
|
|
75
|
+
|
|
76
|
+
tableMenuActions(editor).find((a) => a.key === 'deleteTable')!.run();
|
|
77
|
+
|
|
78
|
+
expect(editor.isActive('table')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses provided labels for tooltips', () => {
|
|
82
|
+
const editor = (current = makeEditor());
|
|
83
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
84
|
+
const actions = tableMenuActions(editor, { tableAddColumnAfter: 'Insertar columna a la derecha' });
|
|
85
|
+
const addCol = actions.find((a) => a.key === 'addColumnAfter')!;
|
|
86
|
+
expect(addCol.title).toBe('Insertar columna a la derecha');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table editing actions exposed by the contextual table toolbar.
|
|
3
|
+
*
|
|
4
|
+
* Pure mapping from an editor instance + labels to a flat, ordered list of
|
|
5
|
+
* buttons. Kept separate from the React component so the command wiring and
|
|
6
|
+
* `can()` gating can be unit-tested against a headless editor.
|
|
7
|
+
*/
|
|
8
|
+
import type { Editor } from '@tiptap/core';
|
|
9
|
+
import type { ToolbarLabels } from '../components/EditorToolbar';
|
|
10
|
+
import type { EditorIconName } from '../components/EditorIcons';
|
|
11
|
+
|
|
12
|
+
export interface TableMenuAction {
|
|
13
|
+
/** Stable key for React lists. */
|
|
14
|
+
key: string;
|
|
15
|
+
/** Tooltip / aria-label. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Name of the SVG icon shown on the button. */
|
|
18
|
+
icon: EditorIconName;
|
|
19
|
+
/** Runs the command. */
|
|
20
|
+
run: () => void;
|
|
21
|
+
/** True when the command can't apply to the current selection. */
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
/** When true, render a visual separator before this action. */
|
|
24
|
+
separatorBefore?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the ordered list of table actions for the current editor state.
|
|
29
|
+
* Recomputed on every render so `disabled` reflects the live selection.
|
|
30
|
+
*/
|
|
31
|
+
export function tableMenuActions(
|
|
32
|
+
editor: Editor,
|
|
33
|
+
labels?: Partial<ToolbarLabels>,
|
|
34
|
+
): TableMenuAction[] {
|
|
35
|
+
const L = labels ?? {};
|
|
36
|
+
const chain = () => editor.chain().focus();
|
|
37
|
+
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
key: 'addColumnBefore',
|
|
41
|
+
title: L.tableAddColumnBefore ?? 'Insert column left',
|
|
42
|
+
icon: 'columnAddBefore',
|
|
43
|
+
run: () => chain().addColumnBefore().run(),
|
|
44
|
+
disabled: !editor.can().addColumnBefore(),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'addColumnAfter',
|
|
48
|
+
title: L.tableAddColumnAfter ?? 'Insert column right',
|
|
49
|
+
icon: 'columnAddAfter',
|
|
50
|
+
run: () => chain().addColumnAfter().run(),
|
|
51
|
+
disabled: !editor.can().addColumnAfter(),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: 'addRowBefore',
|
|
55
|
+
title: L.tableAddRowBefore ?? 'Insert row above',
|
|
56
|
+
icon: 'rowAddBefore',
|
|
57
|
+
run: () => chain().addRowBefore().run(),
|
|
58
|
+
disabled: !editor.can().addRowBefore(),
|
|
59
|
+
separatorBefore: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'addRowAfter',
|
|
63
|
+
title: L.tableAddRowAfter ?? 'Insert row below',
|
|
64
|
+
icon: 'rowAddAfter',
|
|
65
|
+
run: () => chain().addRowAfter().run(),
|
|
66
|
+
disabled: !editor.can().addRowAfter(),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'deleteColumn',
|
|
70
|
+
title: L.tableDeleteColumn ?? 'Delete column',
|
|
71
|
+
icon: 'columnDelete',
|
|
72
|
+
run: () => chain().deleteColumn().run(),
|
|
73
|
+
disabled: !editor.can().deleteColumn(),
|
|
74
|
+
separatorBefore: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: 'deleteRow',
|
|
78
|
+
title: L.tableDeleteRow ?? 'Delete row',
|
|
79
|
+
icon: 'rowDelete',
|
|
80
|
+
run: () => chain().deleteRow().run(),
|
|
81
|
+
disabled: !editor.can().deleteRow(),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'toggleHeaderRow',
|
|
85
|
+
title: L.tableToggleHeaderRow ?? 'Toggle header row',
|
|
86
|
+
icon: 'headerRow',
|
|
87
|
+
run: () => chain().toggleHeaderRow().run(),
|
|
88
|
+
disabled: !editor.can().toggleHeaderRow(),
|
|
89
|
+
separatorBefore: true,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: 'deleteTable',
|
|
93
|
+
title: L.tableDelete ?? 'Delete table',
|
|
94
|
+
icon: 'tableDelete',
|
|
95
|
+
run: () => chain().deleteTable().run(),
|
|
96
|
+
disabled: !editor.can().deleteTable(),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
@@ -14,7 +14,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function asNode(value: unknown): TiptapNode | null {
|
|
17
|
-
return isRecord(value) && typeof value.type === 'string' ? (value as TiptapNode) : null;
|
|
17
|
+
return isRecord(value) && typeof value.type === 'string' ? (value as unknown as TiptapNode) : null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function inlineTextLength(nodes: unknown): number {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic fingerprint of rendered editor HTML, used by the renderer parity
|
|
3
|
+
* oracle. MUST stay logically identical to the PHP version in
|
|
4
|
+
* `shared-editor-laravel/tests/Support/Fingerprint.php`.
|
|
5
|
+
*
|
|
6
|
+
* Captures content + structure (what must agree between the CSR static renderer
|
|
7
|
+
* and the server-side `TiptapHtmlRenderer`), deliberately ignoring cosmetic
|
|
8
|
+
* markup (wrappers, data-* attributes, inline-style formatting).
|
|
9
|
+
*/
|
|
10
|
+
export interface HtmlFingerprint {
|
|
11
|
+
text: string;
|
|
12
|
+
headings: number[];
|
|
13
|
+
links: string[];
|
|
14
|
+
images: string[];
|
|
15
|
+
strong: number;
|
|
16
|
+
em: number;
|
|
17
|
+
u: number;
|
|
18
|
+
s: number;
|
|
19
|
+
code: number;
|
|
20
|
+
ul: number;
|
|
21
|
+
ol: number;
|
|
22
|
+
li: number;
|
|
23
|
+
pre: number;
|
|
24
|
+
blockquote: number;
|
|
25
|
+
hr: number;
|
|
26
|
+
table: number;
|
|
27
|
+
tr: number;
|
|
28
|
+
th: number;
|
|
29
|
+
td: number;
|
|
30
|
+
aside: number;
|
|
31
|
+
checkbox: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function fingerprintHtml(html: string): HtmlFingerprint {
|
|
35
|
+
const doc = new DOMParser().parseFromString(`<div id="__root">${html}</div>`, 'text/html');
|
|
36
|
+
const root = doc.getElementById('__root');
|
|
37
|
+
if (!root) {
|
|
38
|
+
throw new Error('fingerprintHtml: could not parse HTML');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const count = (sel: string): number => root.querySelectorAll(sel).length;
|
|
42
|
+
const attrs = (sel: string, attr: string): string[] =>
|
|
43
|
+
Array.from(root.querySelectorAll(sel))
|
|
44
|
+
.map((el) => el.getAttribute(attr) ?? '')
|
|
45
|
+
.sort();
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
// Whitespace removed entirely: inter-element spacing (e.g. the space PHP
|
|
49
|
+
// emits after a task checkbox, or block concatenation) is cosmetic and must
|
|
50
|
+
// not break parity — only the sequence of visible characters matters.
|
|
51
|
+
text: (root.textContent ?? '').replace(/\s+/g, ''),
|
|
52
|
+
headings: Array.from(root.querySelectorAll('h1,h2,h3,h4,h5,h6')).map((h) =>
|
|
53
|
+
Number(h.tagName.charAt(1)),
|
|
54
|
+
),
|
|
55
|
+
links: attrs('a[href]', 'href'),
|
|
56
|
+
images: attrs('img[src]', 'src'),
|
|
57
|
+
strong: count('strong'),
|
|
58
|
+
em: count('em'),
|
|
59
|
+
u: count('u'),
|
|
60
|
+
s: count('s'),
|
|
61
|
+
code: count('code'),
|
|
62
|
+
ul: count('ul'),
|
|
63
|
+
ol: count('ol'),
|
|
64
|
+
li: count('li'),
|
|
65
|
+
pre: count('pre'),
|
|
66
|
+
blockquote: count('blockquote'),
|
|
67
|
+
hr: count('hr'),
|
|
68
|
+
table: count('table'),
|
|
69
|
+
tr: count('tr'),
|
|
70
|
+
th: count('th'),
|
|
71
|
+
td: count('td'),
|
|
72
|
+
aside: count('aside'),
|
|
73
|
+
checkbox: count('input[type="checkbox"]'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parity oracle (JS side).
|
|
3
|
+
*
|
|
4
|
+
* Renders shared TipTap fixtures via the static renderer and computes a
|
|
5
|
+
* SEMANTIC fingerprint (visible text + structural counts + headings/links/
|
|
6
|
+
* images). The committed `fingerprints.json` is the parity contract; the PHP
|
|
7
|
+
* test (`TiptapRendererParityTest`) asserts `TiptapHtmlRenderer` produces the
|
|
8
|
+
* SAME fingerprint. Cosmetic differences (colgroup, data-*, <mark> vs <span>,
|
|
9
|
+
* target/rel, inline-style spacing) are intentionally ignored — only content
|
|
10
|
+
* and structure must agree, since CSR previews and server PDFs can style
|
|
11
|
+
* differently.
|
|
12
|
+
*
|
|
13
|
+
* Regenerate the contract after intentional changes:
|
|
14
|
+
* UPDATE_FP=1 pnpm vitest run src/parity/parity.test.ts
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { renderTiptapJsonToHtml } from '../lib/renderTiptapJson';
|
|
20
|
+
import { fingerprintHtml } from './fingerprint';
|
|
21
|
+
|
|
22
|
+
const FIXTURES_DIR = resolve(
|
|
23
|
+
process.cwd(),
|
|
24
|
+
'../../php/shared-editor-laravel/tests/fixtures/tiptap',
|
|
25
|
+
);
|
|
26
|
+
const fixtures = JSON.parse(
|
|
27
|
+
readFileSync(resolve(FIXTURES_DIR, 'fixtures.json'), 'utf8'),
|
|
28
|
+
) as Record<string, unknown>;
|
|
29
|
+
const fpPath = resolve(FIXTURES_DIR, 'fingerprints.json');
|
|
30
|
+
|
|
31
|
+
describe('renderer parity (JS / static-renderer)', () => {
|
|
32
|
+
if (process.env.UPDATE_FP) {
|
|
33
|
+
it('regenerates the fingerprint contract', () => {
|
|
34
|
+
const out: Record<string, unknown> = {};
|
|
35
|
+
for (const [name, doc] of Object.entries(fixtures)) {
|
|
36
|
+
out[name] = fingerprintHtml(renderTiptapJsonToHtml(doc));
|
|
37
|
+
}
|
|
38
|
+
writeFileSync(fpPath, `${JSON.stringify(out, null, 2)}\n`);
|
|
39
|
+
expect(Object.keys(out).length).toBe(Object.keys(fixtures).length);
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const expected = JSON.parse(readFileSync(fpPath, 'utf8')) as Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
for (const [name, doc] of Object.entries(fixtures)) {
|
|
47
|
+
it(`fixture "${name}" matches the contract`, () => {
|
|
48
|
+
expect(fingerprintHtml(renderTiptapJsonToHtml(doc))).toEqual(expected[name]);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only content typography for rendered TipTap/ProseMirror HTML.
|
|
3
|
+
*
|
|
4
|
+
* The live editor styles its content via `.maya-editor-wrapper .ProseMirror …`
|
|
5
|
+
* (see maya-editor.css), which only matches inside the editor view. Read-only
|
|
6
|
+
* previews (`EditorContentHtml` / `EditorContentJson`) render a plain
|
|
7
|
+
* `.maya-editor-content` container with no `.ProseMirror`, so without these
|
|
8
|
+
* rules headings, lists, quotes, code and tables collapse to base size —
|
|
9
|
+
* especially under Tailwind Preflight, which resets `h1`–`h6` to inherit.
|
|
10
|
+
*
|
|
11
|
+
* These rules mirror the editor's scale but are scoped to `.maya-editor-content`
|
|
12
|
+
* (specificity 0,1,1) — strictly lower than the editor's `.maya-editor-wrapper
|
|
13
|
+
* .ProseMirror h1` (0,2,1) — so the live editor's appearance is unchanged while
|
|
14
|
+
* every read-only consumer gets matching typography.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
.maya-editor-content {
|
|
18
|
+
font-size: 15px;
|
|
19
|
+
line-height: 1.6;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.maya-editor-content p { margin: 0.5em 0; }
|
|
23
|
+
.maya-editor-content p:first-child { margin-top: 0; }
|
|
24
|
+
.maya-editor-content p:last-child { margin-bottom: 0; }
|
|
25
|
+
.maya-editor-content > :first-child { margin-top: 0; }
|
|
26
|
+
.maya-editor-content > :last-child { margin-bottom: 0; }
|
|
27
|
+
|
|
28
|
+
.maya-editor-content h1 { font-size: 1.7em; font-weight: 700; margin: 0.8em 0 0.3em; line-height: 1.25; }
|
|
29
|
+
.maya-editor-content h2 { font-size: 1.4em; font-weight: 700; margin: 0.8em 0 0.3em; line-height: 1.3; }
|
|
30
|
+
.maya-editor-content h3 { font-size: 1.2em; font-weight: 600; margin: 0.7em 0 0.25em; }
|
|
31
|
+
.maya-editor-content h4 { font-size: 1.05em; font-weight: 600; margin: 0.6em 0 0.2em; }
|
|
32
|
+
.maya-editor-content h5 { font-size: 1em; font-weight: 600; margin: 0.6em 0 0.2em; }
|
|
33
|
+
.maya-editor-content h6 { font-size: 0.9em; font-weight: 600; margin: 0.6em 0 0.2em; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
34
|
+
|
|
35
|
+
.maya-editor-content ul,
|
|
36
|
+
.maya-editor-content ol {
|
|
37
|
+
padding-left: 1.4em;
|
|
38
|
+
margin: 0.5em 0;
|
|
39
|
+
}
|
|
40
|
+
.maya-editor-content ul { list-style: disc; }
|
|
41
|
+
.maya-editor-content ol { list-style: decimal; }
|
|
42
|
+
.maya-editor-content li { margin: 0.2em 0; }
|
|
43
|
+
.maya-editor-content li > p { margin: 0; }
|
|
44
|
+
|
|
45
|
+
/* Task lists (taskList / taskItem) */
|
|
46
|
+
.maya-editor-content ul[data-type="taskList"] {
|
|
47
|
+
list-style: none;
|
|
48
|
+
padding-left: 0.25em;
|
|
49
|
+
}
|
|
50
|
+
.maya-editor-content li[data-type="taskItem"] {
|
|
51
|
+
display: flex;
|
|
52
|
+
gap: 6px;
|
|
53
|
+
align-items: flex-start;
|
|
54
|
+
list-style: none;
|
|
55
|
+
}
|
|
56
|
+
.maya-editor-content li[data-type="taskItem"] > label { flex-shrink: 0; margin-top: 0.2em; }
|
|
57
|
+
.maya-editor-content li[data-type="taskItem"] > div { flex: 1; }
|
|
58
|
+
|
|
59
|
+
/* Indent attribute (Indent extension) */
|
|
60
|
+
.maya-editor-content [data-indent="1"] { margin-left: 2em; }
|
|
61
|
+
.maya-editor-content [data-indent="2"] { margin-left: 4em; }
|
|
62
|
+
.maya-editor-content [data-indent="3"] { margin-left: 6em; }
|
|
63
|
+
.maya-editor-content [data-indent="4"] { margin-left: 8em; }
|
|
64
|
+
.maya-editor-content [data-indent="5"] { margin-left: 10em; }
|
|
65
|
+
.maya-editor-content [data-indent="6"] { margin-left: 12em; }
|
|
66
|
+
.maya-editor-content [data-indent="7"] { margin-left: 14em; }
|
|
67
|
+
.maya-editor-content [data-indent="8"] { margin-left: 16em; }
|
|
68
|
+
|
|
69
|
+
.maya-editor-content blockquote {
|
|
70
|
+
border-left: 3px solid var(--maya-editor-border, #d6d6df);
|
|
71
|
+
margin: 0.75em 0;
|
|
72
|
+
padding-left: 1em;
|
|
73
|
+
color: rgba(120, 120, 130, 0.95);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.maya-editor-content code {
|
|
77
|
+
background: rgba(127, 127, 140, 0.18);
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
padding: 0.05em 0.4em;
|
|
80
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
81
|
+
font-size: 0.92em;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.maya-editor-content pre {
|
|
85
|
+
background: rgba(127, 127, 140, 0.12);
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
padding: 0.8em 1em;
|
|
88
|
+
margin: 0.75em 0;
|
|
89
|
+
overflow-x: auto;
|
|
90
|
+
}
|
|
91
|
+
.maya-editor-content pre code {
|
|
92
|
+
background: transparent;
|
|
93
|
+
padding: 0;
|
|
94
|
+
font-size: 0.9em;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.maya-editor-content hr {
|
|
98
|
+
border: 0;
|
|
99
|
+
border-top: 1px solid var(--maya-editor-border, #d6d6df);
|
|
100
|
+
margin: 1.2em 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.maya-editor-content a {
|
|
104
|
+
color: var(--maya-editor-link, #7c3aed);
|
|
105
|
+
text-decoration: underline;
|
|
106
|
+
text-underline-offset: 2px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.maya-editor-content table {
|
|
110
|
+
border-collapse: collapse;
|
|
111
|
+
margin: 0.8em 0;
|
|
112
|
+
width: 100%;
|
|
113
|
+
}
|
|
114
|
+
.maya-editor-content table th,
|
|
115
|
+
.maya-editor-content table td {
|
|
116
|
+
border: 1px solid var(--maya-editor-border, #d6d6df);
|
|
117
|
+
padding: 0.5em 0.7em;
|
|
118
|
+
vertical-align: top;
|
|
119
|
+
}
|
|
120
|
+
.maya-editor-content table th {
|
|
121
|
+
background: rgba(127, 127, 140, 0.08);
|
|
122
|
+
text-align: left;
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.maya-editor-content img {
|
|
127
|
+
max-width: 100%;
|
|
128
|
+
height: auto;
|
|
129
|
+
border-radius: 4px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.maya-editor-content aside.alert {
|
|
133
|
+
border-left: 4px solid var(--maya-editor-link, #7c3aed);
|
|
134
|
+
border-radius: 4px;
|
|
135
|
+
background: rgba(124, 58, 237, 0.06);
|
|
136
|
+
padding: 0.75em 1em;
|
|
137
|
+
margin: 0.75em 0;
|
|
138
|
+
}
|
|
139
|
+
.maya-editor-content aside.alert-warning { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.08); }
|
|
140
|
+
.maya-editor-content aside.alert-success { border-left-color: #10b981; background: rgba(16, 185, 129, 0.08); }
|
|
141
|
+
.maya-editor-content aside.alert-danger { border-left-color: #ef4444; background: rgba(239, 68, 68, 0.08); }
|
|
142
|
+
|
|
143
|
+
.maya-editor-content iframe {
|
|
144
|
+
display: block;
|
|
145
|
+
max-width: 100%;
|
|
146
|
+
margin: 0.5em 0;
|
|
147
|
+
border: 1px solid var(--maya-editor-border, #d6d6df);
|
|
148
|
+
border-radius: 6px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Page break (PDF/export semantics) — show a subtle separator in previews. */
|
|
152
|
+
.maya-editor-content .page-break,
|
|
153
|
+
.maya-editor-content [data-page-break],
|
|
154
|
+
.maya-editor-content hr[data-type="pageBreak"] {
|
|
155
|
+
border: 0;
|
|
156
|
+
border-top: 2px dashed var(--maya-editor-border, #c9c9d2);
|
|
157
|
+
margin: 1.5em 0;
|
|
158
|
+
page-break-after: always;
|
|
159
|
+
break-after: page;
|
|
160
|
+
}
|
|
@@ -86,6 +86,46 @@
|
|
|
86
86
|
/* Hidden file inputs reused by the toolbar (image, .docx) */
|
|
87
87
|
.maya-editor-hidden-input { display: none; }
|
|
88
88
|
|
|
89
|
+
/* ── Word-style ribbon (full mode) ─────────────────────────── */
|
|
90
|
+
.maya-editor-ribbon {
|
|
91
|
+
gap: 0;
|
|
92
|
+
align-items: stretch;
|
|
93
|
+
padding: 4px 6px;
|
|
94
|
+
}
|
|
95
|
+
.maya-editor-ribbon-group {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 3px;
|
|
100
|
+
padding: 2px 8px;
|
|
101
|
+
border-right: 1px solid var(--maya-editor-border, #e3e3e8);
|
|
102
|
+
}
|
|
103
|
+
.maya-editor-ribbon-group:last-child {
|
|
104
|
+
border-right: 0;
|
|
105
|
+
}
|
|
106
|
+
.maya-editor-ribbon-group__row {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
justify-content: center;
|
|
111
|
+
gap: 2px;
|
|
112
|
+
flex: 1 1 auto;
|
|
113
|
+
}
|
|
114
|
+
.maya-editor-ribbon-group__label {
|
|
115
|
+
font-size: 10px;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
color: var(--maya-editor-muted, #8a8a94);
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
user-select: none;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Crisp icons regardless of theme. */
|
|
123
|
+
.maya-editor-toolbar__btn svg {
|
|
124
|
+
display: block;
|
|
125
|
+
width: 16px;
|
|
126
|
+
height: 16px;
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
/* ── Comment hover popover ─────────────────────────────────── */
|
|
90
130
|
.maya-comment-popover {
|
|
91
131
|
position: fixed;
|