@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maya-AQSS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @ceedcv-maya/shared-editor-react
|
|
2
|
+
|
|
3
|
+
Unified TipTap editor for the Maya ecosystem.
|
|
4
|
+
|
|
5
|
+
## Components
|
|
6
|
+
|
|
7
|
+
- **`<MayaEditor mode="lite" | "full" />`** — single editor with two visual modes. `lite` for short comments and alerts; `full` for templates and documents (BlockNote parity).
|
|
8
|
+
- **`<EditorContentHtml html />`** — read-only renderer with DOMPurify sanitisation (aligned with the server-side `TiptapHtmlRenderer`).
|
|
9
|
+
- **`<EditorToolbar />`** — toolbar builder, used internally by `MayaEditor` and exposed for custom integrations.
|
|
10
|
+
|
|
11
|
+
## Extensions
|
|
12
|
+
|
|
13
|
+
- `IframeBlock` — sandboxed iframe block with optional domain allowlist.
|
|
14
|
+
- `AlertBlock` — variants info / warning / success / danger.
|
|
15
|
+
- `CommentMark` — anchored-comment mark (paired with `AnchoredCommentController` server-side).
|
|
16
|
+
|
|
17
|
+
## Conversion
|
|
18
|
+
|
|
19
|
+
- `convertBlockNoteToTiptap(blocks)` — legacy → ProseMirror conversion. Mirror of the PHP `Maya\Editor\Renderers\BlockNoteToTiptap`.
|
|
20
|
+
|
|
21
|
+
## Document import & block splitting
|
|
22
|
+
|
|
23
|
+
Helpers behind the "Import from Word → blocks" flow (`DocxBlockSplitter` in maya_dms):
|
|
24
|
+
|
|
25
|
+
- `docxToHtml(file)` / `docxToHtmlResult(file)` — convert a `.docx` `File` to sanitised, editor-ready HTML via mammoth (loaded dynamically, ~430KB). `docxToHtmlResult` also returns mammoth's `messages` (warnings for unrecognised styles / track changes that may not import cleanly).
|
|
26
|
+
- `splitHtmlIntoBlocks(html)` — split an HTML fragment into top-level `BlockChunk[]` (heading / paragraph / list / table / figure / blockquote / codeBlock / horizontalRule / other), preserving each element's `outerHTML` for a lossless round-trip. **List items are exploded one chunk per `<li>`** (re-wrapped in their `<ul>`/`<ol>`) so callers can assign each bullet to a different block. Empty whitespace/`<br>`-only paragraphs are flagged `isEmpty`.
|
|
27
|
+
- `htmlToTiptapDoc(html, extensions?)` — convert HTML to a TipTap JSON doc using a headless editor with the canonical Maya extensions, so the round-trip matches what the live editor persists. Empty input yields a doc with one empty paragraph (TipTap's normalised empty state).
|
|
28
|
+
- `buildMayaEditorExtensions(mode)` — the canonical TipTap extension list, shared by `MayaEditor` and `htmlToTiptapDoc` (single source of truth for the schema).
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const html = await docxToHtml(file);
|
|
32
|
+
const chunks = splitHtmlIntoBlocks(html).filter((c) => !c.isEmpty);
|
|
33
|
+
// …user groups chunks into blocks…
|
|
34
|
+
const doc = htmlToTiptapDoc(groupHtml); // → persist doc.content
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Known mammoth limitations: track changes and comments are dropped; non-standard Word styles may not map. `docxToHtmlResult().messages` surfaces these.
|
|
38
|
+
|
|
39
|
+
## Anchored comments
|
|
40
|
+
|
|
41
|
+
- `getAnchorRange(editor, commentId)` / `setAnchorRange(editor, id, range)`
|
|
42
|
+
- `rebaseAnchors(anchors, tr)` — applies a ProseMirror `Transaction.mapping` to a list of anchors and flags collapsed ones as invalid.
|
|
43
|
+
|
|
44
|
+
## Sanitisation
|
|
45
|
+
|
|
46
|
+
- `sanitizeEditorHtml(rawHtml)` — DOMPurify config covering the full set of tags emitted by `TiptapHtmlRenderer` (paragraph, headings, lists, tables, blockquote, code, images, iframes with `sandbox`, alerts).
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { MayaEditor } from '@ceedcv-maya/shared-editor-react';
|
|
52
|
+
|
|
53
|
+
<MayaEditor
|
|
54
|
+
mode="full"
|
|
55
|
+
initialContent={template.html}
|
|
56
|
+
editable={canEdit}
|
|
57
|
+
onChange={(html) => save(html)}
|
|
58
|
+
isDark={theme === 'dark'}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Lite mode for short comments:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
<MayaEditor mode="lite" initialContent={comment.body} onChange={setBody} />
|
|
66
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ceedcv-maya/shared-editor-react",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"@tanstack/react-query": "^5.0.0",
|
|
9
|
+
"@tiptap/core": "^3.0.0",
|
|
10
|
+
"@tiptap/extension-color": "^3.0.0",
|
|
11
|
+
"@tiptap/extension-highlight": "^3.0.0",
|
|
12
|
+
"@tiptap/extension-image": "^3.0.0",
|
|
13
|
+
"@tiptap/extension-link": "^3.0.0",
|
|
14
|
+
"@tiptap/extension-table": "^3.0.0",
|
|
15
|
+
"@tiptap/extension-table-cell": "^3.0.0",
|
|
16
|
+
"@tiptap/extension-table-header": "^3.0.0",
|
|
17
|
+
"@tiptap/extension-table-row": "^3.0.0",
|
|
18
|
+
"@tiptap/extension-task-item": "^3.0.0",
|
|
19
|
+
"@tiptap/extension-task-list": "^3.0.0",
|
|
20
|
+
"@tiptap/extension-text-align": "^3.0.0",
|
|
21
|
+
"@tiptap/extension-text-style": "^3.0.0",
|
|
22
|
+
"@tiptap/extension-underline": "^3.0.0",
|
|
23
|
+
"@tiptap/pm": "^3.0.0",
|
|
24
|
+
"@tiptap/react": "^3.0.0",
|
|
25
|
+
"@tiptap/starter-kit": "^3.0.0",
|
|
26
|
+
"dompurify": "^3.0.0",
|
|
27
|
+
"mammoth": "^1.8.0",
|
|
28
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
29
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"mammoth": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/dompurify": "^3.0.0",
|
|
38
|
+
"@types/react": "^19.0.0",
|
|
39
|
+
"@types/react-dom": "^19.0.0",
|
|
40
|
+
"jsdom": "^29.1.1",
|
|
41
|
+
"react": "^19.0.0",
|
|
42
|
+
"react-dom": "^19.0.0",
|
|
43
|
+
"vitest": "^4.1.8"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"lint": "echo \"no linter configured\""
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Maya-AQSS/maya_platform.git",
|
|
55
|
+
"directory": "packages/js/shared-editor-react"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"description": "Unified rich-text editor for Maya: MayaEditor (TipTap) with lite/full modes, BlockNote→TipTap converter, HTML SSR renderer, anchored-comments support.",
|
|
61
|
+
"keywords": [
|
|
62
|
+
"react",
|
|
63
|
+
"tiptap",
|
|
64
|
+
"prosemirror",
|
|
65
|
+
"blocknote",
|
|
66
|
+
"editor",
|
|
67
|
+
"wysiwyg",
|
|
68
|
+
"ceedcv",
|
|
69
|
+
"maya"
|
|
70
|
+
],
|
|
71
|
+
"author": {
|
|
72
|
+
"name": "CEEDCV",
|
|
73
|
+
"email": "info@ceedcv.es",
|
|
74
|
+
"homepage": "https://ceedcv.es"
|
|
75
|
+
},
|
|
76
|
+
"homepage": "https://github.com/Maya-AQSS/shared-editor-react#readme",
|
|
77
|
+
"bugs": {
|
|
78
|
+
"url": "https://github.com/Maya-AQSS/maya_platform/issues"
|
|
79
|
+
},
|
|
80
|
+
"sideEffects": false,
|
|
81
|
+
"files": [
|
|
82
|
+
"src",
|
|
83
|
+
"LICENSE",
|
|
84
|
+
"README.md",
|
|
85
|
+
"tsconfig.json"
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ColorPickerProps {
|
|
4
|
+
title: string;
|
|
5
|
+
/** Currently applied colour (used for the swatch indicator + state). */
|
|
6
|
+
value?: string | null;
|
|
7
|
+
/** Called with a 6-digit hex or `null` (clear). */
|
|
8
|
+
onSelect: (color: string | null) => void;
|
|
9
|
+
/** Glyph shown inside the trigger button (e.g. `A` for text, `▮` for bg). */
|
|
10
|
+
glyph: React.ReactNode;
|
|
11
|
+
clearLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PALETTE: Array<{ value: string | null; label: string }> = [
|
|
15
|
+
{ value: null, label: 'Default' },
|
|
16
|
+
{ value: '#000000', label: 'Black' },
|
|
17
|
+
{ value: '#5b5b5b', label: 'Grey' },
|
|
18
|
+
{ value: '#ef4444', label: 'Red' },
|
|
19
|
+
{ value: '#f97316', label: 'Orange' },
|
|
20
|
+
{ value: '#f59e0b', label: 'Yellow' },
|
|
21
|
+
{ value: '#10b981', label: 'Green' },
|
|
22
|
+
{ value: '#06b6d4', label: 'Cyan' },
|
|
23
|
+
{ value: '#3b82f6', label: 'Blue' },
|
|
24
|
+
{ value: '#8b5cf6', label: 'Purple' },
|
|
25
|
+
{ value: '#ec4899', label: 'Pink' },
|
|
26
|
+
{ value: '#a16207', label: 'Brown' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lightweight palette popover used by the editor toolbar for text colour
|
|
31
|
+
* and highlight (background) selection. Closes on outside click, Escape,
|
|
32
|
+
* or after a swatch is chosen.
|
|
33
|
+
*/
|
|
34
|
+
export function ColorPicker({ title, value, onSelect, glyph, clearLabel = 'Default' }: ColorPickerProps) {
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const onDocClick = (e: MouseEvent) => {
|
|
41
|
+
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
|
|
42
|
+
};
|
|
43
|
+
const onKey = (e: KeyboardEvent) => {
|
|
44
|
+
if (e.key === 'Escape') setOpen(false);
|
|
45
|
+
};
|
|
46
|
+
document.addEventListener('mousedown', onDocClick);
|
|
47
|
+
document.addEventListener('keydown', onKey);
|
|
48
|
+
return () => {
|
|
49
|
+
document.removeEventListener('mousedown', onDocClick);
|
|
50
|
+
document.removeEventListener('keydown', onKey);
|
|
51
|
+
};
|
|
52
|
+
}, [open]);
|
|
53
|
+
|
|
54
|
+
const isActive = !!value;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="maya-editor-color" ref={wrapperRef}>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
title={title}
|
|
61
|
+
aria-label={title}
|
|
62
|
+
aria-pressed={isActive ? 'true' : undefined}
|
|
63
|
+
aria-haspopup="true"
|
|
64
|
+
aria-expanded={open}
|
|
65
|
+
onClick={() => setOpen((v) => !v)}
|
|
66
|
+
className={`maya-editor-toolbar__btn maya-editor-color__btn${isActive ? ' is-active' : ''}`}
|
|
67
|
+
>
|
|
68
|
+
<span className="maya-editor-color__glyph">{glyph}</span>
|
|
69
|
+
<span
|
|
70
|
+
className="maya-editor-color__swatch"
|
|
71
|
+
style={value ? { background: value } : undefined}
|
|
72
|
+
aria-hidden
|
|
73
|
+
/>
|
|
74
|
+
</button>
|
|
75
|
+
{open && (
|
|
76
|
+
<div className="maya-editor-color__panel" role="menu">
|
|
77
|
+
{PALETTE.map((item) => (
|
|
78
|
+
<button
|
|
79
|
+
key={item.value ?? '__default__'}
|
|
80
|
+
type="button"
|
|
81
|
+
role="menuitem"
|
|
82
|
+
className={`maya-editor-color__cell${value === item.value ? ' is-active' : ''}`}
|
|
83
|
+
title={item.value === null ? clearLabel : item.label}
|
|
84
|
+
aria-label={item.value === null ? clearLabel : item.label}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
onSelect(item.value);
|
|
87
|
+
setOpen(false);
|
|
88
|
+
}}
|
|
89
|
+
style={
|
|
90
|
+
item.value === null
|
|
91
|
+
? { backgroundImage: 'linear-gradient(45deg, transparent 47%, #d33 47%, #d33 53%, transparent 53%)' }
|
|
92
|
+
: { background: item.value }
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
export interface CommentHoverData {
|
|
5
|
+
/** Display name of the comment author. */
|
|
6
|
+
author?: string;
|
|
7
|
+
/** ISO/locale string shown under the author; consumer formats it. */
|
|
8
|
+
createdAt?: string;
|
|
9
|
+
/** Body of the comment (plain text, line breaks are honoured). */
|
|
10
|
+
body: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CommentHoverPopoverProps {
|
|
14
|
+
comment: CommentHoverData | null;
|
|
15
|
+
/** Viewport-relative coordinates of the highlighted span. */
|
|
16
|
+
anchorRect: DOMRect | null;
|
|
17
|
+
isDark?: boolean;
|
|
18
|
+
/** Optional close button label (used only for the aria-label). */
|
|
19
|
+
closeLabel?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tooltip-style popover anchored above an editor span carrying a
|
|
24
|
+
* `data-comment-id`. Renders via a portal so it escapes the editor's
|
|
25
|
+
* `overflow: hidden` clip box and isn't clipped by the wizard layout.
|
|
26
|
+
*
|
|
27
|
+
* Positioning: prefers the top edge of the highlighted span; flips below
|
|
28
|
+
* when there's no room above (within 16px viewport padding).
|
|
29
|
+
*/
|
|
30
|
+
export function CommentHoverPopover({
|
|
31
|
+
comment,
|
|
32
|
+
anchorRect,
|
|
33
|
+
isDark = false,
|
|
34
|
+
closeLabel = 'Close',
|
|
35
|
+
}: CommentHoverPopoverProps) {
|
|
36
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
37
|
+
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!ref.current || !comment) return;
|
|
41
|
+
const r = ref.current.getBoundingClientRect();
|
|
42
|
+
setSize({ width: r.width, height: r.height });
|
|
43
|
+
}, [comment?.body, comment?.author]);
|
|
44
|
+
|
|
45
|
+
if (!comment || !anchorRect || typeof document === 'undefined') return null;
|
|
46
|
+
|
|
47
|
+
const padding = 8;
|
|
48
|
+
const margin = 16;
|
|
49
|
+
const popoverW = size?.width ?? 280;
|
|
50
|
+
const popoverH = size?.height ?? 80;
|
|
51
|
+
const vw = window.innerWidth;
|
|
52
|
+
const vh = window.innerHeight;
|
|
53
|
+
|
|
54
|
+
// Place above if there's room, otherwise below.
|
|
55
|
+
const placeAbove = anchorRect.top - popoverH - padding > margin;
|
|
56
|
+
const top = placeAbove
|
|
57
|
+
? Math.max(margin, anchorRect.top - popoverH - padding)
|
|
58
|
+
: Math.min(vh - popoverH - margin, anchorRect.bottom + padding);
|
|
59
|
+
|
|
60
|
+
// Centre horizontally over the span; clamp to viewport.
|
|
61
|
+
const centerX = anchorRect.left + anchorRect.width / 2;
|
|
62
|
+
const left = Math.max(margin, Math.min(vw - popoverW - margin, centerX - popoverW / 2));
|
|
63
|
+
|
|
64
|
+
return createPortal(
|
|
65
|
+
<div
|
|
66
|
+
ref={ref}
|
|
67
|
+
className={`maya-comment-popover${isDark ? ' is-dark' : ''}`}
|
|
68
|
+
role="tooltip"
|
|
69
|
+
aria-label={closeLabel}
|
|
70
|
+
style={{ top, left, maxWidth: 320 }}
|
|
71
|
+
>
|
|
72
|
+
<div className="maya-comment-popover__header">
|
|
73
|
+
<span className="maya-comment-popover__author">{comment.author ?? 'Comentario'}</span>
|
|
74
|
+
{comment.createdAt && (
|
|
75
|
+
<span className="maya-comment-popover__date">{comment.createdAt}</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
<div className="maya-comment-popover__body">{comment.body}</div>
|
|
79
|
+
</div>,
|
|
80
|
+
document.body,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { sanitizeEditorHtml } from '../lib/dompurifyConfig';
|
|
3
|
+
|
|
4
|
+
interface EditorContentHtmlProps {
|
|
5
|
+
/** Pre-rendered HTML (typically from server-side TiptapHtmlRenderer). */
|
|
6
|
+
html: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders pre-sanitised editor HTML. Use this for read-only views (PDF
|
|
12
|
+
* previews, validator views, search hit cards) — for editing, use
|
|
13
|
+
* `<MayaEditor />`.
|
|
14
|
+
*
|
|
15
|
+
* Sanitises with the package DOMPurify config (aligned with the SSR
|
|
16
|
+
* renderer) — defence in depth, not a substitute for server-side
|
|
17
|
+
* sanitisation.
|
|
18
|
+
*/
|
|
19
|
+
export function EditorContentHtml({ html, className }: EditorContentHtmlProps) {
|
|
20
|
+
const safeHtml = useMemo(() => sanitizeEditorHtml(html), [html]);
|
|
21
|
+
if (!safeHtml) return null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={className ?? 'maya-editor-content'}
|
|
26
|
+
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/react';
|
|
2
|
+
import type { EditorMode } from '../types';
|
|
3
|
+
import { Btn } from './EditorToolbarButton';
|
|
4
|
+
import {
|
|
5
|
+
FormattingButtons,
|
|
6
|
+
AdvancedFormattingButtons,
|
|
7
|
+
AlignmentButtons,
|
|
8
|
+
IndentButtons,
|
|
9
|
+
HeadingButtons,
|
|
10
|
+
ListAndBlockButtons,
|
|
11
|
+
TableAndMediaButtons,
|
|
12
|
+
DocumentButtons,
|
|
13
|
+
ViewModeButtons,
|
|
14
|
+
} from './EditorToolbarGroups';
|
|
15
|
+
|
|
16
|
+
interface EditorToolbarProps {
|
|
17
|
+
editor: Editor | null;
|
|
18
|
+
mode: EditorMode;
|
|
19
|
+
isFullscreen?: boolean;
|
|
20
|
+
onToggleFullscreen?: () => void;
|
|
21
|
+
onInsertHtml?: () => void;
|
|
22
|
+
onInsertMarkdown?: () => void;
|
|
23
|
+
viewMode?: 'wysiwyg' | 'html' | 'markdown';
|
|
24
|
+
onImage?: () => void;
|
|
25
|
+
onImportDocx?: () => void;
|
|
26
|
+
onExportDocx?: () => void;
|
|
27
|
+
onAddComment?: () => void;
|
|
28
|
+
onToggleFind?: () => void;
|
|
29
|
+
labels?: ToolbarLabels;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToolbarLabels {
|
|
33
|
+
bold: string;
|
|
34
|
+
italic: string;
|
|
35
|
+
underline: string;
|
|
36
|
+
strike: string;
|
|
37
|
+
code: string;
|
|
38
|
+
link: string;
|
|
39
|
+
linkPrompt: string;
|
|
40
|
+
unlink: string;
|
|
41
|
+
heading1: string;
|
|
42
|
+
heading2: string;
|
|
43
|
+
heading3: string;
|
|
44
|
+
bulletList: string;
|
|
45
|
+
orderedList: string;
|
|
46
|
+
taskList: string;
|
|
47
|
+
blockquote: string;
|
|
48
|
+
codeBlock: string;
|
|
49
|
+
horizontalRule: string;
|
|
50
|
+
image: string;
|
|
51
|
+
table: string;
|
|
52
|
+
alert: string;
|
|
53
|
+
iframe: string;
|
|
54
|
+
iframePrompt: string;
|
|
55
|
+
fullscreen: string;
|
|
56
|
+
exitFullscreen: string;
|
|
57
|
+
insertHtml: string;
|
|
58
|
+
insertMarkdown: string;
|
|
59
|
+
alignLeft: string;
|
|
60
|
+
alignCenter: string;
|
|
61
|
+
alignRight: string;
|
|
62
|
+
alignJustify: string;
|
|
63
|
+
indent: string;
|
|
64
|
+
outdent: string;
|
|
65
|
+
textColor: string;
|
|
66
|
+
backgroundColor: string;
|
|
67
|
+
colorDefault: string;
|
|
68
|
+
undo: string;
|
|
69
|
+
redo: string;
|
|
70
|
+
uploadImage: string;
|
|
71
|
+
importDocx: string;
|
|
72
|
+
exportDocx: string;
|
|
73
|
+
addComment: string;
|
|
74
|
+
find: string;
|
|
75
|
+
findPlaceholder?: string;
|
|
76
|
+
replacePlaceholder?: string;
|
|
77
|
+
findNext?: string;
|
|
78
|
+
findPrev?: string;
|
|
79
|
+
replace?: string;
|
|
80
|
+
replaceAll?: string;
|
|
81
|
+
findClose?: string;
|
|
82
|
+
caseSensitive?: string;
|
|
83
|
+
findNone?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_LABELS: ToolbarLabels = {
|
|
87
|
+
bold: 'Bold',
|
|
88
|
+
italic: 'Italic',
|
|
89
|
+
underline: 'Underline',
|
|
90
|
+
strike: 'Strike',
|
|
91
|
+
code: 'Code',
|
|
92
|
+
link: 'Link',
|
|
93
|
+
linkPrompt: 'URL',
|
|
94
|
+
unlink: 'Unlink',
|
|
95
|
+
heading1: 'H1',
|
|
96
|
+
heading2: 'H2',
|
|
97
|
+
heading3: 'H3',
|
|
98
|
+
bulletList: 'Bullet list',
|
|
99
|
+
orderedList: 'Numbered list',
|
|
100
|
+
taskList: 'Task list',
|
|
101
|
+
blockquote: 'Quote',
|
|
102
|
+
codeBlock: 'Code block',
|
|
103
|
+
horizontalRule: 'HR',
|
|
104
|
+
image: 'Image',
|
|
105
|
+
table: 'Table',
|
|
106
|
+
alert: 'Alert',
|
|
107
|
+
iframe: 'Iframe',
|
|
108
|
+
iframePrompt: 'Iframe URL',
|
|
109
|
+
fullscreen: 'Fullscreen',
|
|
110
|
+
exitFullscreen: 'Exit fullscreen',
|
|
111
|
+
insertHtml: 'Insert HTML',
|
|
112
|
+
insertMarkdown: 'Insert Markdown',
|
|
113
|
+
alignLeft: 'Align left',
|
|
114
|
+
alignCenter: 'Align center',
|
|
115
|
+
alignRight: 'Align right',
|
|
116
|
+
alignJustify: 'Justify',
|
|
117
|
+
indent: 'Increase indent',
|
|
118
|
+
outdent: 'Decrease indent',
|
|
119
|
+
textColor: 'Text color',
|
|
120
|
+
backgroundColor: 'Highlight color',
|
|
121
|
+
colorDefault: 'Default color',
|
|
122
|
+
undo: 'Undo',
|
|
123
|
+
redo: 'Redo',
|
|
124
|
+
uploadImage: 'Insert image',
|
|
125
|
+
importDocx: 'Import Word (.docx)',
|
|
126
|
+
exportDocx: 'Export Word (.docx)',
|
|
127
|
+
addComment: 'Comment selection',
|
|
128
|
+
find: 'Find and replace',
|
|
129
|
+
findPlaceholder: 'Find',
|
|
130
|
+
replacePlaceholder: 'Replace with',
|
|
131
|
+
findNext: 'Next match',
|
|
132
|
+
findPrev: 'Previous match',
|
|
133
|
+
replace: 'Replace',
|
|
134
|
+
replaceAll: 'Replace all',
|
|
135
|
+
findClose: 'Close find',
|
|
136
|
+
caseSensitive: 'Match case',
|
|
137
|
+
findNone: 'No matches',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* EditorToolbar is a wrapper component that composes focused button groups.
|
|
142
|
+
* Each group handles a logically distinct set of toolbar buttons, keeping
|
|
143
|
+
* individual files under the 400-line target per coding-style.md.
|
|
144
|
+
*/
|
|
145
|
+
export function EditorToolbar({
|
|
146
|
+
editor,
|
|
147
|
+
mode,
|
|
148
|
+
isFullscreen,
|
|
149
|
+
onToggleFullscreen,
|
|
150
|
+
onInsertHtml,
|
|
151
|
+
onInsertMarkdown,
|
|
152
|
+
viewMode = 'wysiwyg',
|
|
153
|
+
onImage,
|
|
154
|
+
onImportDocx,
|
|
155
|
+
onExportDocx,
|
|
156
|
+
onAddComment,
|
|
157
|
+
onToggleFind,
|
|
158
|
+
labels,
|
|
159
|
+
}: EditorToolbarProps) {
|
|
160
|
+
if (!editor) return null;
|
|
161
|
+
const L = labels ?? DEFAULT_LABELS;
|
|
162
|
+
|
|
163
|
+
const isLite = mode === 'lite';
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="maya-editor-toolbar" role="toolbar" aria-label="Editor toolbar">
|
|
167
|
+
<Btn
|
|
168
|
+
disabled={!editor.can().undo()}
|
|
169
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
170
|
+
title={L.undo}
|
|
171
|
+
>
|
|
172
|
+
↶
|
|
173
|
+
</Btn>
|
|
174
|
+
<Btn
|
|
175
|
+
disabled={!editor.can().redo()}
|
|
176
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
177
|
+
title={L.redo}
|
|
178
|
+
>
|
|
179
|
+
↷
|
|
180
|
+
</Btn>
|
|
181
|
+
|
|
182
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
183
|
+
<FormattingButtons editor={editor} labels={L} />
|
|
184
|
+
|
|
185
|
+
{!isLite && (
|
|
186
|
+
<>
|
|
187
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
188
|
+
<AdvancedFormattingButtons editor={editor} labels={L} />
|
|
189
|
+
|
|
190
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
191
|
+
<AlignmentButtons editor={editor} labels={L} />
|
|
192
|
+
|
|
193
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
194
|
+
<IndentButtons editor={editor} labels={L} />
|
|
195
|
+
|
|
196
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
197
|
+
<HeadingButtons editor={editor} labels={L} />
|
|
198
|
+
<ListAndBlockButtons editor={editor} labels={L} />
|
|
199
|
+
<TableAndMediaButtons editor={editor} labels={L} onImage={onImage} />
|
|
200
|
+
|
|
201
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
202
|
+
<DocumentButtons
|
|
203
|
+
editor={editor}
|
|
204
|
+
labels={L}
|
|
205
|
+
onImportDocx={onImportDocx}
|
|
206
|
+
onExportDocx={onExportDocx}
|
|
207
|
+
onAddComment={onAddComment}
|
|
208
|
+
onToggleFind={onToggleFind}
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
212
|
+
<ViewModeButtons
|
|
213
|
+
editor={editor}
|
|
214
|
+
labels={L}
|
|
215
|
+
viewMode={viewMode}
|
|
216
|
+
isFullscreen={isFullscreen}
|
|
217
|
+
onInsertHtml={onInsertHtml}
|
|
218
|
+
onInsertMarkdown={onInsertMarkdown}
|
|
219
|
+
onToggleFullscreen={onToggleFullscreen}
|
|
220
|
+
/>
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar button component used across EditorToolbar and its button groups.
|
|
3
|
+
*/
|
|
4
|
+
interface BtnProps {
|
|
5
|
+
active?: boolean;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
onClick: () => void;
|
|
8
|
+
title: string;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Btn({
|
|
13
|
+
active,
|
|
14
|
+
disabled,
|
|
15
|
+
onClick,
|
|
16
|
+
title,
|
|
17
|
+
children,
|
|
18
|
+
}: BtnProps) {
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
title={title}
|
|
23
|
+
aria-label={title}
|
|
24
|
+
aria-pressed={active ? 'true' : undefined}
|
|
25
|
+
disabled={disabled}
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
className={`maya-editor-toolbar__btn${active ? ' is-active' : ''}`}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</button>
|
|
31
|
+
);
|
|
32
|
+
}
|