@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,379 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/react';
|
|
3
|
+
import type { Editor } from '@tiptap/react';
|
|
4
|
+
|
|
5
|
+
import { buildMayaEditorExtensions } from '../lib/editorExtensions';
|
|
6
|
+
import { useEditorContent, type EditorOutput } from '../hooks/useEditorContent';
|
|
7
|
+
import { sanitizeEditorHtml } from '../lib/dompurifyConfig';
|
|
8
|
+
import { markdownToHtml } from '../lib/markdownToHtml';
|
|
9
|
+
import { htmlToMarkdown } from '../lib/htmlToMarkdown';
|
|
10
|
+
import { normalizeTableHtml } from '../lib/normalizeTableHtml';
|
|
11
|
+
import { docxToHtml } from '../lib/docxToHtml';
|
|
12
|
+
import type { EditorMode, TiptapDoc } from '../types';
|
|
13
|
+
import { EditorToolbar, type ToolbarLabels } from './EditorToolbar';
|
|
14
|
+
import { FindReplaceBar } from './FindReplaceBar';
|
|
15
|
+
import { CommentHoverPopover, type CommentHoverData } from './CommentHoverPopover';
|
|
16
|
+
import '../styles/maya-editor.css';
|
|
17
|
+
|
|
18
|
+
type ViewMode = 'wysiwyg' | 'html' | 'markdown';
|
|
19
|
+
|
|
20
|
+
export interface MayaEditorProps {
|
|
21
|
+
/** Initial HTML content (preferred) or a ProseMirror JSON doc. */
|
|
22
|
+
initialContent?: string | object;
|
|
23
|
+
/** When false, the editor is read-only. Defaults to true. */
|
|
24
|
+
editable?: boolean;
|
|
25
|
+
/** Toggle dark-mode CSS class on the editor wrapper. */
|
|
26
|
+
isDark?: boolean;
|
|
27
|
+
/** Editor mode: 'lite' (minimal toolbar) | 'full' (BlockNote parity). */
|
|
28
|
+
mode?: EditorMode;
|
|
29
|
+
/** Debounced change callback (300ms). Payload depends on `output`. */
|
|
30
|
+
onChange?: (payload: string | TiptapDoc) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Output shape: `'html'` (default) emits a sanitisation-ready string;
|
|
33
|
+
* `'json'` emits the full ProseMirror doc `{type:'doc', content:[…]}`,
|
|
34
|
+
* structurally equivalent to BlockNote's legacy block array and a
|
|
35
|
+
* better fit for backends that still validate as JSON object/array.
|
|
36
|
+
*/
|
|
37
|
+
output?: EditorOutput;
|
|
38
|
+
/** Fullscreen toggle callback (only meaningful in 'full' mode). */
|
|
39
|
+
onFullscreenChange?: (isFullscreen: boolean) => void;
|
|
40
|
+
/** Optional file uploader; returns the URL to embed. */
|
|
41
|
+
uploadFile?: (file: File) => Promise<string>;
|
|
42
|
+
/** Optional toolbar labels override (i18n). */
|
|
43
|
+
toolbarLabels?: ToolbarLabels;
|
|
44
|
+
/** Optional placeholder text. */
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
/** Forwarded onEditorReady callback to access the underlying editor. */
|
|
47
|
+
onEditorReady?: (editor: Editor) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Called when the user clicks "Comment selection". Receives the current
|
|
50
|
+
* text range. The consumer should persist the comment, then return the
|
|
51
|
+
* commentId — the editor wraps the selection with a `CommentMark`
|
|
52
|
+
* carrying that id. Returning `null`/`undefined` cancels.
|
|
53
|
+
*/
|
|
54
|
+
onCreateComment?: (range: {
|
|
55
|
+
from: number;
|
|
56
|
+
to: number;
|
|
57
|
+
text: string;
|
|
58
|
+
}) => Promise<string | number | null | undefined> | string | number | null | undefined;
|
|
59
|
+
/** Called when the user clicks "Export .docx". Consumer triggers the download. */
|
|
60
|
+
onExportDocx?: () => void;
|
|
61
|
+
/**
|
|
62
|
+
* Lookup table for anchored-comment hover previews. The package itself
|
|
63
|
+
* doesn't know how comments are fetched — the consumer passes a dict
|
|
64
|
+
* keyed by `commentId` so the editor can render a `data-comment-id`
|
|
65
|
+
* span's contents in a portal popover on hover. Missing keys → no
|
|
66
|
+
* popover (silent).
|
|
67
|
+
*/
|
|
68
|
+
commentsById?: Record<string, CommentHoverData>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Unified TipTap editor for the Maya ecosystem.
|
|
73
|
+
*
|
|
74
|
+
* Two visual modes via the `mode` prop — the underlying ProseMirror
|
|
75
|
+
* schema and extensions are shared, so a `mode=lite` instance can be
|
|
76
|
+
* upgraded to `mode=full` without re-parsing content.
|
|
77
|
+
*
|
|
78
|
+
* Intended replacements:
|
|
79
|
+
* - `mode='lite'` → 4 textareas in maya_logs + maya_dashboard
|
|
80
|
+
* - `mode='full'` → BlockNoteEditorPanel in maya_dms (templates/documents)
|
|
81
|
+
*/
|
|
82
|
+
export function MayaEditor({
|
|
83
|
+
initialContent,
|
|
84
|
+
editable = true,
|
|
85
|
+
isDark = false,
|
|
86
|
+
mode = 'lite',
|
|
87
|
+
onChange,
|
|
88
|
+
onFullscreenChange,
|
|
89
|
+
uploadFile,
|
|
90
|
+
toolbarLabels,
|
|
91
|
+
placeholder,
|
|
92
|
+
onEditorReady,
|
|
93
|
+
output,
|
|
94
|
+
onCreateComment,
|
|
95
|
+
onExportDocx,
|
|
96
|
+
commentsById,
|
|
97
|
+
}: MayaEditorProps) {
|
|
98
|
+
const effectiveOutput: EditorOutput = output ?? (mode === 'full' ? 'json' : 'html');
|
|
99
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
100
|
+
const [viewMode, setViewMode] = useState<ViewMode>('wysiwyg');
|
|
101
|
+
const [sourceText, setSourceText] = useState('');
|
|
102
|
+
const [findOpen, setFindOpen] = useState(false);
|
|
103
|
+
// selectionVersion bumps on every selectionUpdate so toolbar predicates
|
|
104
|
+
// that read `editor.state.selection` (e.g. the comment button's disabled
|
|
105
|
+
// state) re-evaluate on cursor moves. TipTap v3's `useEditor` re-renders
|
|
106
|
+
// on transactions but not selection-only changes.
|
|
107
|
+
const [, setSelectionVersion] = useState(0);
|
|
108
|
+
const [hoveredComment, setHoveredComment] = useState<{
|
|
109
|
+
id: string;
|
|
110
|
+
rect: DOMRect;
|
|
111
|
+
} | null>(null);
|
|
112
|
+
// viewReady flips to true after TipTap's `create` event fires, which is
|
|
113
|
+
// when `editor.view` becomes safely accessible. Used to defer effects
|
|
114
|
+
// that touch the view DOM until it's actually mounted.
|
|
115
|
+
const [viewReady, setViewReady] = useState(false);
|
|
116
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
117
|
+
const docxInputRef = useRef<HTMLInputElement | null>(null);
|
|
118
|
+
|
|
119
|
+
const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
|
|
120
|
+
|
|
121
|
+
const editor = useEditor({
|
|
122
|
+
extensions,
|
|
123
|
+
content: initialContent ?? '',
|
|
124
|
+
editable,
|
|
125
|
+
editorProps: {
|
|
126
|
+
attributes: {
|
|
127
|
+
class: `maya-editor maya-editor--${mode}${isDark ? ' is-dark' : ''}`,
|
|
128
|
+
...(placeholder ? { 'data-placeholder': placeholder } : {}),
|
|
129
|
+
},
|
|
130
|
+
// Reshape pasted HTML so complex tables (caption/tfoot/colgroup)
|
|
131
|
+
// survive TipTap's parser. See `normalizeTableHtml` for details.
|
|
132
|
+
transformPastedHTML: (html) => normalizeTableHtml(html),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
useEditorContent(editor, onChange, { output: effectiveOutput });
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (editor && onEditorReady) onEditorReady(editor);
|
|
140
|
+
}, [editor, onEditorReady]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!editor) return;
|
|
144
|
+
const bump = () => setSelectionVersion((v) => v + 1);
|
|
145
|
+
const markReady = () => setViewReady(true);
|
|
146
|
+
editor.on('selectionUpdate', bump);
|
|
147
|
+
editor.on('transaction', bump);
|
|
148
|
+
editor.on('create', markReady);
|
|
149
|
+
// The editor may have already fired `create` before we got here
|
|
150
|
+
// (useEditor sometimes returns an instance that's already mounted).
|
|
151
|
+
try {
|
|
152
|
+
if (editor.view && editor.view.dom) setViewReady(true);
|
|
153
|
+
} catch { /* view not ready yet — markReady will fire it */ }
|
|
154
|
+
return () => {
|
|
155
|
+
editor.off('selectionUpdate', bump);
|
|
156
|
+
editor.off('transaction', bump);
|
|
157
|
+
editor.off('create', markReady);
|
|
158
|
+
};
|
|
159
|
+
}, [editor]);
|
|
160
|
+
|
|
161
|
+
// Hover detection on CommentMark spans (`data-comment-id`).
|
|
162
|
+
// Listens on the editor view DOM so we don't pay for delegated mouse
|
|
163
|
+
// events outside the editor surface. Closes when the pointer leaves
|
|
164
|
+
// the comment span and there's no replacement inside the same hover.
|
|
165
|
+
// Gated on `viewReady` because `editor.view` is a getter that throws
|
|
166
|
+
// until TipTap fires the `create` event.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!editor || !commentsById || !viewReady) return;
|
|
169
|
+
let root: HTMLElement;
|
|
170
|
+
try {
|
|
171
|
+
root = editor.view.dom as HTMLElement;
|
|
172
|
+
} catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!root) return;
|
|
176
|
+
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
177
|
+
|
|
178
|
+
const handleEnter = (e: MouseEvent) => {
|
|
179
|
+
const target = e.target as HTMLElement | null;
|
|
180
|
+
const span = target?.closest?.('[data-comment-id]') as HTMLElement | null;
|
|
181
|
+
if (!span) return;
|
|
182
|
+
const id = span.getAttribute('data-comment-id');
|
|
183
|
+
if (!id || !commentsById[id]) return;
|
|
184
|
+
if (hideTimer) {
|
|
185
|
+
clearTimeout(hideTimer);
|
|
186
|
+
hideTimer = null;
|
|
187
|
+
}
|
|
188
|
+
setHoveredComment({ id, rect: span.getBoundingClientRect() });
|
|
189
|
+
};
|
|
190
|
+
const handleLeave = (e: MouseEvent) => {
|
|
191
|
+
const target = e.relatedTarget as HTMLElement | null;
|
|
192
|
+
if (target && target.closest?.('[data-comment-id]')) return;
|
|
193
|
+
if (target && target.closest?.('.maya-comment-popover')) return;
|
|
194
|
+
hideTimer = setTimeout(() => setHoveredComment(null), 80);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
root.addEventListener('mouseover', handleEnter);
|
|
198
|
+
root.addEventListener('mouseout', handleLeave);
|
|
199
|
+
return () => {
|
|
200
|
+
root.removeEventListener('mouseover', handleEnter);
|
|
201
|
+
root.removeEventListener('mouseout', handleLeave);
|
|
202
|
+
if (hideTimer) clearTimeout(hideTimer);
|
|
203
|
+
};
|
|
204
|
+
}, [editor, commentsById, viewReady]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (editor) editor.setEditable(editable);
|
|
208
|
+
}, [editor, editable]);
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!onFullscreenChange) return;
|
|
212
|
+
onFullscreenChange(isFullscreen);
|
|
213
|
+
}, [isFullscreen, onFullscreenChange]);
|
|
214
|
+
|
|
215
|
+
if (!editor) return null;
|
|
216
|
+
|
|
217
|
+
const handlePickImage = async (file: File) => {
|
|
218
|
+
if (!uploadFile) return;
|
|
219
|
+
try {
|
|
220
|
+
const url = await uploadFile(file);
|
|
221
|
+
if (url) editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// Surface as console only — the upstream uploader is expected to
|
|
224
|
+
// show its own toast/error UI.
|
|
225
|
+
console.error('[MayaEditor] image upload failed', e);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const handlePickDocx = async (file: File) => {
|
|
230
|
+
try {
|
|
231
|
+
const html = await docxToHtml(file);
|
|
232
|
+
editor.commands.setContent(html, { emitUpdate: true });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.error('[MayaEditor] docx import failed', e);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handleCommentSelection = async () => {
|
|
239
|
+
if (!onCreateComment) return;
|
|
240
|
+
const { from, to } = editor.state.selection;
|
|
241
|
+
if (to <= from) return;
|
|
242
|
+
const text = editor.state.doc.textBetween(from, to, ' ');
|
|
243
|
+
const id = await Promise.resolve(onCreateComment({ from, to, text }));
|
|
244
|
+
if (id == null) return;
|
|
245
|
+
editor
|
|
246
|
+
.chain()
|
|
247
|
+
.focus()
|
|
248
|
+
.setTextSelection({ from, to })
|
|
249
|
+
.setComment(id)
|
|
250
|
+
.run();
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const enterSource = (target: 'html' | 'markdown') => {
|
|
254
|
+
const currentHtml = editor.getHTML();
|
|
255
|
+
const text = target === 'html' ? currentHtml : htmlToMarkdown(currentHtml);
|
|
256
|
+
setSourceText(text);
|
|
257
|
+
setViewMode(target);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const exitSource = () => {
|
|
261
|
+
const rawHtml =
|
|
262
|
+
viewMode === 'markdown'
|
|
263
|
+
? markdownToHtml(sourceText)
|
|
264
|
+
: sourceText;
|
|
265
|
+
// Normalise complex tables (caption/tfoot/colgroup) before sanitising
|
|
266
|
+
// — DOMPurify only strips disallowed tags, it doesn't reshape the
|
|
267
|
+
// tree to match TipTap's schema.
|
|
268
|
+
const html = sanitizeEditorHtml(normalizeTableHtml(rawHtml));
|
|
269
|
+
if (editor && html != null) {
|
|
270
|
+
editor.commands.setContent(html, { emitUpdate: true });
|
|
271
|
+
}
|
|
272
|
+
setViewMode('wysiwyg');
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const toggleHtml = () => {
|
|
276
|
+
if (viewMode === 'html') exitSource();
|
|
277
|
+
else if (viewMode === 'markdown') {
|
|
278
|
+
// markdown → html (switch source flavour without round-tripping the editor)
|
|
279
|
+
const html = sanitizeEditorHtml(markdownToHtml(sourceText));
|
|
280
|
+
setSourceText(html);
|
|
281
|
+
setViewMode('html');
|
|
282
|
+
} else enterSource('html');
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const toggleMarkdown = () => {
|
|
286
|
+
if (viewMode === 'markdown') exitSource();
|
|
287
|
+
else if (viewMode === 'html') {
|
|
288
|
+
const md = htmlToMarkdown(sourceText);
|
|
289
|
+
setSourceText(md);
|
|
290
|
+
setViewMode('markdown');
|
|
291
|
+
} else enterSource('markdown');
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
className={`maya-editor-wrapper${isFullscreen ? ' is-fullscreen' : ''}${isDark ? ' is-dark' : ''}`}
|
|
297
|
+
>
|
|
298
|
+
<EditorToolbar
|
|
299
|
+
editor={editor}
|
|
300
|
+
mode={mode}
|
|
301
|
+
isFullscreen={isFullscreen}
|
|
302
|
+
onToggleFullscreen={
|
|
303
|
+
mode === 'full' ? () => setIsFullscreen((v) => !v) : undefined
|
|
304
|
+
}
|
|
305
|
+
onInsertHtml={mode === 'full' ? toggleHtml : undefined}
|
|
306
|
+
onInsertMarkdown={mode === 'full' ? toggleMarkdown : undefined}
|
|
307
|
+
viewMode={viewMode}
|
|
308
|
+
onImage={mode === 'full' && uploadFile ? () => fileInputRef.current?.click() : undefined}
|
|
309
|
+
onImportDocx={mode === 'full' ? () => docxInputRef.current?.click() : undefined}
|
|
310
|
+
onExportDocx={mode === 'full' && onExportDocx ? onExportDocx : undefined}
|
|
311
|
+
onAddComment={mode === 'full' && onCreateComment ? handleCommentSelection : undefined}
|
|
312
|
+
onToggleFind={mode === 'full' ? () => setFindOpen((v) => !v) : undefined}
|
|
313
|
+
labels={toolbarLabels}
|
|
314
|
+
/>
|
|
315
|
+
{mode === 'full' && (
|
|
316
|
+
<FindReplaceBar
|
|
317
|
+
editor={editor}
|
|
318
|
+
open={findOpen}
|
|
319
|
+
onClose={() => setFindOpen(false)}
|
|
320
|
+
labels={{
|
|
321
|
+
findPlaceholder: toolbarLabels?.findPlaceholder ?? 'Find',
|
|
322
|
+
replacePlaceholder: toolbarLabels?.replacePlaceholder ?? 'Replace with',
|
|
323
|
+
findNext: toolbarLabels?.findNext ?? 'Next match',
|
|
324
|
+
findPrev: toolbarLabels?.findPrev ?? 'Previous match',
|
|
325
|
+
replace: toolbarLabels?.replace ?? 'Replace',
|
|
326
|
+
replaceAll: toolbarLabels?.replaceAll ?? 'Replace all',
|
|
327
|
+
close: toolbarLabels?.findClose ?? 'Close',
|
|
328
|
+
caseSensitive: toolbarLabels?.caseSensitive ?? 'Match case',
|
|
329
|
+
count: (a, b) => `${a}/${b}`,
|
|
330
|
+
none: toolbarLabels?.findNone ?? 'No matches',
|
|
331
|
+
replacedCount: (n) => `${n} replaced`,
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
<input
|
|
336
|
+
ref={fileInputRef}
|
|
337
|
+
type="file"
|
|
338
|
+
accept="image/*"
|
|
339
|
+
className="maya-editor-hidden-input"
|
|
340
|
+
onChange={(e) => {
|
|
341
|
+
const f = e.target.files?.[0];
|
|
342
|
+
if (f) handlePickImage(f);
|
|
343
|
+
e.target.value = '';
|
|
344
|
+
}}
|
|
345
|
+
/>
|
|
346
|
+
<input
|
|
347
|
+
ref={docxInputRef}
|
|
348
|
+
type="file"
|
|
349
|
+
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
350
|
+
className="maya-editor-hidden-input"
|
|
351
|
+
onChange={(e) => {
|
|
352
|
+
const f = e.target.files?.[0];
|
|
353
|
+
if (f) handlePickDocx(f);
|
|
354
|
+
e.target.value = '';
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
<CommentHoverPopover
|
|
358
|
+
comment={hoveredComment && commentsById ? commentsById[hoveredComment.id] ?? null : null}
|
|
359
|
+
anchorRect={hoveredComment?.rect ?? null}
|
|
360
|
+
isDark={isDark}
|
|
361
|
+
/>
|
|
362
|
+
{viewMode === 'wysiwyg' ? (
|
|
363
|
+
<EditorContent editor={editor} className="maya-editor-content" />
|
|
364
|
+
) : (
|
|
365
|
+
<textarea
|
|
366
|
+
className="maya-editor-source"
|
|
367
|
+
value={sourceText}
|
|
368
|
+
onChange={(e) => setSourceText(e.target.value)}
|
|
369
|
+
spellCheck={false}
|
|
370
|
+
aria-label={
|
|
371
|
+
viewMode === 'html'
|
|
372
|
+
? (toolbarLabels?.insertHtml ?? 'HTML source')
|
|
373
|
+
: (toolbarLabels?.insertMarkdown ?? 'Markdown source')
|
|
374
|
+
}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
interface SourceInputDialogProps {
|
|
5
|
+
open: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
initialValue?: string;
|
|
10
|
+
confirmLabel: string;
|
|
11
|
+
cancelLabel: string;
|
|
12
|
+
onConfirm: (value: string) => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lightweight modal used by the "Insert HTML" and "Insert Markdown"
|
|
18
|
+
* toolbar actions. Renders a sticky overlay with a textarea so power
|
|
19
|
+
* users can paste/edit source instead of clicking through the toolbar.
|
|
20
|
+
*
|
|
21
|
+
* Intentionally dependency-free: no portal, no headless-ui — the
|
|
22
|
+
* package targets multiple consumers (DMS, logs, dashboard) with
|
|
23
|
+
* different UI kits and shouldn't pull a modal lib transitively.
|
|
24
|
+
*/
|
|
25
|
+
export function SourceInputDialog({
|
|
26
|
+
open,
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
placeholder,
|
|
30
|
+
initialValue = '',
|
|
31
|
+
confirmLabel,
|
|
32
|
+
cancelLabel,
|
|
33
|
+
onConfirm,
|
|
34
|
+
onCancel,
|
|
35
|
+
}: SourceInputDialogProps) {
|
|
36
|
+
const [value, setValue] = useState(initialValue);
|
|
37
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (open) {
|
|
41
|
+
setValue(initialValue);
|
|
42
|
+
// Defer focus to next tick — React mounts the textarea after the open prop flips.
|
|
43
|
+
const t = setTimeout(() => textareaRef.current?.focus(), 0);
|
|
44
|
+
return () => clearTimeout(t);
|
|
45
|
+
}
|
|
46
|
+
}, [open, initialValue]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!open) return;
|
|
50
|
+
const onKey = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key === 'Escape') {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
onCancel();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener('keydown', onKey);
|
|
57
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
58
|
+
}, [open, onCancel]);
|
|
59
|
+
|
|
60
|
+
if (!open || typeof document === 'undefined') return null;
|
|
61
|
+
|
|
62
|
+
// Portal to <body> so the dialog escapes any ancestor with `overflow:
|
|
63
|
+
// hidden`, `transform`, `will-change` or `filter` (which would otherwise
|
|
64
|
+
// turn `position: fixed` into a containing-block-relative position and
|
|
65
|
+
// clip / restack the overlay behind sibling UI).
|
|
66
|
+
return createPortal(
|
|
67
|
+
<div
|
|
68
|
+
className="maya-editor-dialog-overlay"
|
|
69
|
+
role="dialog"
|
|
70
|
+
aria-modal="true"
|
|
71
|
+
aria-label={title}
|
|
72
|
+
onMouseDown={(e) => {
|
|
73
|
+
// Click on the backdrop (not on the dialog itself) cancels.
|
|
74
|
+
if (e.target === e.currentTarget) onCancel();
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div className="maya-editor-dialog" onMouseDown={(e) => e.stopPropagation()}>
|
|
78
|
+
<header className="maya-editor-dialog__header">
|
|
79
|
+
<h3 className="maya-editor-dialog__title">{title}</h3>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
className="maya-editor-dialog__close"
|
|
83
|
+
aria-label={cancelLabel}
|
|
84
|
+
onClick={onCancel}
|
|
85
|
+
>
|
|
86
|
+
✕
|
|
87
|
+
</button>
|
|
88
|
+
</header>
|
|
89
|
+
{description && <p className="maya-editor-dialog__description">{description}</p>}
|
|
90
|
+
<textarea
|
|
91
|
+
ref={textareaRef}
|
|
92
|
+
className="maya-editor-dialog__textarea"
|
|
93
|
+
value={value}
|
|
94
|
+
placeholder={placeholder}
|
|
95
|
+
onChange={(e) => setValue(e.target.value)}
|
|
96
|
+
rows={12}
|
|
97
|
+
spellCheck={false}
|
|
98
|
+
/>
|
|
99
|
+
<footer className="maya-editor-dialog__footer">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className="maya-editor-dialog__btn maya-editor-dialog__btn--ghost"
|
|
103
|
+
onClick={onCancel}
|
|
104
|
+
>
|
|
105
|
+
{cancelLabel}
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
className="maya-editor-dialog__btn maya-editor-dialog__btn--primary"
|
|
110
|
+
onClick={() => onConfirm(value)}
|
|
111
|
+
disabled={value.trim() === ''}
|
|
112
|
+
>
|
|
113
|
+
{confirmLabel}
|
|
114
|
+
</button>
|
|
115
|
+
</footer>
|
|
116
|
+
</div>
|
|
117
|
+
</div>,
|
|
118
|
+
document.body,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
export type AlertVariant = 'info' | 'warning' | 'success' | 'danger';
|
|
4
|
+
|
|
5
|
+
const VARIANTS: AlertVariant[] = ['info', 'warning', 'success', 'danger'];
|
|
6
|
+
|
|
7
|
+
declare module '@tiptap/core' {
|
|
8
|
+
interface Commands<ReturnType> {
|
|
9
|
+
alert: {
|
|
10
|
+
setAlert: (variant: AlertVariant) => ReturnType;
|
|
11
|
+
toggleAlert: (variant: AlertVariant) => ReturnType;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const AlertBlock = Node.create({
|
|
17
|
+
name: 'alert',
|
|
18
|
+
group: 'block',
|
|
19
|
+
content: 'block+',
|
|
20
|
+
defining: true,
|
|
21
|
+
|
|
22
|
+
addAttributes() {
|
|
23
|
+
return {
|
|
24
|
+
variant: {
|
|
25
|
+
default: 'info',
|
|
26
|
+
parseHTML: (el) => {
|
|
27
|
+
const v = (el as HTMLElement).getAttribute('data-variant') ?? 'info';
|
|
28
|
+
return VARIANTS.includes(v as AlertVariant) ? v : 'info';
|
|
29
|
+
},
|
|
30
|
+
renderHTML: (attrs) => ({
|
|
31
|
+
'data-variant': attrs.variant,
|
|
32
|
+
class: `alert alert-${attrs.variant}`,
|
|
33
|
+
role: 'note',
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
parseHTML() {
|
|
40
|
+
return [{ tag: 'aside[data-variant]' }, { tag: 'aside.alert' }];
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
renderHTML({ HTMLAttributes }) {
|
|
44
|
+
return ['aside', mergeAttributes(HTMLAttributes), 0];
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
addCommands() {
|
|
48
|
+
return {
|
|
49
|
+
setAlert:
|
|
50
|
+
(variant) =>
|
|
51
|
+
({ commands }) =>
|
|
52
|
+
commands.setNode(this.name, { variant }),
|
|
53
|
+
toggleAlert:
|
|
54
|
+
(variant) =>
|
|
55
|
+
({ commands }) =>
|
|
56
|
+
commands.toggleWrap(this.name, { variant }),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Mark, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
declare module '@tiptap/core' {
|
|
4
|
+
interface Commands<ReturnType> {
|
|
5
|
+
comment: {
|
|
6
|
+
setComment: (commentId: string | number) => ReturnType;
|
|
7
|
+
unsetComment: () => ReturnType;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mark applied to a text range that has an anchored comment.
|
|
14
|
+
*
|
|
15
|
+
* Persistence: the `commentId` attribute references `anchored_comments.id`
|
|
16
|
+
* (server-side). Rebasement on edits is handled by `CommentAnchor.rebase`
|
|
17
|
+
* via ProseMirror's `Transaction.mapping`.
|
|
18
|
+
*/
|
|
19
|
+
export const CommentMark = Mark.create({
|
|
20
|
+
name: 'comment',
|
|
21
|
+
inclusive: false,
|
|
22
|
+
excludes: '',
|
|
23
|
+
|
|
24
|
+
addAttributes() {
|
|
25
|
+
return {
|
|
26
|
+
commentId: {
|
|
27
|
+
default: null,
|
|
28
|
+
parseHTML: (el) => (el as HTMLElement).getAttribute('data-comment-id'),
|
|
29
|
+
renderHTML: (attrs) =>
|
|
30
|
+
attrs.commentId == null
|
|
31
|
+
? {}
|
|
32
|
+
: { 'data-comment-id': String(attrs.commentId), class: 'maya-anchored-comment' },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
parseHTML() {
|
|
38
|
+
return [{ tag: 'span[data-comment-id]' }];
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
renderHTML({ HTMLAttributes }) {
|
|
42
|
+
return ['span', mergeAttributes(HTMLAttributes), 0];
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
addCommands() {
|
|
46
|
+
return {
|
|
47
|
+
setComment:
|
|
48
|
+
(commentId) =>
|
|
49
|
+
({ commands }) =>
|
|
50
|
+
commands.setMark(this.name, { commentId: String(commentId) }),
|
|
51
|
+
unsetComment:
|
|
52
|
+
() =>
|
|
53
|
+
({ commands }) =>
|
|
54
|
+
commands.unsetMark(this.name),
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
export interface IframeOptions {
|
|
4
|
+
HTMLAttributes: Record<string, unknown>;
|
|
5
|
+
allowedDomains: string[] | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
declare module '@tiptap/core' {
|
|
9
|
+
interface Commands<ReturnType> {
|
|
10
|
+
iframe: {
|
|
11
|
+
setIframe: (options: { src: string; title?: string }) => ReturnType;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom iframe block — replaces the legacy `createIframeBlock.ts`
|
|
18
|
+
* BlockNote-specific component.
|
|
19
|
+
*/
|
|
20
|
+
export const IframeBlock = Node.create<IframeOptions>({
|
|
21
|
+
name: 'iframe',
|
|
22
|
+
group: 'block',
|
|
23
|
+
atom: true,
|
|
24
|
+
selectable: true,
|
|
25
|
+
draggable: true,
|
|
26
|
+
|
|
27
|
+
addOptions() {
|
|
28
|
+
return {
|
|
29
|
+
HTMLAttributes: {
|
|
30
|
+
sandbox: 'allow-scripts allow-same-origin',
|
|
31
|
+
loading: 'lazy',
|
|
32
|
+
},
|
|
33
|
+
allowedDomains: null,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
addAttributes() {
|
|
38
|
+
return {
|
|
39
|
+
src: { default: null },
|
|
40
|
+
title: { default: null },
|
|
41
|
+
width: { default: '100%' },
|
|
42
|
+
height: { default: '400' },
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
parseHTML() {
|
|
47
|
+
return [{ tag: 'iframe' }];
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
renderHTML({ HTMLAttributes }) {
|
|
51
|
+
return ['iframe', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
addCommands() {
|
|
55
|
+
return {
|
|
56
|
+
setIframe:
|
|
57
|
+
(options) =>
|
|
58
|
+
({ commands }) => {
|
|
59
|
+
if (!options.src) return false;
|
|
60
|
+
if (this.options.allowedDomains) {
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(options.src);
|
|
63
|
+
const allowed = this.options.allowedDomains.some((d) => url.host.endsWith(d));
|
|
64
|
+
if (!allowed) return false;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return commands.insertContent({
|
|
70
|
+
type: this.name,
|
|
71
|
+
attrs: options,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|