@ceedcv-maya/shared-editor-react 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/package.json +87 -0
  4. package/src/components/ColorPicker.tsx +100 -0
  5. package/src/components/CommentHoverPopover.tsx +82 -0
  6. package/src/components/EditorContentHtml.tsx +29 -0
  7. package/src/components/EditorToolbar.tsx +225 -0
  8. package/src/components/EditorToolbarButton.tsx +32 -0
  9. package/src/components/EditorToolbarGroups.tsx +401 -0
  10. package/src/components/FindReplaceBar.tsx +253 -0
  11. package/src/components/MayaEditor.tsx +379 -0
  12. package/src/components/SourceInputDialog.tsx +120 -0
  13. package/src/extensions/AlertBlock.ts +59 -0
  14. package/src/extensions/CommentMark.ts +57 -0
  15. package/src/extensions/IframeBlock.ts +76 -0
  16. package/src/extensions/Indent.ts +133 -0
  17. package/src/hooks/useEditorContent.ts +47 -0
  18. package/src/i18n/en.json +54 -0
  19. package/src/i18n/es.json +54 -0
  20. package/src/index.ts +47 -0
  21. package/src/lib/CommentAnchor.ts +68 -0
  22. package/src/lib/docxToHtml.ts +58 -0
  23. package/src/lib/dompurifyConfig.test.ts +98 -0
  24. package/src/lib/dompurifyConfig.ts +123 -0
  25. package/src/lib/editorExtensions.ts +73 -0
  26. package/src/lib/htmlToMarkdown.ts +166 -0
  27. package/src/lib/htmlToTiptapDoc.test.ts +52 -0
  28. package/src/lib/htmlToTiptapDoc.ts +26 -0
  29. package/src/lib/markdownToHtml.ts +234 -0
  30. package/src/lib/normalizeTableHtml.ts +74 -0
  31. package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
  32. package/src/lib/splitHtmlIntoBlocks.ts +136 -0
  33. package/src/serializers/BlockNoteToTiptap.ts +223 -0
  34. package/src/styles/maya-editor.css +538 -0
  35. package/src/types.ts +56 -0
  36. package/tsconfig.json +20 -0
@@ -0,0 +1,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
+ });