@react-email/editor 1.5.1 → 1.5.2
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/dist/index.cjs +34 -6
- package/dist/index.mjs +34 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -13,13 +13,20 @@ let _tiptap_extension_placeholder = require("@tiptap/extension-placeholder");
|
|
|
13
13
|
//#region src/utils/paste-sanitizer.ts
|
|
14
14
|
/**
|
|
15
15
|
* Sanitizes pasted HTML.
|
|
16
|
-
* -
|
|
17
|
-
* - From
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Detects content from the Resend editor by checking for node-* class names.
|
|
16
|
+
* - Always: drop dangerous elements (script, iframe, ...) and unsafe URL schemes (javascript:, ...).
|
|
17
|
+
* - From editor (has node-* classes): preserve attributes so node identity round-trips.
|
|
18
|
+
* - From external: strip all styles/classes, keep only semantic HTML.
|
|
21
19
|
*/
|
|
22
20
|
const EDITOR_CLASS_PATTERN = /class="[^"]*node-/;
|
|
21
|
+
const FORBIDDEN_TAGS = new Set([
|
|
22
|
+
"script",
|
|
23
|
+
"iframe",
|
|
24
|
+
"object",
|
|
25
|
+
"embed",
|
|
26
|
+
"meta",
|
|
27
|
+
"base"
|
|
28
|
+
]);
|
|
29
|
+
const URL_ATTRIBUTES = new Set(["href", "src"]);
|
|
23
30
|
/**
|
|
24
31
|
* Attributes to preserve on specific elements for EXTERNAL content.
|
|
25
32
|
* Only functional attributes - NO style or class.
|
|
@@ -53,11 +60,32 @@ function isFromEditor(html) {
|
|
|
53
60
|
return EDITOR_CLASS_PATTERN.test(html);
|
|
54
61
|
}
|
|
55
62
|
function sanitizePastedHtml(html) {
|
|
56
|
-
if (isFromEditor(html)) return html;
|
|
57
63
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
64
|
+
removeForbiddenElements(doc.body);
|
|
65
|
+
scrubUnsafeUrlAttributes(doc.body);
|
|
66
|
+
if (isFromEditor(html)) return doc.body.innerHTML;
|
|
58
67
|
sanitizeNode(doc.body);
|
|
59
68
|
return doc.body.innerHTML;
|
|
60
69
|
}
|
|
70
|
+
function removeForbiddenElements(root) {
|
|
71
|
+
for (const tag of FORBIDDEN_TAGS) for (const el of Array.from(root.getElementsByTagName(tag))) el.remove();
|
|
72
|
+
}
|
|
73
|
+
function scrubUnsafeUrlAttributes(root) {
|
|
74
|
+
for (const el of Array.from(root.querySelectorAll("[href], [src]"))) {
|
|
75
|
+
const allowDataImage = el.tagName.toLowerCase() === "img";
|
|
76
|
+
for (const attr of URL_ATTRIBUTES) {
|
|
77
|
+
const value = el.getAttribute(attr);
|
|
78
|
+
if (value !== null && !isSafeUrl(value, allowDataImage)) el.removeAttribute(attr);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function isSafeUrl(value, allowDataImage) {
|
|
83
|
+
const trimmed = value.trim().toLowerCase();
|
|
84
|
+
if (trimmed.startsWith("javascript:")) return false;
|
|
85
|
+
if (trimmed.startsWith("vbscript:")) return false;
|
|
86
|
+
if (trimmed.startsWith("data:")) return allowDataImage && trimmed.startsWith("data:image/");
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
61
89
|
function sanitizeNode(node) {
|
|
62
90
|
if (node.nodeType === Node.ELEMENT_NODE) sanitizeElement(node);
|
|
63
91
|
for (const child of Array.from(node.childNodes)) sanitizeNode(child);
|
package/dist/index.mjs
CHANGED
|
@@ -11,13 +11,20 @@ import { Placeholder } from "@tiptap/extension-placeholder";
|
|
|
11
11
|
//#region src/utils/paste-sanitizer.ts
|
|
12
12
|
/**
|
|
13
13
|
* Sanitizes pasted HTML.
|
|
14
|
-
* -
|
|
15
|
-
* - From
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Detects content from the Resend editor by checking for node-* class names.
|
|
14
|
+
* - Always: drop dangerous elements (script, iframe, ...) and unsafe URL schemes (javascript:, ...).
|
|
15
|
+
* - From editor (has node-* classes): preserve attributes so node identity round-trips.
|
|
16
|
+
* - From external: strip all styles/classes, keep only semantic HTML.
|
|
19
17
|
*/
|
|
20
18
|
const EDITOR_CLASS_PATTERN = /class="[^"]*node-/;
|
|
19
|
+
const FORBIDDEN_TAGS = new Set([
|
|
20
|
+
"script",
|
|
21
|
+
"iframe",
|
|
22
|
+
"object",
|
|
23
|
+
"embed",
|
|
24
|
+
"meta",
|
|
25
|
+
"base"
|
|
26
|
+
]);
|
|
27
|
+
const URL_ATTRIBUTES = new Set(["href", "src"]);
|
|
21
28
|
/**
|
|
22
29
|
* Attributes to preserve on specific elements for EXTERNAL content.
|
|
23
30
|
* Only functional attributes - NO style or class.
|
|
@@ -51,11 +58,32 @@ function isFromEditor(html) {
|
|
|
51
58
|
return EDITOR_CLASS_PATTERN.test(html);
|
|
52
59
|
}
|
|
53
60
|
function sanitizePastedHtml(html) {
|
|
54
|
-
if (isFromEditor(html)) return html;
|
|
55
61
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
62
|
+
removeForbiddenElements(doc.body);
|
|
63
|
+
scrubUnsafeUrlAttributes(doc.body);
|
|
64
|
+
if (isFromEditor(html)) return doc.body.innerHTML;
|
|
56
65
|
sanitizeNode(doc.body);
|
|
57
66
|
return doc.body.innerHTML;
|
|
58
67
|
}
|
|
68
|
+
function removeForbiddenElements(root) {
|
|
69
|
+
for (const tag of FORBIDDEN_TAGS) for (const el of Array.from(root.getElementsByTagName(tag))) el.remove();
|
|
70
|
+
}
|
|
71
|
+
function scrubUnsafeUrlAttributes(root) {
|
|
72
|
+
for (const el of Array.from(root.querySelectorAll("[href], [src]"))) {
|
|
73
|
+
const allowDataImage = el.tagName.toLowerCase() === "img";
|
|
74
|
+
for (const attr of URL_ATTRIBUTES) {
|
|
75
|
+
const value = el.getAttribute(attr);
|
|
76
|
+
if (value !== null && !isSafeUrl(value, allowDataImage)) el.removeAttribute(attr);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isSafeUrl(value, allowDataImage) {
|
|
81
|
+
const trimmed = value.trim().toLowerCase();
|
|
82
|
+
if (trimmed.startsWith("javascript:")) return false;
|
|
83
|
+
if (trimmed.startsWith("vbscript:")) return false;
|
|
84
|
+
if (trimmed.startsWith("data:")) return allowDataImage && trimmed.startsWith("data:image/");
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
59
87
|
function sanitizeNode(node) {
|
|
60
88
|
if (node.nodeType === Node.ELEMENT_NODE) sanitizeElement(node);
|
|
61
89
|
for (const child of Array.from(node.childNodes)) sanitizeNode(child);
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/utils/paste-sanitizer.ts","../src/core/create-paste-handler.ts","../src/email-editor/email-editor.tsx"],"sourcesContent":["/**\n * Sanitizes pasted HTML.\n * - From editor (has node-* classes): pass through as-is\n * - From external: strip all styles/classes, keep only semantic HTML\n */\n\n/**\n * Detects content from the Resend editor by checking for node-* class names.\n */\nconst EDITOR_CLASS_PATTERN = /class=\"[^\"]*node-/;\n\n/**\n * Attributes to preserve on specific elements for EXTERNAL content.\n * Only functional attributes - NO style or class.\n */\nconst PRESERVED_ATTRIBUTES: Record<string, string[]> = {\n a: ['href', 'target', 'rel'],\n img: ['src', 'alt', 'width', 'height'],\n td: ['colspan', 'rowspan'],\n th: ['colspan', 'rowspan', 'scope'],\n table: ['border', 'cellpadding', 'cellspacing'],\n '*': ['id'],\n};\n\nfunction isFromEditor(html: string): boolean {\n return EDITOR_CLASS_PATTERN.test(html);\n}\n\nexport function sanitizePastedHtml(html: string): string {\n if (isFromEditor(html)) {\n return html;\n }\n\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n sanitizeNode(doc.body);\n\n return doc.body.innerHTML;\n}\n\nfunction sanitizeNode(node: Node): void {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const el = node as HTMLElement;\n sanitizeElement(el);\n }\n\n for (const child of Array.from(node.childNodes)) {\n sanitizeNode(child);\n }\n}\n\nfunction sanitizeElement(el: HTMLElement): void {\n const tagName = el.tagName.toLowerCase();\n\n const allowedForTag = PRESERVED_ATTRIBUTES[tagName] || [];\n const allowedGlobal = PRESERVED_ATTRIBUTES['*'] || [];\n const allowed = new Set([...allowedForTag, ...allowedGlobal]);\n\n const attributesToRemove: string[] = [];\n\n for (const attr of Array.from(el.attributes)) {\n if (attr.name.startsWith('data-')) {\n attributesToRemove.push(attr.name);\n continue;\n }\n\n if (!allowed.has(attr.name)) {\n attributesToRemove.push(attr.name);\n }\n }\n\n for (const attr of attributesToRemove) {\n el.removeAttribute(attr);\n }\n}\n","import type { Extensions } from '@tiptap/core';\nimport { generateJSON } from '@tiptap/html';\nimport type { Slice } from '@tiptap/pm/model';\nimport type { EditorView } from '@tiptap/pm/view';\nimport { sanitizePastedHtml } from '../utils/paste-sanitizer';\n\nexport type PasteHandler = (\n payload: string | File,\n view: EditorView,\n) => boolean;\n\nexport function createPasteHandler({\n onPaste,\n extensions,\n}: {\n onPaste?: PasteHandler;\n extensions: Extensions;\n}) {\n return (view: EditorView, event: ClipboardEvent, slice: Slice): boolean => {\n const text = event.clipboardData?.getData('text/plain');\n\n if (text && onPaste?.(text, view)) {\n event.preventDefault();\n\n return true;\n }\n\n if (event.clipboardData?.files?.[0]) {\n const file = event.clipboardData.files[0];\n if (onPaste?.(file, view)) {\n event.preventDefault();\n\n return true;\n }\n }\n\n if (slice.content.childCount === 1) {\n return false;\n }\n\n if (event.clipboardData?.getData?.('text/html')) {\n event.preventDefault();\n const html = event.clipboardData.getData('text/html');\n\n const sanitizedHtml = sanitizePastedHtml(html);\n\n const jsonContent = generateJSON(sanitizedHtml, extensions);\n const node = view.state.schema.nodeFromJSON(jsonContent);\n\n const transaction = view.state.tr.replaceSelectionWith(node, false);\n view.dispatch(transaction);\n\n return true;\n }\n return false;\n };\n}\n","import type { Content, Editor, Extensions, JSONContent } from '@tiptap/core';\nimport {\n EditorProvider,\n type UseEditorOptions,\n useCurrentEditor,\n} from '@tiptap/react';\nimport {\n forwardRef,\n type ReactNode,\n type Ref,\n useEffect,\n useImperativeHandle,\n useLayoutEffect,\n useMemo,\n useRef,\n} from 'react';\nimport { createPasteHandler } from '../core/create-paste-handler';\nimport { composeReactEmail } from '../core/serializer/compose-react-email';\nimport { StarterKit } from '../extensions';\nimport { EmailTheming } from '../plugins/email-theming/extension';\nimport type { EditorThemeInput } from '../plugins/email-theming/types';\nimport { createImageExtension } from '../plugins/image/extension';\nimport { BubbleMenu } from '../ui/bubble-menu';\nimport { SlashCommandRoot } from '../ui/slash-command/root';\nimport '../ui/themes/default.css';\nimport { Placeholder } from '@tiptap/extension-placeholder';\n\nexport interface EmailEditorRef {\n getEmail: () => Promise<{ html: string; text: string }>;\n getEmailHTML: () => Promise<string>;\n getEmailText: () => Promise<string>;\n getJSON: () => JSONContent;\n editor: Editor | null;\n}\n\nexport interface EmailEditorProps {\n content?: Content;\n onUpdate?: (ref: EmailEditorRef) => void;\n onReady?: (ref: EmailEditorRef) => void;\n theme?: EditorThemeInput;\n editable?: boolean;\n placeholder?: string;\n bubbleMenu?: {\n hideWhenActiveNodes?: string[];\n hideWhenActiveMarks?: string[];\n };\n extensions?: Extensions;\n onUploadImage?: (file: File) => Promise<{ url: string }>;\n className?: string;\n children?: ReactNode;\n}\n\nfunction buildRef(editor: Editor | null): EmailEditorRef {\n return {\n getEmail: async () => {\n if (!editor) return { html: '', text: '' };\n return composeReactEmail({ editor });\n },\n getEmailHTML: async () => {\n if (!editor) return '';\n const result = await composeReactEmail({ editor });\n return result.html;\n },\n getEmailText: async () => {\n if (!editor) return '';\n const result = await composeReactEmail({ editor });\n return result.text;\n },\n getJSON: () => editor?.getJSON() ?? { type: 'doc', content: [] },\n editor,\n };\n}\n\nfunction RefBridge({\n editorRef,\n onUpdateRef,\n}: {\n editorRef: Ref<EmailEditorRef>;\n onUpdateRef: React.RefObject<((ref: EmailEditorRef) => void) | undefined>;\n}) {\n const { editor } = useCurrentEditor();\n\n const emailEditorRef = useMemo(() => buildRef(editor), [editor]);\n\n useImperativeHandle(editorRef, () => emailEditorRef, [emailEditorRef]);\n\n useEffect(() => {\n if (!editor) return;\n\n const handler = () => {\n onUpdateRef.current?.(emailEditorRef);\n };\n\n editor.on('update', handler);\n return () => {\n editor.off('update', handler);\n };\n }, [editor, emailEditorRef, onUpdateRef]);\n\n return null;\n}\n\nfunction EmailEditorReadyBridge({\n onReadyRef,\n}: {\n onReadyRef: React.RefObject<((ref: EmailEditorRef) => void) | undefined>;\n}) {\n const { editor } = useCurrentEditor();\n\n useLayoutEffect(() => {\n if (!editor) return;\n onReadyRef.current?.(buildRef(editor));\n }, [editor, onReadyRef]);\n\n return null;\n}\n\nexport const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(\n (\n {\n content,\n onUpdate,\n onReady,\n theme = 'basic',\n editable = true,\n placeholder,\n bubbleMenu,\n extensions: extensionsProp,\n onUploadImage,\n className,\n children,\n },\n ref,\n ) => {\n const onUpdateRef = useRef(onUpdate);\n onUpdateRef.current = onUpdate;\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n\n const imageExtension = useMemo(() => {\n if (!onUploadImage) return null;\n return createImageExtension({ uploadImage: onUploadImage });\n }, [onUploadImage]);\n\n const extensions = useMemo(() => {\n const base = extensionsProp ?? [\n StarterKit.configure(),\n Placeholder.configure({\n placeholder:\n placeholder ??\n (({ node }) => {\n // TODO: this heading placeholder is not working,\n // in part because styles are only targetting paragraphs,\n // but in part because of the way the content is rendered\n if (node.type.name === 'heading') {\n return `Heading ${node.attrs.level}`;\n }\n return \"Press '/' for commands\";\n }),\n includeChildren: true,\n }),\n EmailTheming.configure({ theme }),\n ];\n\n return imageExtension ? [...base, imageExtension] : base;\n }, [extensionsProp, theme, placeholder, imageExtension]);\n\n const editorProps: UseEditorOptions['editorProps'] = useMemo(\n () => ({\n handlePaste: createPasteHandler({\n extensions,\n }),\n }),\n [extensions],\n );\n\n return (\n <EditorProvider\n key={typeof theme === 'string' ? theme : JSON.stringify(theme)}\n extensions={extensions}\n content={content}\n editable={editable}\n immediatelyRender={false}\n editorProps={editorProps}\n editorContainerProps={{ className }}\n >\n <RefBridge editorRef={ref} onUpdateRef={onUpdateRef} />\n <EmailEditorReadyBridge onReadyRef={onReadyRef} />\n <BubbleMenu\n hideWhenActiveNodes={\n bubbleMenu?.hideWhenActiveNodes ?? ['button', 'horizontalRule']\n }\n hideWhenActiveMarks={bubbleMenu?.hideWhenActiveMarks ?? ['link']}\n />\n <BubbleMenu.LinkDefault />\n <BubbleMenu.ButtonDefault />\n <BubbleMenu.ImageDefault />\n <SlashCommandRoot />\n {children}\n </EditorProvider>\n );\n },\n);\n\nEmailEditor.displayName = 'EmailEditor';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/utils/paste-sanitizer.ts","../src/core/create-paste-handler.ts","../src/email-editor/email-editor.tsx"],"sourcesContent":["/**\n * Sanitizes pasted HTML.\n * - Always: drop dangerous elements (script, iframe, ...) and unsafe URL schemes (javascript:, ...).\n * - From editor (has node-* classes): preserve attributes so node identity round-trips.\n * - From external: strip all styles/classes, keep only semantic HTML.\n */\n\nconst EDITOR_CLASS_PATTERN = /class=\"[^\"]*node-/;\n\nconst FORBIDDEN_TAGS = new Set([\n 'script',\n 'iframe',\n 'object',\n 'embed',\n 'meta',\n 'base',\n]);\n\nconst URL_ATTRIBUTES = new Set(['href', 'src']);\n\n/**\n * Attributes to preserve on specific elements for EXTERNAL content.\n * Only functional attributes - NO style or class.\n */\nconst PRESERVED_ATTRIBUTES: Record<string, string[]> = {\n a: ['href', 'target', 'rel'],\n img: ['src', 'alt', 'width', 'height'],\n td: ['colspan', 'rowspan'],\n th: ['colspan', 'rowspan', 'scope'],\n table: ['border', 'cellpadding', 'cellspacing'],\n '*': ['id'],\n};\n\nfunction isFromEditor(html: string): boolean {\n return EDITOR_CLASS_PATTERN.test(html);\n}\n\nexport function sanitizePastedHtml(html: string): string {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n removeForbiddenElements(doc.body);\n scrubUnsafeUrlAttributes(doc.body);\n\n if (isFromEditor(html)) {\n return doc.body.innerHTML;\n }\n\n sanitizeNode(doc.body);\n\n return doc.body.innerHTML;\n}\n\nfunction removeForbiddenElements(root: Element): void {\n for (const tag of FORBIDDEN_TAGS) {\n for (const el of Array.from(root.getElementsByTagName(tag))) {\n el.remove();\n }\n }\n}\n\nfunction scrubUnsafeUrlAttributes(root: Element): void {\n for (const el of Array.from(\n root.querySelectorAll<HTMLElement>('[href], [src]'),\n )) {\n const allowDataImage = el.tagName.toLowerCase() === 'img';\n for (const attr of URL_ATTRIBUTES) {\n const value = el.getAttribute(attr);\n if (value !== null && !isSafeUrl(value, allowDataImage)) {\n el.removeAttribute(attr);\n }\n }\n }\n}\n\nfunction isSafeUrl(value: string, allowDataImage: boolean): boolean {\n const trimmed = value.trim().toLowerCase();\n if (trimmed.startsWith('javascript:')) return false;\n if (trimmed.startsWith('vbscript:')) return false;\n if (trimmed.startsWith('data:')) {\n return allowDataImage && trimmed.startsWith('data:image/');\n }\n return true;\n}\n\nfunction sanitizeNode(node: Node): void {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const el = node as HTMLElement;\n sanitizeElement(el);\n }\n\n for (const child of Array.from(node.childNodes)) {\n sanitizeNode(child);\n }\n}\n\nfunction sanitizeElement(el: HTMLElement): void {\n const tagName = el.tagName.toLowerCase();\n\n const allowedForTag = PRESERVED_ATTRIBUTES[tagName] || [];\n const allowedGlobal = PRESERVED_ATTRIBUTES['*'] || [];\n const allowed = new Set([...allowedForTag, ...allowedGlobal]);\n\n const attributesToRemove: string[] = [];\n\n for (const attr of Array.from(el.attributes)) {\n if (attr.name.startsWith('data-')) {\n attributesToRemove.push(attr.name);\n continue;\n }\n\n if (!allowed.has(attr.name)) {\n attributesToRemove.push(attr.name);\n }\n }\n\n for (const attr of attributesToRemove) {\n el.removeAttribute(attr);\n }\n}\n","import type { Extensions } from '@tiptap/core';\nimport { generateJSON } from '@tiptap/html';\nimport type { Slice } from '@tiptap/pm/model';\nimport type { EditorView } from '@tiptap/pm/view';\nimport { sanitizePastedHtml } from '../utils/paste-sanitizer';\n\nexport type PasteHandler = (\n payload: string | File,\n view: EditorView,\n) => boolean;\n\nexport function createPasteHandler({\n onPaste,\n extensions,\n}: {\n onPaste?: PasteHandler;\n extensions: Extensions;\n}) {\n return (view: EditorView, event: ClipboardEvent, slice: Slice): boolean => {\n const text = event.clipboardData?.getData('text/plain');\n\n if (text && onPaste?.(text, view)) {\n event.preventDefault();\n\n return true;\n }\n\n if (event.clipboardData?.files?.[0]) {\n const file = event.clipboardData.files[0];\n if (onPaste?.(file, view)) {\n event.preventDefault();\n\n return true;\n }\n }\n\n if (slice.content.childCount === 1) {\n return false;\n }\n\n if (event.clipboardData?.getData?.('text/html')) {\n event.preventDefault();\n const html = event.clipboardData.getData('text/html');\n\n const sanitizedHtml = sanitizePastedHtml(html);\n\n const jsonContent = generateJSON(sanitizedHtml, extensions);\n const node = view.state.schema.nodeFromJSON(jsonContent);\n\n const transaction = view.state.tr.replaceSelectionWith(node, false);\n view.dispatch(transaction);\n\n return true;\n }\n return false;\n };\n}\n","import type { Content, Editor, Extensions, JSONContent } from '@tiptap/core';\nimport {\n EditorProvider,\n type UseEditorOptions,\n useCurrentEditor,\n} from '@tiptap/react';\nimport {\n forwardRef,\n type ReactNode,\n type Ref,\n useEffect,\n useImperativeHandle,\n useLayoutEffect,\n useMemo,\n useRef,\n} from 'react';\nimport { createPasteHandler } from '../core/create-paste-handler';\nimport { composeReactEmail } from '../core/serializer/compose-react-email';\nimport { StarterKit } from '../extensions';\nimport { EmailTheming } from '../plugins/email-theming/extension';\nimport type { EditorThemeInput } from '../plugins/email-theming/types';\nimport { createImageExtension } from '../plugins/image/extension';\nimport { BubbleMenu } from '../ui/bubble-menu';\nimport { SlashCommandRoot } from '../ui/slash-command/root';\nimport '../ui/themes/default.css';\nimport { Placeholder } from '@tiptap/extension-placeholder';\n\nexport interface EmailEditorRef {\n getEmail: () => Promise<{ html: string; text: string }>;\n getEmailHTML: () => Promise<string>;\n getEmailText: () => Promise<string>;\n getJSON: () => JSONContent;\n editor: Editor | null;\n}\n\nexport interface EmailEditorProps {\n content?: Content;\n onUpdate?: (ref: EmailEditorRef) => void;\n onReady?: (ref: EmailEditorRef) => void;\n theme?: EditorThemeInput;\n editable?: boolean;\n placeholder?: string;\n bubbleMenu?: {\n hideWhenActiveNodes?: string[];\n hideWhenActiveMarks?: string[];\n };\n extensions?: Extensions;\n onUploadImage?: (file: File) => Promise<{ url: string }>;\n className?: string;\n children?: ReactNode;\n}\n\nfunction buildRef(editor: Editor | null): EmailEditorRef {\n return {\n getEmail: async () => {\n if (!editor) return { html: '', text: '' };\n return composeReactEmail({ editor });\n },\n getEmailHTML: async () => {\n if (!editor) return '';\n const result = await composeReactEmail({ editor });\n return result.html;\n },\n getEmailText: async () => {\n if (!editor) return '';\n const result = await composeReactEmail({ editor });\n return result.text;\n },\n getJSON: () => editor?.getJSON() ?? { type: 'doc', content: [] },\n editor,\n };\n}\n\nfunction RefBridge({\n editorRef,\n onUpdateRef,\n}: {\n editorRef: Ref<EmailEditorRef>;\n onUpdateRef: React.RefObject<((ref: EmailEditorRef) => void) | undefined>;\n}) {\n const { editor } = useCurrentEditor();\n\n const emailEditorRef = useMemo(() => buildRef(editor), [editor]);\n\n useImperativeHandle(editorRef, () => emailEditorRef, [emailEditorRef]);\n\n useEffect(() => {\n if (!editor) return;\n\n const handler = () => {\n onUpdateRef.current?.(emailEditorRef);\n };\n\n editor.on('update', handler);\n return () => {\n editor.off('update', handler);\n };\n }, [editor, emailEditorRef, onUpdateRef]);\n\n return null;\n}\n\nfunction EmailEditorReadyBridge({\n onReadyRef,\n}: {\n onReadyRef: React.RefObject<((ref: EmailEditorRef) => void) | undefined>;\n}) {\n const { editor } = useCurrentEditor();\n\n useLayoutEffect(() => {\n if (!editor) return;\n onReadyRef.current?.(buildRef(editor));\n }, [editor, onReadyRef]);\n\n return null;\n}\n\nexport const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(\n (\n {\n content,\n onUpdate,\n onReady,\n theme = 'basic',\n editable = true,\n placeholder,\n bubbleMenu,\n extensions: extensionsProp,\n onUploadImage,\n className,\n children,\n },\n ref,\n ) => {\n const onUpdateRef = useRef(onUpdate);\n onUpdateRef.current = onUpdate;\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n\n const imageExtension = useMemo(() => {\n if (!onUploadImage) return null;\n return createImageExtension({ uploadImage: onUploadImage });\n }, [onUploadImage]);\n\n const extensions = useMemo(() => {\n const base = extensionsProp ?? [\n StarterKit.configure(),\n Placeholder.configure({\n placeholder:\n placeholder ??\n (({ node }) => {\n // TODO: this heading placeholder is not working,\n // in part because styles are only targetting paragraphs,\n // but in part because of the way the content is rendered\n if (node.type.name === 'heading') {\n return `Heading ${node.attrs.level}`;\n }\n return \"Press '/' for commands\";\n }),\n includeChildren: true,\n }),\n EmailTheming.configure({ theme }),\n ];\n\n return imageExtension ? [...base, imageExtension] : base;\n }, [extensionsProp, theme, placeholder, imageExtension]);\n\n const editorProps: UseEditorOptions['editorProps'] = useMemo(\n () => ({\n handlePaste: createPasteHandler({\n extensions,\n }),\n }),\n [extensions],\n );\n\n return (\n <EditorProvider\n key={typeof theme === 'string' ? theme : JSON.stringify(theme)}\n extensions={extensions}\n content={content}\n editable={editable}\n immediatelyRender={false}\n editorProps={editorProps}\n editorContainerProps={{ className }}\n >\n <RefBridge editorRef={ref} onUpdateRef={onUpdateRef} />\n <EmailEditorReadyBridge onReadyRef={onReadyRef} />\n <BubbleMenu\n hideWhenActiveNodes={\n bubbleMenu?.hideWhenActiveNodes ?? ['button', 'horizontalRule']\n }\n hideWhenActiveMarks={bubbleMenu?.hideWhenActiveMarks ?? ['link']}\n />\n <BubbleMenu.LinkDefault />\n <BubbleMenu.ButtonDefault />\n <BubbleMenu.ImageDefault />\n <SlashCommandRoot />\n {children}\n </EditorProvider>\n );\n },\n);\n\nEmailEditor.displayName = 'EmailEditor';\n"],"mappings":"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"}
|