diff --git a/.gitignore b/.gitignore index 1807daab30..966ddd71e8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,6 @@ yarn-error.log* .env.test.local .env.production.local -# turbo .turbo +.worktrees .serena/ diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md new file mode 100644 index 0000000000..bdadb633d7 --- /dev/null +++ b/packages/editor/CHANGELOG.md @@ -0,0 +1,2 @@ +# @react-email/editor + diff --git a/packages/editor/examples/index.html b/packages/editor/examples/index.html new file mode 100644 index 0000000000..5903004c46 --- /dev/null +++ b/packages/editor/examples/index.html @@ -0,0 +1,12 @@ + + +
+ + +{description}
+This is a full-featured email editor combining all available components. Try selecting text, inserting columns, adding buttons, and switching themes.
+Check out our latest post on React Email for building better email templates.
+ +`; + +function ControlPanel() { + const { editor } = useCurrentEditor(); + const [html, setHtml] = useState(''); + const [exporting, setExporting] = useState(false); + + const handleExport = async () => { + if (!editor) return; + setExporting(true); + const result = await editor.getReactEmail(); + setHtml(result.html); + setExporting(false); + }; + + return ( +Click the button below to see its bubble menu. You can edit the button link and text.
+ +Use the slash command menu (type /) to insert more buttons.
+`; + +export function Buttons() { + return ( +Click on this link to see the link bubble menu. You can edit the URL, open the link, or unlink it.
+Try adding more links by selecting text and pressing Cmd+K.
+`; + +export function LinkEditing() { + return ( +]*>\s*Code\s*<\/code\s*>/s);
+ });
+
+ it('should not apply inline text styles to link-only marks', async () => {
+ const content = docWithGlobalContent([
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Link',
+ attrs: { style: 'color: red; font-size: 16px;' },
+ marks: [
+ {
+ type: 'link',
+ attrs: {
+ href: 'https://example.com',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const editor = createEditorWithContent(content);
+ const result = await composeReactEmail({
+ editor,
+ preview: '',
+ });
+
+ expect(result.html).toContain(' {
+ it('should render bullet lists using the extension renderer', async () => {
+ const content = docWithGlobalContent([
+ {
+ type: 'bulletList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'List item',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const editor = createEditorWithContent(content);
+ const result = await composeReactEmail({
+ editor,
+ preview: '',
+ });
+
+ expect(result.html).toContain(' {
+ const content = docWithGlobalContent([
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Hello',
+ },
+ {
+ type: 'hardBreak',
+ },
+ {
+ type: 'text',
+ text: 'World',
+ },
+ ],
+ },
+ ]);
+
+ const editor = createEditorWithContent(content);
+ const result = await composeReactEmail({
+ editor,
+ preview: '',
+ });
+
+ expect(result.html).toContain('
{
+ it('returns html and text when ReactEmail extension is registered', async () => {
+ const content = docWithGlobalContent([
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Hello from getReactEmail',
+ },
+ ],
+ },
+ ]);
+
+ const editor = createEditorWithContent(content, [ReactEmail]);
+ const result = await editor.getReactEmail({ preview: '' });
+
+ expect(result.html).toContain('Hello from getReactEmail');
+ expect(result.text).toContain('Hello from getReactEmail');
+ });
+
+ it('supports custom nodes with renderToReactEmail in config', async () => {
+ const CustomBlock = Node.create({
+ name: 'customBlock',
+ group: 'block',
+ content: 'inline*',
+ renderHTML({ HTMLAttributes }) {
+ return ['div', HTMLAttributes, 0];
+ },
+ renderToReactEmail({ children, style }) {
+ return {children};
+ },
+ });
+
+ const content = docWithGlobalContent([
+ {
+ type: 'customBlock',
+ content: [
+ {
+ type: 'text',
+ text: 'Custom content',
+ },
+ ],
+ },
+ ]);
+
+ const editor = createEditorWithContent(content, [ReactEmail, CustomBlock]);
+ const result = await editor.getReactEmail();
+
+ expect(result.html).toContain('Custom content');
+ expect(result.html).toContain('border:1px solid red');
+ });
+});
diff --git a/packages/editor/src/core/serializer/compose-react-email.tsx b/packages/editor/src/core/serializer/compose-react-email.tsx
new file mode 100644
index 0000000000..225515f154
--- /dev/null
+++ b/packages/editor/src/core/serializer/compose-react-email.tsx
@@ -0,0 +1,140 @@
+import { pretty, render, toPlainText } from '@react-email/components';
+import type { Editor, JSONContent } from '@tiptap/core';
+import { inlineCssToJs } from '../../utils/styles';
+import { DefaultBaseTemplate } from './default-base-template';
+import type { MarkRendererComponent, NodeRendererComponent } from './react-email';
+import type { SerializerPlugin } from './serializer-plugin';
+
+const NODES_WITH_INCREMENTED_CHILD_DEPTH = new Set([
+ 'bulletList',
+ 'orderedList',
+]);
+
+interface ComposeReactEmailResult {
+ html: string;
+ text: string;
+}
+
+/**
+ * @deprecated Use `editor.getReactEmail()` instead after adding the `ReactEmail` extension.
+ */
+export const composeReactEmail = async ({
+ editor,
+ preview,
+}: {
+ editor: Editor;
+ preview?: string;
+}): Promise => {
+ const data = editor.getJSON();
+ const extensions = editor.extensionManager.extensions;
+
+ const serializerPlugin = extensions
+ .map(
+ (ext) =>
+ (ext as { options?: { serializerPlugin?: SerializerPlugin } }).options
+ ?.serializerPlugin,
+ )
+ .filter((p) => Boolean(p))
+ .at(-1);
+
+ const typeToExtensionMap = Object.fromEntries(
+ extensions.map((extension) => [extension.name, extension]),
+ );
+
+ function parseContent(content: JSONContent[] | undefined, depth = 0) {
+ if (!content) {
+ return;
+ }
+
+ return content.map((node: JSONContent, index: number) => {
+ const style = serializerPlugin?.getNodeStyles(node, depth, editor) ?? {};
+
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+
+ if (!node.type) {
+ return null;
+ }
+
+ const emailNode = typeToExtensionMap[node.type];
+ const nodeRenderer = emailNode?.config?.renderToReactEmail as
+ | NodeRendererComponent
+ | undefined;
+ if (!emailNode || !nodeRenderer) {
+ return null;
+ }
+
+ const NodeComponent = nodeRenderer;
+ const childDepth = NODES_WITH_INCREMENTED_CHILD_DEPTH.has(node.type)
+ ? depth + 1
+ : depth;
+
+ let renderedNode: React.ReactNode = node.text ? (
+ node.text
+ ) : (
+
+ {parseContent(node.content, childDepth)}
+
+ );
+ if (node.marks) {
+ for (const mark of node.marks) {
+ const emailMark = typeToExtensionMap[mark.type];
+ const markRenderer = emailMark?.config?.renderToReactEmail as
+ | MarkRendererComponent
+ | undefined;
+ if (emailMark && markRenderer) {
+ const MarkComponent = markRenderer;
+ const markStyle =
+ serializerPlugin?.getNodeStyles(
+ {
+ type: mark.type,
+ attrs: mark.attrs ?? {},
+ },
+ depth,
+ editor,
+ ) ?? {};
+ renderedNode = (
+
+ {renderedNode}
+
+ );
+ }
+ }
+ }
+
+ return renderedNode;
+ });
+ }
+
+ const BaseTemplate = serializerPlugin?.BaseTemplate ?? DefaultBaseTemplate;
+
+ const parsedContent = parseContent(data.content);
+ const unformattedHtml = await render(
+
+ {parsedContent}
+ ,
+ );
+
+ const [prettyHtml, text] = await Promise.all([
+ pretty(unformattedHtml),
+ toPlainText(unformattedHtml),
+ ]);
+
+ return { html: prettyHtml, text };
+};
diff --git a/packages/editor/src/core/serializer/default-base-template.tsx b/packages/editor/src/core/serializer/default-base-template.tsx
new file mode 100644
index 0000000000..63904ee4ab
--- /dev/null
+++ b/packages/editor/src/core/serializer/default-base-template.tsx
@@ -0,0 +1,39 @@
+import { Body, Head, Html, Preview, Section } from '@react-email/components';
+import type * as React from 'react';
+
+type BaseTemplateProps = {
+ children: React.ReactNode;
+ previewText?: string;
+};
+
+export function DefaultBaseTemplate({
+ children,
+ previewText,
+}: BaseTemplateProps) {
+ return (
+
+
+
+
+
+
+
+ {previewText && previewText !== '' && {previewText} }
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/editor/src/core/serializer/react-email.tsx b/packages/editor/src/core/serializer/react-email.tsx
new file mode 100644
index 0000000000..f8fa1efcb2
--- /dev/null
+++ b/packages/editor/src/core/serializer/react-email.tsx
@@ -0,0 +1,183 @@
+import { pretty, render, toPlainText } from '@react-email/components';
+import {
+ type AnyExtension,
+ type Editor,
+ Extension,
+ type JSONContent,
+ type MarkConfig,
+ type NodeConfig,
+} from '@tiptap/core';
+import { inlineCssToJs } from '../../utils/styles';
+import { DefaultBaseTemplate } from './default-base-template';
+import type { SerializerPlugin } from './serializer-plugin';
+
+export type NodeRendererComponent = (props: {
+ node: JSONContent;
+ style: React.CSSProperties;
+ children?: React.ReactNode;
+ extension: AnyExtension;
+}) => React.ReactNode;
+
+export type SerializedMark = NonNullable[number];
+
+export type MarkRendererComponent = (props: {
+ mark: SerializedMark;
+ node: JSONContent;
+ style: React.CSSProperties;
+ children?: React.ReactNode;
+ extension: AnyExtension;
+}) => React.ReactNode;
+
+declare module '@tiptap/core' {
+ interface NodeConfig {
+ renderToReactEmail?: NodeRendererComponent;
+ }
+
+ interface MarkConfig {
+ renderToReactEmail?: MarkRendererComponent;
+ }
+
+ interface Editor {
+ getReactEmail(options?: {
+ preview?: string;
+ }): Promise<{ html: string; text: string }>;
+ }
+}
+
+const NODES_WITH_INCREMENTED_CHILD_DEPTH = new Set([
+ 'bulletList',
+ 'orderedList',
+]);
+
+function serializeToReactEmail(
+ editor: Editor,
+ preview?: string,
+): Promise<{ html: string; text: string }> {
+ const data = editor.getJSON();
+ const extensions = editor.extensionManager.extensions;
+
+ const serializerPlugin = extensions
+ .map(
+ (ext) =>
+ (ext as { options?: { serializerPlugin?: SerializerPlugin } }).options
+ ?.serializerPlugin,
+ )
+ .filter((p) => Boolean(p))
+ .at(-1);
+
+ const typeToExtensionMap = Object.fromEntries(
+ extensions.map((extension) => [extension.name, extension]),
+ );
+
+ function parseContent(content: JSONContent[] | undefined, depth = 0) {
+ if (!content) {
+ return;
+ }
+
+ return content.map((node: JSONContent, index: number) => {
+ const style = serializerPlugin?.getNodeStyles(node, depth, editor) ?? {};
+
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+
+ if (!node.type) {
+ return null;
+ }
+
+ const emailNode = typeToExtensionMap[node.type];
+ const nodeRenderer = emailNode?.config?.renderToReactEmail as
+ | NodeRendererComponent
+ | undefined;
+ if (!emailNode || !nodeRenderer) {
+ return null;
+ }
+
+ const NodeComponent = nodeRenderer;
+ const childDepth = NODES_WITH_INCREMENTED_CHILD_DEPTH.has(node.type)
+ ? depth + 1
+ : depth;
+
+ let renderedNode: React.ReactNode = node.text ? (
+ node.text
+ ) : (
+
+ {parseContent(node.content, childDepth)}
+
+ );
+ if (node.marks) {
+ for (const mark of node.marks) {
+ const emailMark = typeToExtensionMap[mark.type];
+ const markRenderer = emailMark?.config?.renderToReactEmail as
+ | MarkRendererComponent
+ | undefined;
+ if (emailMark && markRenderer) {
+ const MarkComponent = markRenderer;
+ const markStyle =
+ serializerPlugin?.getNodeStyles(
+ {
+ type: mark.type,
+ attrs: mark.attrs ?? {},
+ },
+ depth,
+ editor,
+ ) ?? {};
+ renderedNode = (
+
+ {renderedNode}
+
+ );
+ }
+ }
+ }
+
+ return renderedNode;
+ });
+ }
+
+ const BaseTemplate = serializerPlugin?.BaseTemplate ?? DefaultBaseTemplate;
+
+ const parsedContent = parseContent(data.content);
+
+ return render(
+
+ {parsedContent}
+ ,
+ ).then(async (unformattedHtml) => {
+ const [prettyHtml, text] = await Promise.all([
+ pretty(unformattedHtml),
+ toPlainText(unformattedHtml),
+ ]);
+ return { html: prettyHtml, text };
+ });
+}
+
+export const ReactEmail = Extension.create({
+ name: 'reactEmail',
+
+ onBeforeCreate() {
+ this.editor.getReactEmail = (options) => {
+ return serializeToReactEmail(this.editor, options?.preview);
+ };
+ },
+
+ onDestroy() {
+ delete (this.editor as Partial>)
+ .getReactEmail;
+ },
+});
diff --git a/packages/editor/src/core/serializer/serializer-plugin.ts b/packages/editor/src/core/serializer/serializer-plugin.ts
new file mode 100644
index 0000000000..72f610ee88
--- /dev/null
+++ b/packages/editor/src/core/serializer/serializer-plugin.ts
@@ -0,0 +1,14 @@
+import type { Editor, JSONContent } from '@tiptap/core';
+
+export interface SerializerPlugin {
+ getNodeStyles(
+ node: JSONContent,
+ depth: number,
+ editor: Editor,
+ ): React.CSSProperties;
+ BaseTemplate(props: {
+ previewText?: string;
+ children: React.ReactNode;
+ editor: Editor;
+ }): React.ReactNode;
+}
diff --git a/packages/editor/src/core/types.ts b/packages/editor/src/core/types.ts
new file mode 100644
index 0000000000..6761b5cb37
--- /dev/null
+++ b/packages/editor/src/core/types.ts
@@ -0,0 +1,76 @@
+/**
+ * Core type definitions for the editor.
+ * These types are used across the core module and can be imported by plugins and UI.
+ */
+
+import type { Attrs } from '@tiptap/pm/model';
+
+export type NodeClickedEvent = {
+ nodeType: string;
+ nodeAttrs: Attrs;
+ nodePos: { pos: number; inside: number };
+};
+
+/**
+ * A single placeholder item with all metadata needed for rendering.
+ * Used by the event bus and placeholder plugin.
+ */
+export interface PlaceholderItem {
+ /** Full placeholder string, e.g., '{{{contact.email}}}' */
+ id: string;
+ /** Display text shown in the dropdown, e.g., 'contact.email' */
+ displayKey: string;
+ /** Base key used for updates, e.g., 'contact' or the placeholder name */
+ placeholderKey: string;
+ /** Fallback value for the variable */
+ fallbackValue: string | null;
+ /** Category ID this variable belongs to */
+ category: string;
+ /** Placeholder type (string, number, boolean, object, list) - needed for loop item computation */
+ type?: string;
+ /** Override fallback warning for this specific item */
+ skipFallbackWarning?: boolean;
+}
+
+/**
+ * Custom placeholder definition used in the placeholders plugin.
+ */
+export type CustomPlaceholder = {
+ id: string;
+ key: string;
+ type: string;
+ fallback_value?: string | null;
+};
+
+/**
+ * Event map for the editor event bus.
+ */
+export interface EditorEventMap {
+ 'node-clicked': NodeClickedEvent;
+}
+
+/**
+ * Available event names in the editor event bus.
+ */
+export type EditorEventName = keyof EditorEventMap;
+
+/**
+ * Event handler function type.
+ */
+export type EventHandler = (
+ payload: EditorEventMap[T],
+) => void | Promise;
+
+/**
+ * Subscription handle returned when subscribing to events.
+ */
+export interface EventSubscription {
+ unsubscribe: () => void;
+}
+
+/**
+ * Options for dispatching events.
+ */
+export interface DispatchOptions {
+ target?: EventTarget;
+}
diff --git a/packages/editor/src/core/use-editor.ts b/packages/editor/src/core/use-editor.ts
new file mode 100644
index 0000000000..0dd23eec51
--- /dev/null
+++ b/packages/editor/src/core/use-editor.ts
@@ -0,0 +1,132 @@
+import type { Content, Editor as EditorClass, Extensions } from '@tiptap/core';
+import { UndoRedo } from '@tiptap/extensions';
+import {
+ type UseEditorOptions,
+ useEditorState,
+ useEditor as useTipTapEditor,
+} from '@tiptap/react';
+import * as React from 'react';
+import { StarterKit } from '../extensions';
+import { createDropHandler } from './create-drop-handler';
+import {
+ createPasteHandler,
+ type PasteHandler,
+ type UploadImageHandler,
+} from './create-paste-handler';
+import { isDocumentVisuallyEmpty } from './is-document-visually-empty';
+
+const COLLABORATION_EXTENSION_NAMES = new Set([
+ 'liveblocksExtension',
+ 'collaboration',
+]);
+
+function hasCollaborationExtension(exts: Extensions): boolean {
+ return exts.some((ext) => COLLABORATION_EXTENSION_NAMES.has(ext.name));
+}
+
+type Merge = A & Omit;
+
+export function useEditor({
+ content,
+ extensions = [],
+ onUpdate,
+ onPaste,
+ onUploadImage,
+ onReady,
+ editable = true,
+ ...rest
+}: Merge<
+ {
+ content: Content;
+ extensions?: Extensions;
+ onUpdate?: (
+ editor: EditorClass,
+ transaction: { getMeta: (key: string) => unknown },
+ ) => void;
+ onPaste?: PasteHandler;
+ onUploadImage?: UploadImageHandler;
+ onReady?: (editor: EditorClass | null) => void;
+ editable?: boolean;
+ },
+ UseEditorOptions
+>) {
+ const [contentError, setContentError] = React.useState(null);
+
+ const isCollaborative = hasCollaborationExtension(extensions);
+
+ const effectiveExtensions: Extensions = React.useMemo(
+ () => [
+ StarterKit,
+ // Collaboration extensions handle their own undo/redo history,
+ // so we only add TipTap's History extension for non-collaborative editors.
+ ...(isCollaborative ? [] : [UndoRedo]),
+ ...extensions,
+ ],
+ [extensions, isCollaborative],
+ );
+
+ const editor = useTipTapEditor({
+ content: isCollaborative ? undefined : content,
+ extensions: effectiveExtensions,
+ editable,
+ immediatelyRender: false,
+ enableContentCheck: true,
+ onContentError({ editor, error, disableCollaboration }) {
+ disableCollaboration();
+ setContentError(error);
+ console.error(error);
+ editor.setEditable(false);
+ },
+ onCreate({ editor }) {
+ onReady?.(editor);
+ },
+ onUpdate({ editor, transaction }) {
+ onUpdate?.(editor, transaction);
+ },
+ editorProps: {
+ handleDOMEvents: {
+ // Keep link behavior interception for view mode only.
+ click: (view, event) => {
+ if (!view.editable) {
+ const target = event.target as HTMLElement;
+ const link = target.closest('a');
+ if (link) {
+ event.preventDefault();
+ return true;
+ }
+ }
+ return false;
+ },
+ },
+ handlePaste: createPasteHandler({
+ onPaste,
+ onUploadImage,
+ extensions: effectiveExtensions,
+ }),
+ handleDrop: createDropHandler({
+ onPaste,
+ onUploadImage,
+ }),
+ },
+ ...rest,
+ });
+
+ const isEditorEmpty = useEditorState({
+ editor,
+ selector: (context) => {
+ if (!context.editor) {
+ return true;
+ }
+
+ return isDocumentVisuallyEmpty(context.editor.state.doc);
+ },
+ });
+
+ return {
+ editor,
+ isEditorEmpty: isEditorEmpty ?? true,
+ extensions: effectiveExtensions,
+ contentError,
+ isCollaborative,
+ };
+}
diff --git a/packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap
new file mode 100644
index 0000000000..14fc817cba
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap
@@ -0,0 +1,9 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Body Node > renders React Email properly 1`] = `
+"
+
+Body content
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap
new file mode 100644
index 0000000000..c58b97f35e
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap
@@ -0,0 +1,36 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EditorButton Node > renders React Email properly 1`] = `
+"
+
+
+
+
+
+ Click me
+
+
+
+
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap
new file mode 100644
index 0000000000..73001452bf
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap
@@ -0,0 +1,10 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CodeBlockPrism Node > renders React Email properly 1`] = `
+"
+
+const x = 1;
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap
new file mode 100644
index 0000000000..dda9bea9f4
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap
@@ -0,0 +1,124 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Column Variants > renders ColumnsColumn with custom width 1`] = `
+"
+
+
+ Column content
+
+
+"
+`;
+
+exports[`Column Variants > renders ColumnsColumn with inline styles 1`] = `
+"
+
+
+ Content
+
+
+"
+`;
+
+exports[`Column Variants > renders FourColumns with 4 column children 1`] = `
+"
+
+
+
+
+ A
+ B
+ C
+ D
+
+
+
+
+"
+`;
+
+exports[`Column Variants > renders ThreeColumns with 3 column children 1`] = `
+"
+
+
+
+
+
+ Column A
+
+
+ Column B
+
+
+ Column C
+
+
+
+
+
+"
+`;
+
+exports[`Column Variants > renders TwoColumns with 2 column children 1`] = `
+"
+
+
+
+
+
+ Column A
+
+
+ Column B
+
+
+
+
+
+"
+`;
+
+exports[`Column Variants > renders column parent with inline styles 1`] = `
+"
+
+
+
+
+ Content
+
+
+
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap
new file mode 100644
index 0000000000..de3065c56d
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap
@@ -0,0 +1,9 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Div Node > renders React Email properly 1`] = `
+"
+
+Div content
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/divider.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/divider.spec.tsx.snap
new file mode 100644
index 0000000000..f8f3f0fe1c
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/divider.spec.tsx.snap
@@ -0,0 +1,11 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Divider Node > renders React Email properly 1`] = `
+"
+
+
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/heading.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/heading.spec.tsx.snap
new file mode 100644
index 0000000000..8eef7eb036
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/heading.spec.tsx.snap
@@ -0,0 +1,10 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Heading Node > renders React Email properly 1`] = `
+"
+
+
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap
new file mode 100644
index 0000000000..8a9a91ea9e
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap
@@ -0,0 +1,23 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Section Node > renders React Email properly 1`] = `
+"
+
+
+
+
+ Section content
+
+
+
+
+"
+`;
diff --git a/packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap
new file mode 100644
index 0000000000..a190008d60
--- /dev/null
+++ b/packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap
@@ -0,0 +1,42 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Table Nodes > renders Table React Email properly 1`] = `
+"
+
+
+
+
+ Table content
+
+
+
+
+"
+`;
+
+exports[`Table Nodes > renders TableCell React Email properly 1`] = `
+"
+
+
+ Cell content
+
+
+"
+`;
+
+exports[`Table Nodes > renders TableRow React Email properly 1`] = `
+"
+
+
+ Row content
+
+
+"
+`;
diff --git a/packages/editor/src/extensions/alignment-attribute.tsx b/packages/editor/src/extensions/alignment-attribute.tsx
new file mode 100644
index 0000000000..7a449b322a
--- /dev/null
+++ b/packages/editor/src/extensions/alignment-attribute.tsx
@@ -0,0 +1,102 @@
+import { Extension } from '@tiptap/core';
+
+export interface AlignmentOptions {
+ types: string[];
+ alignments: string[];
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ alignment: {
+ /**
+ * Set the text align attribute
+ */
+ setAlignment: (alignment: string) => ReturnType;
+ };
+ }
+}
+
+export const AlignmentAttribute = Extension.create({
+ name: 'alignmentAttribute',
+
+ addOptions() {
+ return {
+ types: [],
+ alignments: ['left', 'center', 'right', 'justify'],
+ };
+ },
+
+ addGlobalAttributes() {
+ return [
+ {
+ types: this.options.types,
+ attributes: {
+ alignment: {
+ parseHTML: (element) => {
+ const explicitAlign =
+ element.getAttribute('align') ||
+ element.getAttribute('alignment') ||
+ element.style.textAlign;
+ if (
+ explicitAlign &&
+ this.options.alignments.includes(explicitAlign)
+ ) {
+ return explicitAlign;
+ }
+
+ // Return null to let natural inheritance work
+ return null;
+ },
+ renderHTML: (attributes) => {
+ if (attributes.alignment === 'left') {
+ return {};
+ }
+
+ return { alignment: attributes.alignment };
+ },
+ },
+ },
+ },
+ ];
+ },
+
+ addCommands() {
+ return {
+ setAlignment:
+ (alignment) =>
+ ({ commands }) => {
+ if (!this.options.alignments.includes(alignment)) {
+ return false;
+ }
+
+ return this.options.types.every((type) =>
+ commands.updateAttributes(type, { alignment }),
+ );
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: () => {
+ // Get the current node's alignment
+ const { from } = this.editor.state.selection;
+ const node = this.editor.state.doc.nodeAt(from);
+ const currentAlignment = node?.attrs?.alignment;
+
+ if (currentAlignment) {
+ requestAnimationFrame(() => {
+ // Preserve the current alignment when creating new nodes
+ this.editor.commands.setAlignment(currentAlignment);
+ });
+ }
+
+ return false;
+ },
+ 'Mod-Shift-l': () => this.editor.commands.setAlignment('left'),
+ 'Mod-Shift-e': () => this.editor.commands.setAlignment('center'),
+ 'Mod-Shift-r': () => this.editor.commands.setAlignment('right'),
+ 'Mod-Shift-j': () => this.editor.commands.setAlignment('justify'),
+ };
+ },
+});
diff --git a/packages/editor/src/extensions/blockquote.tsx b/packages/editor/src/extensions/blockquote.tsx
new file mode 100644
index 0000000000..e2bdd37aea
--- /dev/null
+++ b/packages/editor/src/extensions/blockquote.tsx
@@ -0,0 +1,23 @@
+import type { BlockquoteOptions } from '@tiptap/extension-blockquote';
+import BlockquoteBase from '@tiptap/extension-blockquote';
+import { getTextAlignment } from '../utils/get-text-alignment';
+import { inlineCssToJs } from '../utils/styles';
+
+export { type BlockquoteOptions };
+
+export const Blockquote = BlockquoteBase.extend({
+ renderToReactEmail({ children, node, style }) {
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/body.spec.tsx b/packages/editor/src/extensions/body.spec.tsx
new file mode 100644
index 0000000000..1868bf9f19
--- /dev/null
+++ b/packages/editor/src/extensions/body.spec.tsx
@@ -0,0 +1,30 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Body } from './body';
+
+// Resolved style matching snapshot: reset only (no body-specific styles in snapshot)
+const bodyStyle = { ...DEFAULT_STYLES.reset };
+
+describe('Body Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = Body.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Body content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/body.tsx b/packages/editor/src/extensions/body.tsx
new file mode 100644
index 0000000000..81b8401105
--- /dev/null
+++ b/packages/editor/src/extensions/body.tsx
@@ -0,0 +1,76 @@
+import { mergeAttributes, Node } from '@tiptap/core';
+import {
+ COMMON_HTML_ATTRIBUTES,
+ createStandardAttributes,
+ LAYOUT_ATTRIBUTES,
+} from '../utils/attribute-helpers';
+import { inlineCssToJs } from '../utils/styles';
+
+export interface BodyOptions {
+ HTMLAttributes: Record;
+}
+
+export const Body = Node.create({
+ name: 'body',
+
+ group: 'block',
+
+ content: 'block+',
+
+ defining: true,
+ isolating: true,
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...COMMON_HTML_ATTRIBUTES,
+ ...LAYOUT_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'body',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ // Preserve all attributes
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/bold.spec.tsx b/packages/editor/src/extensions/bold.spec.tsx
new file mode 100644
index 0000000000..2b63c99f41
--- /dev/null
+++ b/packages/editor/src/extensions/bold.spec.tsx
@@ -0,0 +1,32 @@
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import { Bold } from './bold';
+
+function createEditor(content?: string) {
+ return new Editor({
+ extensions: [StarterKit.configure({ bold: false }), Bold],
+ content: content ?? 'hello world
',
+ });
+}
+
+describe('Bold', () => {
+ it('parses semantic bold elements', () => {
+ const editor = createEditor('hello world
');
+
+ editor.commands.setTextSelection({ from: 1, to: 6 });
+
+ expect(editor.isActive('bold')).toBe(true);
+ editor.destroy();
+ });
+
+ it('does not infer bold from font-weight styles alone', () => {
+ const editor = createEditor(
+ 'hello world
',
+ );
+
+ editor.commands.setTextSelection({ from: 1, to: 6 });
+
+ expect(editor.isActive('bold')).toBe(false);
+ editor.destroy();
+ });
+});
diff --git a/packages/editor/src/extensions/bold.tsx b/packages/editor/src/extensions/bold.tsx
new file mode 100644
index 0000000000..0eb949f644
--- /dev/null
+++ b/packages/editor/src/extensions/bold.tsx
@@ -0,0 +1,29 @@
+import type { BoldOptions as TipTapBoldOptions } from '@tiptap/extension-bold';
+import BoldBase from '@tiptap/extension-bold';
+
+export type BoldOptions = TipTapBoldOptions;
+
+const BoldWithoutFontWeightInference = BoldBase.extend({
+ parseHTML() {
+ return [
+ {
+ tag: 'strong',
+ },
+ {
+ tag: 'b',
+ getAttrs: (node) =>
+ (node as HTMLElement).style.fontWeight !== 'normal' && null,
+ },
+ {
+ style: 'font-weight=400',
+ clearMark: (mark) => mark.type.name === this.name,
+ },
+ ];
+ },
+});
+
+export const Bold = BoldWithoutFontWeightInference.extend({
+ renderToReactEmail({ children, style }) {
+ return {children};
+ },
+});
diff --git a/packages/editor/src/extensions/bullet-list.tsx b/packages/editor/src/extensions/bullet-list.tsx
new file mode 100644
index 0000000000..1f473f4a0a
--- /dev/null
+++ b/packages/editor/src/extensions/bullet-list.tsx
@@ -0,0 +1,21 @@
+import type { BulletListOptions } from '@tiptap/extension-bullet-list';
+import BulletListBase from '@tiptap/extension-bullet-list';
+import { inlineCssToJs } from '../utils/styles';
+
+export { type BulletListOptions };
+
+export const BulletList = BulletListBase.extend({
+ renderToReactEmail({ children, node, style }) {
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/button.spec.tsx b/packages/editor/src/extensions/button.spec.tsx
new file mode 100644
index 0000000000..3cb11e69ad
--- /dev/null
+++ b/packages/editor/src/extensions/button.spec.tsx
@@ -0,0 +1,31 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Button } from './button';
+
+const buttonStyle = { ...DEFAULT_STYLES.reset, ...DEFAULT_STYLES.button };
+
+describe('EditorButton Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = Button.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Click me
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/button.tsx b/packages/editor/src/extensions/button.tsx
new file mode 100644
index 0000000000..b67ebcd75b
--- /dev/null
+++ b/packages/editor/src/extensions/button.tsx
@@ -0,0 +1,129 @@
+import {
+ Column,
+ Button as ReactEmailButton,
+ Row,
+} from '@react-email/components';
+import { mergeAttributes, Node } from '@tiptap/core';
+import { inlineCssToJs } from '../utils/styles';
+
+export interface EditorButtonOptions {
+ HTMLAttributes: Record;
+ [key: string]: unknown;
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ button: {
+ setButton: () => ReturnType;
+ updateButton: (attributes: Record) => ReturnType;
+ };
+ }
+}
+
+export const Button = Node.create({
+ name: 'button',
+ group: 'block',
+ content: 'inline*',
+ defining: true,
+ draggable: true,
+ marks: 'bold',
+
+ addAttributes() {
+ return {
+ class: {
+ default: 'button',
+ },
+ href: {
+ default: '#',
+ },
+ alignment: {
+ default: 'left',
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'a[data-id="react-email-button"]',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ // Preserve all attributes
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ mergeAttributes({
+ class: `align-${HTMLAttributes?.alignment}`,
+ }),
+ [
+ 'a',
+ mergeAttributes({
+ class: `node-button ${HTMLAttributes?.class}`,
+ style: HTMLAttributes?.style,
+ 'data-id': 'react-email-button',
+ 'data-href': HTMLAttributes?.href,
+ }),
+ 0,
+ ],
+ ];
+ },
+
+ addCommands() {
+ return {
+ updateButton:
+ (attributes) =>
+ ({ commands }) => {
+ return commands.updateAttributes('button', attributes);
+ },
+
+ setButton:
+ () =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: 'button',
+ content: [
+ {
+ type: 'text',
+ text: 'Button',
+ },
+ ],
+ });
+ },
+ };
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/class-attribute.tsx b/packages/editor/src/extensions/class-attribute.tsx
new file mode 100644
index 0000000000..b871383199
--- /dev/null
+++ b/packages/editor/src/extensions/class-attribute.tsx
@@ -0,0 +1,80 @@
+import { Extension } from '@tiptap/core';
+
+export interface ClassAttributeOptions {
+ types: string[];
+ class: string[];
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ class: {
+ /**
+ * Set the class attribute
+ */
+ setClass: (classList: string) => ReturnType;
+ /**
+ * Unset the class attribute
+ */
+ unsetClass: () => ReturnType;
+ };
+ }
+}
+
+export const ClassAttribute = Extension.create({
+ name: 'classAttribute',
+
+ addOptions() {
+ return {
+ types: [],
+ class: [],
+ };
+ },
+
+ addGlobalAttributes() {
+ return [
+ {
+ types: this.options.types,
+ attributes: {
+ class: {
+ default: '',
+ parseHTML: (element) => element.className || '',
+ renderHTML: (attributes) => {
+ return attributes.class ? { class: attributes.class } : {};
+ },
+ },
+ },
+ },
+ ];
+ },
+
+ addCommands() {
+ return {
+ unsetClass:
+ () =>
+ ({ commands }) => {
+ return this.options.types.every((type) =>
+ commands.resetAttributes(type, 'class'),
+ );
+ },
+ setClass:
+ (classList: string) =>
+ ({ commands }) => {
+ return this.options.types.every((type) =>
+ commands.updateAttributes(type, { class: classList }),
+ );
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: ({ editor }) => {
+ requestAnimationFrame(() => {
+ editor.commands.resetAttributes('paragraph', 'class');
+ });
+
+ return false;
+ },
+ };
+ },
+});
diff --git a/packages/editor/src/extensions/code-block.spec.tsx b/packages/editor/src/extensions/code-block.spec.tsx
new file mode 100644
index 0000000000..5c2d4a8be7
--- /dev/null
+++ b/packages/editor/src/extensions/code-block.spec.tsx
@@ -0,0 +1,56 @@
+import { dracula, render } from '@react-email/components';
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { CodeBlockPrism } from './code-block';
+
+// Resolved style matching snapshot: codeBlock only, no reset (theme provides padding/margin)
+const codeBlockStyle = { ...DEFAULT_STYLES.codeBlock };
+
+describe('CodeBlockPrism Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = CodeBlockPrism.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('does not modify the theme', async () => {
+ const Component = CodeBlockPrism.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+
+ const originalTheme = structuredClone(dracula);
+ await render(
+ ,
+ { pretty: true },
+ );
+ expect(dracula).toStrictEqual(originalTheme);
+ });
+});
diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx
new file mode 100644
index 0000000000..a400db1729
--- /dev/null
+++ b/packages/editor/src/extensions/code-block.tsx
@@ -0,0 +1,179 @@
+import * as ReactEmailComponents from '@react-email/components';
+import {
+ type PrismLanguage,
+ CodeBlock as ReactEmailCodeBlock,
+} from '@react-email/components';
+import { mergeAttributes } from '@tiptap/core';
+import type { CodeBlockOptions } from '@tiptap/extension-code-block';
+import CodeBlock from '@tiptap/extension-code-block';
+import { TextSelection } from '@tiptap/pm/state';
+import { PrismPlugin } from './prism-plugin';
+
+export interface CodeBlockPrismOptions extends CodeBlockOptions {
+ defaultLanguage: string;
+ defaultTheme: string;
+}
+
+export const CodeBlockPrism = CodeBlock.extend({
+ addOptions(): CodeBlockPrismOptions {
+ return {
+ languageClassPrefix: 'language-',
+ exitOnTripleEnter: false,
+ exitOnArrowDown: false,
+ enableTabIndentation: true,
+ tabSize: 2,
+ defaultLanguage: 'javascript',
+ defaultTheme: 'default',
+ HTMLAttributes: {},
+ };
+ },
+
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ language: {
+ default: this.options.defaultLanguage,
+ parseHTML: (element: HTMLElement | null) => {
+ if (!element) {
+ return null;
+ }
+ const { languageClassPrefix } = this.options;
+ if (!languageClassPrefix) {
+ return null;
+ }
+ const classNames = [
+ ...(element.firstElementChild?.classList || []),
+ ];
+ const languages = classNames
+ .filter((className) =>
+ className.startsWith(languageClassPrefix || ''),
+ )
+ .map((className) => className.replace(languageClassPrefix, ''));
+ const language = languages[0];
+
+ if (!language) {
+ return null;
+ }
+
+ return language;
+ },
+ rendered: false,
+ },
+ theme: {
+ default: this.options.defaultTheme,
+ rendered: false,
+ },
+ };
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ return [
+ 'pre',
+ mergeAttributes(
+ this.options.HTMLAttributes,
+ HTMLAttributes,
+ {
+ class: node.attrs.language
+ ? `${this.options.languageClassPrefix}${node.attrs.language}`
+ : null,
+ },
+ { 'data-theme': node.attrs.theme },
+ ),
+ [
+ 'code',
+ {
+ class: node.attrs.language
+ ? `${this.options.languageClassPrefix}${node.attrs.language} node-codeTag`
+ : 'node-codeTag',
+ },
+ 0,
+ ],
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ ...this.parent?.(),
+ 'Mod-a': ({ editor }) => {
+ const { state } = editor;
+ const { selection } = state;
+ const { $from } = selection;
+
+ for (let depth = $from.depth; depth >= 1; depth--) {
+ if ($from.node(depth).type.name === this.name) {
+ const blockStart = $from.start(depth);
+ const blockEnd = $from.end(depth);
+
+ const alreadyFullySelected =
+ selection.from === blockStart && selection.to === blockEnd;
+ if (alreadyFullySelected) {
+ return false;
+ }
+
+ const tr = state.tr.setSelection(
+ TextSelection.create(state.doc, blockStart, blockEnd),
+ );
+ editor.view.dispatch(tr);
+ return true;
+ }
+ }
+
+ return false;
+ },
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ ...(this.parent?.() || []),
+ PrismPlugin({
+ name: this.name,
+ defaultLanguage: this.options.defaultLanguage,
+ defaultTheme: this.options.defaultTheme,
+ }),
+ ];
+ },
+
+ renderToReactEmail({ node, style }) {
+ const language = node.attrs?.language
+ ? `${node.attrs.language}`
+ : 'javascript';
+
+ // @ts-expect-error -- @react-email/components does not export theme objects by name; dynamic access needed for user-selected themes
+ const userTheme = ReactEmailComponents[node.attrs?.theme];
+
+ // Without theme, render a gray code block
+ const theme = userTheme
+ ? {
+ ...userTheme,
+ base: {
+ ...userTheme.base,
+ borderRadius: '0.125rem',
+ padding: '0.75rem 1rem',
+ },
+ }
+ : {
+ base: {
+ color: '#1e293b',
+ background: '#f1f5f9',
+ lineHeight: '1.5',
+ fontFamily:
+ '"Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace',
+ padding: '0.75rem 1rem',
+ borderRadius: '0.125rem',
+ },
+ };
+
+ return (
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/code.tsx b/packages/editor/src/extensions/code.tsx
new file mode 100644
index 0000000000..4408f7066c
--- /dev/null
+++ b/packages/editor/src/extensions/code.tsx
@@ -0,0 +1,12 @@
+import CodeBase from '@tiptap/extension-code';
+import { inlineCssToJs } from '../utils/styles';
+
+export const Code = CodeBase.extend({
+ renderToReactEmail({ children, node, style }) {
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/columns.spec.tsx b/packages/editor/src/extensions/columns.spec.tsx
new file mode 100644
index 0000000000..aa938a91d0
--- /dev/null
+++ b/packages/editor/src/extensions/columns.spec.tsx
@@ -0,0 +1,183 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import {
+ ColumnsColumn,
+ FourColumns,
+ ThreeColumns,
+ TwoColumns,
+} from './columns';
+
+const columnsStyle = { ...DEFAULT_STYLES.reset };
+
+describe('Column Variants', () => {
+ it('renders TwoColumns with 2 column children', async () => {
+ const Parent = TwoColumns.config.renderToReactEmail;
+ const Child = ColumnsColumn.config.renderToReactEmail;
+
+ expect(
+ await render(
+
+
+ Column A
+
+
+ Column B
+
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders ThreeColumns with 3 column children', async () => {
+ const Parent = ThreeColumns.config.renderToReactEmail;
+ const Child = ColumnsColumn.config.renderToReactEmail;
+
+ expect(
+ await render(
+
+
+ Column A
+
+
+ Column B
+
+
+ Column C
+
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders FourColumns with 4 column children', async () => {
+ const Parent = FourColumns.config.renderToReactEmail;
+ const Child = ColumnsColumn.config.renderToReactEmail;
+
+ expect(
+ await render(
+
+
+ A
+
+
+ B
+
+
+ C
+
+
+ D
+
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders ColumnsColumn with custom width', async () => {
+ const Component = ColumnsColumn.config.renderToReactEmail;
+
+ expect(
+ await render(
+
+ Column content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders column parent with inline styles', async () => {
+ const Component = TwoColumns.config.renderToReactEmail;
+
+ expect(
+ await render(
+
+ Content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders ColumnsColumn with inline styles', async () => {
+ const Component = ColumnsColumn.config.renderToReactEmail;
+
+ expect(
+ await render(
+
+ Content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/columns.tsx b/packages/editor/src/extensions/columns.tsx
new file mode 100644
index 0000000000..c8ac23adcb
--- /dev/null
+++ b/packages/editor/src/extensions/columns.tsx
@@ -0,0 +1,264 @@
+import { Column, Row } from '@react-email/components';
+import { type CommandProps, mergeAttributes, Node } from '@tiptap/core';
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
+import { TextSelection } from '@tiptap/pm/state';
+import {
+ COMMON_HTML_ATTRIBUTES,
+ createStandardAttributes,
+ LAYOUT_ATTRIBUTES,
+} from '../utils/attribute-helpers';
+import { inlineCssToJs } from '../utils/styles';
+
+declare module '@tiptap/core' {
+ interface Commands {
+ columns: {
+ insertColumns: (count: 2 | 3 | 4) => ReturnType;
+ };
+ }
+}
+
+export const COLUMN_PARENT_TYPES = [
+ 'twoColumns',
+ 'threeColumns',
+ 'fourColumns',
+] as const;
+
+const COLUMN_PARENT_SET = new Set(COLUMN_PARENT_TYPES);
+
+export const MAX_COLUMNS_DEPTH = 3;
+
+export function getColumnsDepth(doc: ProseMirrorNode, from: number): number {
+ const $from = doc.resolve(from);
+ let depth = 0;
+ for (let d = $from.depth; d > 0; d--) {
+ if (COLUMN_PARENT_SET.has($from.node(d).type.name)) {
+ depth++;
+ }
+ }
+ return depth;
+}
+
+interface ColumnsVariantConfig {
+ name: (typeof COLUMN_PARENT_TYPES)[number];
+ columnCount: number;
+ content: string;
+ dataType: string;
+}
+
+const VARIANTS: ColumnsVariantConfig[] = [
+ {
+ name: 'twoColumns',
+ columnCount: 2,
+ content: 'columnsColumn columnsColumn',
+ dataType: 'two-columns',
+ },
+ {
+ name: 'threeColumns',
+ columnCount: 3,
+ content: 'columnsColumn columnsColumn columnsColumn',
+ dataType: 'three-columns',
+ },
+ {
+ name: 'fourColumns',
+ columnCount: 4,
+ content: 'columnsColumn{4}',
+ dataType: 'four-columns',
+ },
+];
+
+const NODE_TYPE_MAP: Record = {
+ 2: 'twoColumns',
+ 3: 'threeColumns',
+ 4: 'fourColumns',
+};
+
+function createColumnsNode(
+ config: ColumnsVariantConfig,
+ includeCommands: boolean,
+) {
+ return Node.create({
+ name: config.name,
+ group: 'block',
+ content: config.content,
+ isolating: true,
+ defining: true,
+
+ addAttributes() {
+ return createStandardAttributes([
+ ...LAYOUT_ATTRIBUTES,
+ ...COMMON_HTML_ATTRIBUTES,
+ ]);
+ },
+
+ parseHTML() {
+ return [{ tag: `div[data-type="${config.dataType}"]` }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ mergeAttributes(
+ { 'data-type': config.dataType, class: 'node-columns' },
+ HTMLAttributes,
+ ),
+ 0,
+ ];
+ },
+
+ ...(includeCommands && {
+ addCommands() {
+ return {
+ insertColumns:
+ (count: 2 | 3 | 4) =>
+ ({
+ commands,
+ state,
+ }: CommandProps & {
+ state: { doc: ProseMirrorNode; selection: { from: number } };
+ }) => {
+ if (
+ getColumnsDepth(state.doc, state.selection.from) >=
+ MAX_COLUMNS_DEPTH
+ ) {
+ return false;
+ }
+ const nodeType = NODE_TYPE_MAP[count];
+ const children = Array.from({ length: count }, () => ({
+ type: 'columnsColumn',
+ content: [{ type: 'paragraph', content: [] }],
+ }));
+ return commands.insertContent({
+ type: nodeType,
+ content: children,
+ });
+ },
+ };
+ },
+ }),
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ return (
+
+ {children}
+
+ );
+ },
+ });
+}
+
+export const TwoColumns = createColumnsNode(VARIANTS[0], true);
+export const ThreeColumns = createColumnsNode(VARIANTS[1], false);
+export const FourColumns = createColumnsNode(VARIANTS[2], false);
+
+export const ColumnsColumn = Node.create({
+ name: 'columnsColumn',
+ group: 'columnsColumn',
+ content: 'block+',
+ isolating: true,
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...LAYOUT_ATTRIBUTES,
+ ...COMMON_HTML_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="column"]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ mergeAttributes(
+ { 'data-type': 'column', class: 'node-column' },
+ HTMLAttributes,
+ ),
+ 0,
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Backspace: ({ editor }) => {
+ const { state } = editor;
+ const { selection } = state;
+ const { empty, $from } = selection;
+
+ if (!empty) return false;
+
+ for (let depth = $from.depth; depth >= 1; depth--) {
+ if ($from.pos !== $from.start(depth)) break;
+
+ const indexInParent = $from.index(depth - 1);
+
+ if (indexInParent === 0) continue;
+
+ const parent = $from.node(depth - 1);
+ const prevNode = parent.child(indexInParent - 1);
+
+ if (COLUMN_PARENT_SET.has(prevNode.type.name)) {
+ const deleteFrom = $from.before(depth) - prevNode.nodeSize;
+ const deleteTo = $from.before(depth);
+ editor.view.dispatch(state.tr.delete(deleteFrom, deleteTo));
+ return true;
+ }
+
+ break;
+ }
+
+ return false;
+ },
+ 'Mod-a': ({ editor }) => {
+ const { state } = editor;
+ const { $from } = state.selection;
+
+ for (let d = $from.depth; d > 0; d--) {
+ if ($from.node(d).type.name !== 'columnsColumn') {
+ continue;
+ }
+
+ const columnStart = $from.start(d);
+ const columnEnd = $from.end(d);
+ const { from, to } = state.selection;
+
+ if (from === columnStart && to === columnEnd) {
+ return false;
+ }
+
+ editor.view.dispatch(
+ state.tr.setSelection(
+ TextSelection.create(state.doc, columnStart, columnEnd),
+ ),
+ );
+ return true;
+ }
+
+ return false;
+ },
+ };
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ const width = node.attrs?.width;
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/div.spec.tsx b/packages/editor/src/extensions/div.spec.tsx
new file mode 100644
index 0000000000..ec6ec2babe
--- /dev/null
+++ b/packages/editor/src/extensions/div.spec.tsx
@@ -0,0 +1,29 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Div } from './div';
+
+const divStyle = { ...DEFAULT_STYLES.reset };
+
+describe('Div Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = Div.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Div content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/div.tsx b/packages/editor/src/extensions/div.tsx
new file mode 100644
index 0000000000..921cb19da5
--- /dev/null
+++ b/packages/editor/src/extensions/div.tsx
@@ -0,0 +1,76 @@
+import { mergeAttributes, Node } from '@tiptap/core';
+import {
+ COMMON_HTML_ATTRIBUTES,
+ createStandardAttributes,
+ LAYOUT_ATTRIBUTES,
+} from '../utils/attribute-helpers';
+import { inlineCssToJs } from '../utils/styles';
+
+export interface DivOptions {
+ HTMLAttributes: Record;
+}
+
+export const Div = Node.create({
+ name: 'div',
+
+ group: 'block',
+
+ content: 'block+',
+
+ defining: true,
+ isolating: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div:not([data-type])',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ // Preserve all attributes
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...COMMON_HTML_ATTRIBUTES,
+ ...LAYOUT_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/divider.spec.tsx b/packages/editor/src/extensions/divider.spec.tsx
new file mode 100644
index 0000000000..5c6fb22b65
--- /dev/null
+++ b/packages/editor/src/extensions/divider.spec.tsx
@@ -0,0 +1,27 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Divider } from './divider';
+
+describe('Divider Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = Divider.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ const node = {
+ type: 'horizontalRule',
+ attrs: {
+ class: 'divider',
+ style: '',
+ },
+ };
+ expect(
+ await render(
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/divider.tsx b/packages/editor/src/extensions/divider.tsx
new file mode 100644
index 0000000000..81589b189d
--- /dev/null
+++ b/packages/editor/src/extensions/divider.tsx
@@ -0,0 +1,66 @@
+import { Hr } from '@react-email/components';
+import { InputRule } from '@tiptap/core';
+import type { HorizontalRuleOptions } from '@tiptap/extension-horizontal-rule';
+import HorizontalRule from '@tiptap/extension-horizontal-rule';
+
+export type DividerOptions = HorizontalRuleOptions;
+
+import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
+import { inlineCssToJs } from '../utils/styles';
+
+export const Divider = HorizontalRule.extend({
+ addAttributes() {
+ return {
+ class: {
+ default: 'divider',
+ },
+ };
+ },
+ // patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740
+ addInputRules() {
+ return [
+ new InputRule({
+ find: /^(?:---|—-|___\s|\*\*\*\s)$/,
+ handler: ({ state, range }) => {
+ const attributes = {};
+
+ const { tr } = state;
+ const start = range.from;
+ const end = range.to;
+
+ tr.insert(start - 1, this.type.create(attributes)).delete(
+ tr.mapping.map(start),
+ tr.mapping.map(end),
+ );
+ },
+ }),
+ ];
+ },
+ addNodeView() {
+ return ReactNodeViewRenderer((props) => {
+ const node = props.node;
+ const { class: className, ...rest } = node.attrs;
+
+ const attrs = {
+ ...rest,
+ className: 'node-hr',
+ style: inlineCssToJs(node.attrs.style),
+ };
+
+ return (
+
+
+
+ );
+ });
+ },
+
+ renderToReactEmail({ node, style }) {
+ return (
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/global-content.ts b/packages/editor/src/extensions/global-content.ts
new file mode 100644
index 0000000000..8ee82ad4c6
--- /dev/null
+++ b/packages/editor/src/extensions/global-content.ts
@@ -0,0 +1,138 @@
+import { type Editor, mergeAttributes, Node } from '@tiptap/core';
+
+const GLOBAL_CONTENT_NODE_TYPE = 'globalContent' as const;
+
+export interface GlobalContentOptions {
+ key: string;
+ data: Record;
+}
+
+declare module '@tiptap/core' {
+ interface GlobalContent {
+ setGlobalContent: (key: string, value: unknown) => ReturnType;
+ }
+
+ interface Commands {
+ globalContent: GlobalContent;
+ }
+}
+
+let cachedGlobalPosition: number | null = null;
+
+function findGlobalContentPositions(doc: Editor['state']['doc']) {
+ const positions: number[] = [];
+
+ doc.descendants((node, position) => {
+ if (node.type.name === GLOBAL_CONTENT_NODE_TYPE) {
+ positions.push(position);
+ }
+ });
+
+ return positions;
+}
+
+function getCachedGlobalContentPosition(doc: Editor['state']['doc']) {
+ if (cachedGlobalPosition != null) {
+ try {
+ if (
+ doc.nodeAt(cachedGlobalPosition)?.type.name === GLOBAL_CONTENT_NODE_TYPE
+ ) {
+ return cachedGlobalPosition;
+ }
+ } catch {
+ cachedGlobalPosition = null;
+ }
+ }
+
+ const positions = findGlobalContentPositions(doc);
+ cachedGlobalPosition = positions[0] ?? null;
+ return cachedGlobalPosition;
+}
+
+export function getGlobalContent(key: string, editor: Editor): unknown | null {
+ const position = getCachedGlobalContentPosition(editor.state.doc);
+ if (cachedGlobalPosition == null) {
+ return null;
+ }
+ return editor.state.doc.nodeAt(position)?.attrs.data[key] ?? null;
+}
+
+export const GlobalContent = Node.create({
+ name: GLOBAL_CONTENT_NODE_TYPE,
+
+ addOptions() {
+ return {
+ key: GLOBAL_CONTENT_NODE_TYPE,
+ data: {},
+ };
+ },
+
+ group: 'block',
+
+ selectable: false,
+ draggable: false,
+ atom: true,
+
+ addAttributes() {
+ return {
+ data: {
+ default: this.options.data,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: `div[data-type="${this.name}"]` }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ mergeAttributes(HTMLAttributes, {
+ 'data-type': this.name,
+ // The node needs to have a width and height, so then
+ // internal TipTap extension can find the first node position
+ // and calculate the correct position of the document container
+ style: 'width: 100%; height: 1px; visibility: hidden;',
+ }),
+ ];
+ },
+
+ addCommands() {
+ return {
+ setGlobalContent:
+ (key: string, value: unknown) =>
+ ({ tr, dispatch }) => {
+ const ensureGlobalPosition = () => {
+ const positions = findGlobalContentPositions(tr.doc);
+
+ for (let i = positions.length - 1; i > 0; i--) {
+ tr.delete(positions[i], positions[i] + 1);
+ }
+
+ const pos = positions[0] ?? -1;
+ if (pos >= 0) {
+ cachedGlobalPosition = pos;
+ } else {
+ cachedGlobalPosition = 0;
+ tr.insert(0, this.type.create());
+ }
+ };
+
+ if (dispatch) {
+ ensureGlobalPosition();
+
+ if (cachedGlobalPosition == null) {
+ return false;
+ }
+ tr.setNodeAttribute(cachedGlobalPosition, 'data', {
+ ...tr.doc.nodeAt(cachedGlobalPosition)?.attrs.data,
+ [key]: value,
+ });
+ }
+
+ return true;
+ },
+ };
+ },
+});
diff --git a/packages/editor/src/extensions/hard-break.tsx b/packages/editor/src/extensions/hard-break.tsx
new file mode 100644
index 0000000000..47155884ec
--- /dev/null
+++ b/packages/editor/src/extensions/hard-break.tsx
@@ -0,0 +1,10 @@
+import type { HardBreakOptions } from '@tiptap/extension-hard-break';
+import HardBreakBase from '@tiptap/extension-hard-break';
+
+export { type HardBreakOptions };
+
+export const HardBreak = HardBreakBase.extend({
+ renderToReactEmail() {
+ return
;
+ },
+});
diff --git a/packages/editor/src/extensions/heading.spec.tsx b/packages/editor/src/extensions/heading.spec.tsx
new file mode 100644
index 0000000000..5cdc1ffb4e
--- /dev/null
+++ b/packages/editor/src/extensions/heading.spec.tsx
@@ -0,0 +1,30 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Heading } from './heading';
+
+describe('Heading Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = Heading.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ const node = {
+ type: 'heading',
+ attrs: {
+ class: '',
+ level: 1,
+ style: '',
+ ychange: null,
+ alignment: 'left',
+ },
+ };
+ expect(
+ await render(
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/heading.tsx b/packages/editor/src/extensions/heading.tsx
new file mode 100644
index 0000000000..19691b28e4
--- /dev/null
+++ b/packages/editor/src/extensions/heading.tsx
@@ -0,0 +1,53 @@
+import { Heading as EmailHeading } from '@react-email/components';
+import type { HeadingOptions as TipTapHeadingOptions } from '@tiptap/extension-heading';
+import { Heading as TipTapHeading } from '@tiptap/extension-heading';
+
+export type HeadingOptions = TipTapHeadingOptions;
+
+import {
+ NodeViewContent,
+ NodeViewWrapper,
+ ReactNodeViewRenderer,
+} from '@tiptap/react';
+import { getTextAlignment } from '../utils/get-text-alignment';
+import { inlineCssToJs } from '../utils/styles';
+
+export const Heading = TipTapHeading.extend({
+ addNodeView() {
+ return ReactNodeViewRenderer(({ node }) => {
+ const level = (node.attrs.level as number) ?? 1;
+ const { class: className, ...rest } = node.attrs;
+
+ const attrs = {
+ ...rest,
+ className: `node-h${level} ${className}`,
+ style: inlineCssToJs(node.attrs.style),
+ };
+
+ return (
+
+
+
+
+
+ );
+ });
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const level = node.attrs?.level ?? 1;
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts
new file mode 100644
index 0000000000..42d894bcde
--- /dev/null
+++ b/packages/editor/src/extensions/index.ts
@@ -0,0 +1,380 @@
+import { type AnyExtension, Extension } from '@tiptap/core';
+import type { BlockquoteOptions } from '@tiptap/extension-blockquote';
+import type { BulletListOptions } from '@tiptap/extension-bullet-list';
+import type { CodeOptions } from '@tiptap/extension-code';
+import type { HardBreakOptions } from '@tiptap/extension-hard-break';
+import type { ItalicOptions } from '@tiptap/extension-italic';
+import type { ListItemOptions } from '@tiptap/extension-list-item';
+import type { OrderedListOptions } from '@tiptap/extension-ordered-list';
+import type { ParagraphOptions } from '@tiptap/extension-paragraph';
+import type { StrikeOptions } from '@tiptap/extension-strike';
+import TipTapStarterKit, {
+ type StarterKitOptions as TipTapStarterKitOptions,
+} from '@tiptap/starter-kit';
+import type { AlignmentOptions } from './alignment-attribute';
+import { AlignmentAttribute } from './alignment-attribute';
+import { Blockquote } from './blockquote';
+import type { BodyOptions } from './body';
+import { Body } from './body';
+import type { BoldOptions } from './bold';
+import { Bold } from './bold';
+import { BulletList } from './bullet-list';
+import type { EditorButtonOptions } from './button';
+import { Button } from './button';
+import type { ClassAttributeOptions } from './class-attribute';
+import { ClassAttribute } from './class-attribute';
+import { Code } from './code';
+import type { CodeBlockPrismOptions } from './code-block';
+import { CodeBlockPrism } from './code-block';
+import {
+ ColumnsColumn,
+ FourColumns,
+ ThreeColumns,
+ TwoColumns,
+} from './columns';
+import type { DivOptions } from './div';
+import { Div } from './div';
+import type { DividerOptions } from './divider';
+import { Divider } from './divider';
+import type { GlobalContentOptions } from './global-content';
+import { GlobalContent } from './global-content';
+import { HardBreak } from './hard-break';
+import type { HeadingOptions } from './heading';
+import { Heading } from './heading';
+import { Italic } from './italic';
+import type { LinkOptions } from './link';
+import { Link } from './link';
+import { ListItem } from './list-item';
+import type { MaxNestingOptions } from './max-nesting';
+import { MaxNesting } from './max-nesting';
+import { OrderedList } from './ordered-list';
+import { Paragraph } from './paragraph';
+import type { PlaceholderOptions } from './placeholder';
+import { Placeholder } from './placeholder';
+import { PreservedStyle } from './preserved-style';
+import type { PreviewTextOptions } from './preview-text';
+import { PreviewText } from './preview-text';
+import type { SectionOptions } from './section';
+import { Section } from './section';
+import { Strike } from './strike';
+import type { StyleAttributeOptions } from './style-attribute';
+import { StyleAttribute } from './style-attribute';
+import type { SupOptions } from './sup';
+import { Sup } from './sup';
+import type { TableCellOptions, TableOptions, TableRowOptions } from './table';
+import { Table, TableCell, TableHeader, TableRow } from './table';
+import { Text } from './text';
+import type { UnderlineOptions } from './underline';
+import { Underline } from './underline';
+import type { UppercaseOptions } from './uppercase';
+import { Uppercase } from './uppercase';
+
+export * from './alignment-attribute';
+export * from './blockquote';
+export * from './body';
+export * from './bold';
+export * from './bullet-list';
+export * from './button';
+export * from './class-attribute';
+export * from './code';
+export * from './code-block';
+export * from './columns';
+export * from './div';
+export * from './divider';
+export * from './global-content';
+export * from './hard-break';
+export * from './heading';
+export * from './italic';
+export * from './link';
+export * from './list-item';
+export * from './max-nesting';
+export * from './ordered-list';
+export * from './paragraph';
+export * from './placeholder';
+export * from './preserved-style';
+export * from './preview-text';
+export * from './section';
+export * from './strike';
+export * from './style-attribute';
+export * from './sup';
+export * from './table';
+export * from './text';
+export * from './underline';
+export * from './uppercase';
+
+const starterKitExtensions: Record = {
+ CodeBlockPrism,
+ Code,
+ TwoColumns,
+ ThreeColumns,
+ FourColumns,
+ ColumnsColumn,
+ Paragraph,
+ BulletList,
+ OrderedList,
+ Blockquote,
+ ListItem,
+ HardBreak,
+ Italic,
+ Placeholder,
+ PreviewText,
+ Bold,
+ Strike,
+ Heading,
+ Divider,
+ Link,
+ Sup,
+ Underline,
+ Uppercase,
+ PreservedStyle,
+ Table,
+ TableRow,
+ TableCell,
+ TableHeader,
+ Body,
+ Div,
+ Button,
+ Section,
+ GlobalContent,
+ Text,
+ AlignmentAttribute,
+ StyleAttribute,
+ ClassAttribute,
+ MaxNesting,
+};
+
+export type StarterKitOptions = {
+ CodeBlockPrism: Partial | false;
+ Code: Partial | false;
+ TwoColumns: Partial> | false;
+ ThreeColumns: Partial> | false;
+ FourColumns: Partial> | false;
+ ColumnsColumn: Partial> | false;
+ Paragraph: Partial | false;
+ BulletList: Partial | false;
+ OrderedList: Partial | false;
+ Blockquote: Partial | false;
+ ListItem: Partial | false;
+ HardBreak: Partial | false;
+ Italic: Partial | false;
+ Placeholder: Partial | false;
+ PreviewText: Partial | false;
+ Bold: Partial | false;
+ Strike: Partial | false;
+ Heading: Partial | false;
+ Divider: Partial | false;
+ Link: Partial | false;
+ Sup: Partial | false;
+ Underline: Partial | false;
+ Uppercase: Partial | false;
+ PreservedStyle: Partial> | false;
+ Table: Partial | false;
+ TableRow: Partial | false;
+ TableCell: Partial | false;
+ TableHeader: Partial> | false;
+ Body: Partial | false;
+ Div: Partial | false;
+ Text: Record | false;
+ Button: Partial | false;
+ Section: Partial | false;
+ GlobalContent: Partial | false;
+ AlignmentAttribute: Partial | false;
+ StyleAttribute: Partial | false;
+ ClassAttribute: Partial | false;
+ MaxNesting: Partial | false;
+ TiptapStarterKit: Partial | false;
+};
+
+export const StarterKit = Extension.create({
+ name: 'reactEmailStarterKit',
+
+ addOptions() {
+ return {
+ TiptapStarterKit: {},
+ CodeBlockPrism: {
+ defaultLanguage: 'javascript',
+ HTMLAttributes: {
+ class: 'prism node-codeBlock',
+ },
+ },
+ Code: {
+ HTMLAttributes: {
+ class: 'node-inlineCode',
+ spellcheck: 'false',
+ },
+ },
+ TwoColumns: {},
+ ThreeColumns: {},
+ FourColumns: {},
+ ColumnsColumn: {},
+ Paragraph: {
+ HTMLAttributes: {
+ class: 'node-paragraph',
+ },
+ },
+ BulletList: {
+ HTMLAttributes: {
+ class: 'node-bulletList',
+ },
+ },
+ OrderedList: {
+ HTMLAttributes: {
+ class: 'node-orderedList',
+ },
+ },
+ Blockquote: {
+ HTMLAttributes: {
+ class: 'node-blockquote',
+ },
+ },
+ ListItem: {},
+ HardBreak: {},
+ Italic: {},
+ Placeholder: {},
+ PreviewText: {},
+ Bold: {},
+ Strike: {},
+ Heading: {},
+ Divider: {},
+ Link: { openOnClick: false, HTMLAttributes: { class: 'node-link' } },
+ Sup: {},
+ Underline: {},
+ Uppercase: {},
+ PreservedStyle: {},
+ Table: {},
+ TableRow: {},
+ TableCell: {},
+ TableHeader: {},
+ Body: {},
+ Div: {},
+ Button: {},
+ Section: {},
+ GlobalContent: {},
+ AlignmentAttribute: {
+ types: [
+ 'heading',
+ 'paragraph',
+ 'image',
+ 'blockquote',
+ 'codeBlock',
+ 'bulletList',
+ 'orderedList',
+ 'listItem',
+ 'button',
+ 'youtube',
+ 'twitter',
+ 'table',
+ 'tableRow',
+ 'tableCell',
+ 'tableHeader',
+ 'columnsColumn',
+ ],
+ },
+ StyleAttribute: {
+ types: [
+ 'heading',
+ 'paragraph',
+ 'image',
+ 'blockquote',
+ 'codeBlock',
+ 'bulletList',
+ 'orderedList',
+ 'listItem',
+ 'button',
+ 'youtube',
+ 'twitter',
+ 'horizontalRule',
+ 'footer',
+ 'section',
+ 'div',
+ 'body',
+ 'table',
+ 'tableRow',
+ 'tableCell',
+ 'tableHeader',
+ 'columnsColumn',
+ 'link',
+ ],
+ },
+ Text: {},
+ ClassAttribute: {
+ types: [
+ 'heading',
+ 'paragraph',
+ 'image',
+ 'blockquote',
+ 'bulletList',
+ 'orderedList',
+ 'listItem',
+ 'button',
+ 'youtube',
+ 'twitter',
+ 'horizontalRule',
+ 'footer',
+ 'section',
+ 'div',
+ 'body',
+ 'table',
+ 'tableRow',
+ 'tableCell',
+ 'tableHeader',
+ 'columnsColumn',
+ 'link',
+ ],
+ },
+ MaxNesting: {
+ maxDepth: 50,
+ nodeTypes: ['section', 'bulletList', 'orderedList'],
+ },
+ };
+ },
+
+ addExtensions() {
+ const extensions: AnyExtension[] = [];
+
+ if (this.options.TiptapStarterKit !== false) {
+ extensions.push(
+ TipTapStarterKit.configure({
+ // Collaboration extensions handle history separately.
+ undoRedo: false,
+ heading: false,
+ link: false,
+ underline: false,
+ trailingNode: false,
+ bold: false,
+ italic: false,
+ strike: false,
+ code: false,
+ paragraph: false,
+ bulletList: false,
+ orderedList: false,
+ listItem: false,
+ blockquote: false,
+ hardBreak: false,
+ gapcursor: false,
+ codeBlock: false,
+ text: false,
+ horizontalRule: false,
+ dropcursor: {
+ color: '#61a8f8',
+ class: 'rounded-full animate-[fade-in_300ms_ease-in-out] !z-40',
+ width: 4,
+ },
+ ...this.options.TiptapStarterKit,
+ }),
+ );
+ }
+
+ for (const [name, extension] of Object.entries(starterKitExtensions)) {
+ const key = name as keyof StarterKitOptions;
+ const extensionOptions = this.options[key];
+ if (extensionOptions !== false) {
+ extensions.push(
+ (extension as AnyExtension).configure(extensionOptions),
+ );
+ }
+ }
+
+ return extensions;
+ },
+});
diff --git a/packages/editor/src/extensions/italic.tsx b/packages/editor/src/extensions/italic.tsx
new file mode 100644
index 0000000000..de39c7d841
--- /dev/null
+++ b/packages/editor/src/extensions/italic.tsx
@@ -0,0 +1,10 @@
+import type { ItalicOptions } from '@tiptap/extension-italic';
+import ItalicBase from '@tiptap/extension-italic';
+
+export { type ItalicOptions };
+
+export const Italic = ItalicBase.extend({
+ renderToReactEmail({ children, style }) {
+ return {children};
+ },
+});
diff --git a/packages/editor/src/extensions/link.tsx b/packages/editor/src/extensions/link.tsx
new file mode 100644
index 0000000000..9b585f8d20
--- /dev/null
+++ b/packages/editor/src/extensions/link.tsx
@@ -0,0 +1,132 @@
+import { Link as ReactEmailLink } from '@react-email/components';
+import type { LinkOptions as TipTapLinkOptions } from '@tiptap/extension-link';
+import TiptapLink from '@tiptap/extension-link';
+
+export type LinkOptions = TipTapLinkOptions;
+
+import { editorEventBus } from '../core';
+import { inlineCssToJs } from '../utils/styles';
+import { processStylesForUnlink } from './preserved-style';
+
+export const Link = TiptapLink.extend({
+ renderToReactEmail({ children, mark, style }) {
+ const linkMarkStyle = mark.attrs?.style
+ ? inlineCssToJs(mark.attrs.style)
+ : {};
+
+ return (
+
+ {children}
+
+ );
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'a[target]:not([data-id="react-email-button"])',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ // Preserve all attributes
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ {
+ tag: 'a[href]:not([data-id="react-email-button"])',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ // Preserve all attributes
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ 'ses:no-track': {
+ default: null,
+ parseHTML: (element) => element.getAttribute('ses:no-track'),
+ },
+ };
+ },
+
+ addCommands() {
+ return {
+ ...this.parent?.(),
+
+ unsetLink:
+ () =>
+ ({ state, chain }) => {
+ const { from } = state.selection;
+ const linkMark = state.doc
+ .resolve(from)
+ .marks()
+ .find((m) => m.type.name === 'link');
+ const linkStyle = linkMark?.attrs?.style ?? null;
+
+ const preservedStyle = processStylesForUnlink(linkStyle);
+
+ const shouldRemoveUnderline = preservedStyle !== linkStyle;
+
+ if (preservedStyle) {
+ const cmd = chain()
+ .extendMarkRange('link')
+ .unsetMark('link')
+ .setMark('preservedStyle', { style: preservedStyle });
+
+ return shouldRemoveUnderline
+ ? cmd.unsetMark('underline').run()
+ : cmd.run();
+ }
+
+ return chain()
+ .extendMarkRange('link')
+ .unsetMark('link')
+ .unsetMark('underline')
+ .run();
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-k': () => {
+ editorEventBus.dispatch('bubble-menu:add-link', undefined);
+ // unselect
+ return this.editor.chain().focus().toggleLink({ href: '' }).run();
+ },
+ };
+ },
+});
diff --git a/packages/editor/src/extensions/list-item.tsx b/packages/editor/src/extensions/list-item.tsx
new file mode 100644
index 0000000000..a4267990dc
--- /dev/null
+++ b/packages/editor/src/extensions/list-item.tsx
@@ -0,0 +1,23 @@
+import type { ListItemOptions } from '@tiptap/extension-list-item';
+import ListItemBase from '@tiptap/extension-list-item';
+import { getTextAlignment } from '../utils/get-text-alignment';
+import { inlineCssToJs } from '../utils/styles';
+
+export { type ListItemOptions };
+
+export const ListItem = ListItemBase.extend({
+ renderToReactEmail({ children, node, style }) {
+ return (
+ -
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/max-nesting.ts b/packages/editor/src/extensions/max-nesting.ts
new file mode 100644
index 0000000000..f3a5bfaca6
--- /dev/null
+++ b/packages/editor/src/extensions/max-nesting.ts
@@ -0,0 +1,135 @@
+import { Extension } from '@tiptap/core';
+import type { NodeRange } from '@tiptap/pm/model';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+
+export interface MaxNestingOptions {
+ maxDepth: number;
+ nodeTypes?: string[];
+}
+
+export const MaxNesting = Extension.create({
+ name: 'maxNesting',
+
+ addOptions() {
+ return {
+ maxDepth: 3,
+ nodeTypes: undefined,
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const { maxDepth, nodeTypes } = this.options;
+
+ if (typeof maxDepth !== 'number' || maxDepth < 1) {
+ throw new Error('maxDepth must be a positive number');
+ }
+
+ return [
+ new Plugin({
+ key: new PluginKey('maxNesting'),
+
+ appendTransaction(transactions, _oldState, newState) {
+ const docChanged = transactions.some((tr) => tr.docChanged);
+ if (!docChanged) {
+ return null;
+ }
+
+ // Collect all ranges that need to be lifted
+ const rangesToLift: { range: NodeRange; target: number }[] = [];
+
+ newState.doc.descendants((node, pos) => {
+ let depth = 0;
+ let currentPos = pos;
+ let currentNode = node;
+
+ while (currentNode && depth <= maxDepth) {
+ if (!nodeTypes || nodeTypes.includes(currentNode.type.name)) {
+ depth++;
+ }
+
+ const $pos = newState.doc.resolve(currentPos);
+ if ($pos.depth === 0) {
+ break;
+ }
+
+ currentPos = $pos.before($pos.depth);
+ currentNode = newState.doc.nodeAt(currentPos)!;
+ }
+
+ if (depth > maxDepth) {
+ const $pos = newState.doc.resolve(pos);
+ if ($pos.depth > 0) {
+ const range = $pos.blockRange();
+ if (
+ range &&
+ 'canReplace' in newState.schema.nodes.doc &&
+ typeof newState.schema.nodes.doc.canReplace === 'function' &&
+ newState.schema.nodes.doc.canReplace(
+ range.start - 1,
+ range.end + 1,
+ newState.doc.slice(range.start, range.end).content,
+ )
+ ) {
+ rangesToLift.push({ range, target: range.start - 1 });
+ }
+ }
+ }
+ });
+
+ if (rangesToLift.length === 0) {
+ return null;
+ }
+
+ // Process ranges in reverse order (end to start) to maintain position validity
+ const tr = newState.tr;
+ for (let i = rangesToLift.length - 1; i >= 0; i--) {
+ const { range, target } = rangesToLift[i];
+ tr.lift(range, target);
+ }
+
+ return tr;
+ },
+
+ filterTransaction(tr) {
+ if (!tr.docChanged) {
+ return true;
+ }
+
+ let wouldCreateDeepNesting = false;
+ const newDoc = tr.doc;
+
+ newDoc.descendants((node, pos) => {
+ if (wouldCreateDeepNesting) {
+ return false;
+ }
+
+ let depth = 0;
+ let currentPos = pos;
+ let currentNode = node;
+
+ while (currentNode && depth <= maxDepth) {
+ if (!nodeTypes || nodeTypes.includes(currentNode.type.name)) {
+ depth++;
+ }
+
+ const $pos = newDoc.resolve(currentPos);
+ if ($pos.depth === 0) {
+ break;
+ }
+
+ currentPos = $pos.before($pos.depth);
+ currentNode = newDoc.nodeAt(currentPos)!;
+ }
+
+ if (depth > maxDepth) {
+ wouldCreateDeepNesting = true;
+ return false;
+ }
+ });
+
+ return !wouldCreateDeepNesting;
+ },
+ }),
+ ];
+ },
+});
diff --git a/packages/editor/src/extensions/ordered-list.tsx b/packages/editor/src/extensions/ordered-list.tsx
new file mode 100644
index 0000000000..ce8d58e78e
--- /dev/null
+++ b/packages/editor/src/extensions/ordered-list.tsx
@@ -0,0 +1,22 @@
+import type { OrderedListOptions } from '@tiptap/extension-ordered-list';
+import OrderedListBase from '@tiptap/extension-ordered-list';
+import { inlineCssToJs } from '../utils/styles';
+
+export { type OrderedListOptions };
+
+export const OrderedList = OrderedListBase.extend({
+ renderToReactEmail({ children, node, style }) {
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/paragraph.tsx b/packages/editor/src/extensions/paragraph.tsx
new file mode 100644
index 0000000000..d8806ff4f6
--- /dev/null
+++ b/packages/editor/src/extensions/paragraph.tsx
@@ -0,0 +1,30 @@
+import type { ParagraphOptions } from '@tiptap/extension-paragraph';
+import ParagraphBase from '@tiptap/extension-paragraph';
+import { getTextAlignment } from '../utils/get-text-alignment';
+import { inlineCssToJs } from '../utils/styles';
+
+export { type ParagraphOptions };
+
+export const Paragraph = ParagraphBase.extend({
+ renderToReactEmail({ children, node, style }) {
+ const isEmpty = !node.content || node.content.length === 0;
+
+ return (
+
+ {isEmpty ? (
+ /* Add
inside empty paragraph to make sure what users sees in the preview is the space that will be render in the email */
+
+ ) : (
+ children
+ )}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/placeholder.spec.ts b/packages/editor/src/extensions/placeholder.spec.ts
new file mode 100644
index 0000000000..162dffe70f
--- /dev/null
+++ b/packages/editor/src/extensions/placeholder.spec.ts
@@ -0,0 +1,186 @@
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Placeholder } from './placeholder';
+
+describe('Placeholder Extension', () => {
+ let editor: Editor;
+
+ beforeEach(() => {
+ editor = new Editor({
+ extensions: [StarterKit.configure({}), Placeholder],
+ content: '',
+ });
+ });
+
+ afterEach(() => {
+ editor.destroy();
+ });
+
+ describe('Extension Configuration', () => {
+ it('be properly configured', () => {
+ const extension = editor.extensionManager.extensions.find(
+ (ext) => ext.name === 'placeholder',
+ );
+
+ expect(extension).toBeDefined();
+ expect(extension?.options.includeChildren).toBe(true);
+ });
+
+ it('allows custom configuration', () => {
+ const customExtension = Placeholder.configure({
+ includeChildren: false,
+ });
+
+ expect(customExtension.options.placeholder).toStrictEqual(
+ Placeholder.options.placeholder,
+ );
+ expect(customExtension.options.includeChildren).toBe(false);
+ });
+ });
+
+ describe('Placeholder Content', () => {
+ it('returns default placeholder for paragraph nodes', () => {
+ const { schema } = editor;
+ const paragraphNode = schema.nodes.paragraph.create();
+
+ const extension = editor.extensionManager.extensions.find(
+ (ext) => ext.name === 'placeholder',
+ );
+
+ const placeholderText = extension?.options.placeholder({
+ node: paragraphNode,
+ });
+
+ expect(placeholderText).toBe("Press '/' for commands");
+ });
+
+ it('returns heading placeholder for heading nodes', () => {
+ const testEditor = new Editor({
+ extensions: [StarterKit.configure({ heading: {} }), Placeholder],
+ content: '',
+ });
+
+ const { schema } = testEditor;
+ const headingNode = schema.nodes.heading.create({ level: 1 });
+
+ const extension = testEditor.extensionManager.extensions.find(
+ (ext) => ext.name === 'placeholder',
+ );
+
+ const placeholderText = extension?.options.placeholder({
+ node: headingNode,
+ });
+
+ expect(placeholderText).toBe('Heading 1');
+
+ testEditor.destroy();
+ });
+
+ it('supports different heading levels', () => {
+ const testEditor = new Editor({
+ extensions: [StarterKit.configure({ heading: {} }), Placeholder],
+ content: '',
+ });
+
+ const { schema } = testEditor;
+ const extension = testEditor.extensionManager.extensions.find(
+ (ext) => ext.name === 'placeholder',
+ );
+
+ [1, 2, 3, 4, 5, 6].forEach((level) => {
+ const headingNode = schema.nodes.heading.create({ level });
+ const placeholderText = extension?.options.placeholder({
+ node: headingNode,
+ });
+ expect(placeholderText).toBe(`Heading ${level}`);
+ });
+
+ testEditor.destroy();
+ });
+ });
+
+ describe('Integration with Editor', () => {
+ it('adds placeholder attributes to empty paragraphs', () => {
+ editor.commands.setContent('');
+
+ const editorElement = editor.view.dom;
+ const paragraph = editorElement.querySelector('p');
+
+ expect(paragraph).toBeTruthy();
+ expect(
+ paragraph?.classList.contains('is-empty') ||
+ paragraph?.hasAttribute('data-placeholder'),
+ ).toBe(true);
+ });
+
+ it('remove placeholder when content is added', () => {
+ editor.commands.setContent('');
+ editor.commands.setContent('Hello World
');
+
+ const editorElement = editor.view.dom;
+ const paragraph = editorElement.querySelector('p');
+
+ expect(paragraph?.textContent).toBe('Hello World');
+ expect(paragraph?.classList.contains('is-empty')).toBe(false);
+ });
+
+ it('handles multiple paragraphs with empty lines correctly', () => {
+ editor.commands.setContent(
+ 'First paragraph
Third paragraph
',
+ );
+
+ const editorElement = editor.view.dom;
+ const paragraphs = editorElement.querySelectorAll('p');
+
+ expect(paragraphs).toHaveLength(3);
+ expect(paragraphs[0].textContent).toBe('First paragraph');
+ expect(paragraphs[1].textContent).toBe('');
+ expect(paragraphs[2].textContent).toBe('Third paragraph');
+
+ expect(paragraphs[1].nodeName.toLowerCase()).toBe('p');
+ });
+
+ it('keeps placeholder on first line when all content is removed', () => {
+ editor.commands.setContent('Some content
More content
');
+
+ let editorElement = editor.view.dom;
+ let paragraphs = editorElement.querySelectorAll('p');
+
+ expect(paragraphs).toHaveLength(2);
+ expect(paragraphs[0].textContent).toBe('Some content');
+ expect(paragraphs[1].textContent).toBe('More content');
+
+ editor.commands.setContent('');
+
+ editorElement = editor.view.dom;
+ paragraphs = editorElement.querySelectorAll('p');
+
+ expect(paragraphs).toHaveLength(1);
+ expect(paragraphs[0].textContent).toBe('');
+ expect(
+ paragraphs[0].classList.contains('is-empty') ||
+ paragraphs[0].hasAttribute('data-placeholder'),
+ ).toBe(true);
+ });
+
+ it('maintains proper document structure when navigating between empty and filled paragraphs', () => {
+ editor.commands.setContent('First line
Third line
');
+
+ const editorElement = editor.view.dom;
+ const paragraphs = editorElement.querySelectorAll('p');
+
+ expect(paragraphs).toHaveLength(3);
+ expect(paragraphs[0].textContent).toBe('First line');
+ expect(paragraphs[1].textContent).toBe('');
+ expect(paragraphs[2].textContent).toBe('Third line');
+
+ editor.commands.focus();
+
+ const emptyParagraphPos = editor.state.doc.resolve(15).pos;
+ editor.commands.setTextSelection(emptyParagraphPos);
+
+ expect(paragraphs[1].nodeName.toLowerCase()).toBe('p');
+ });
+ });
+});
diff --git a/packages/editor/src/extensions/placeholder.ts b/packages/editor/src/extensions/placeholder.ts
new file mode 100644
index 0000000000..e1de84e4c8
--- /dev/null
+++ b/packages/editor/src/extensions/placeholder.ts
@@ -0,0 +1,20 @@
+import type { Extension } from '@tiptap/core';
+import type { PlaceholderOptions as TipTapPlaceholderOptions } from '@tiptap/extension-placeholder';
+import TipTapPlaceholder from '@tiptap/extension-placeholder';
+import type { Node } from '@tiptap/pm/model';
+
+export interface PlaceholderOptions {
+ placeholder?: string | ((props: { node: Node }) => string);
+ includeChildren?: boolean;
+}
+
+export const Placeholder: Extension =
+ TipTapPlaceholder.configure({
+ placeholder: ({ node }) => {
+ if (node.type.name === 'heading') {
+ return `Heading ${node.attrs.level}`;
+ }
+ return "Press '/' for commands";
+ },
+ includeChildren: true,
+ });
diff --git a/packages/editor/src/extensions/preserved-style.tsx b/packages/editor/src/extensions/preserved-style.tsx
new file mode 100644
index 0000000000..5218eab2c7
--- /dev/null
+++ b/packages/editor/src/extensions/preserved-style.tsx
@@ -0,0 +1,125 @@
+import { Mark, mergeAttributes } from '@tiptap/core';
+import { inlineCssToJs } from '../utils/styles';
+
+export const PreservedStyle = Mark.create({
+ name: 'preservedStyle',
+
+ addAttributes() {
+ return {
+ style: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('style'),
+ renderHTML: (attributes) => {
+ if (!attributes.style) {
+ return {};
+ }
+ return { style: attributes.style };
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[style]',
+ getAttrs: (element) => {
+ if (typeof element === 'string') {
+ return false;
+ }
+ const style = element.getAttribute('style');
+ if (style && hasPreservableStyles(style)) {
+ return { style };
+ }
+ return false;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['span', mergeAttributes(HTMLAttributes), 0];
+ },
+
+ renderToReactEmail({ children, mark }) {
+ const preservedStyles = mark.attrs?.style
+ ? inlineCssToJs(mark.attrs.style)
+ : undefined;
+
+ return {children};
+ },
+});
+
+const LINK_INDICATOR_STYLES = [
+ 'color',
+ 'text-decoration',
+ 'text-decoration-line',
+ 'text-decoration-color',
+ 'text-decoration-style',
+];
+
+function parseStyleString(styleString: string): CSSStyleDeclaration {
+ const temp = document.createElement('div');
+ temp.style.cssText = styleString;
+ return temp.style;
+}
+
+function hasBackground(style: CSSStyleDeclaration): boolean {
+ const bgColor = style.backgroundColor;
+ const bg = style.background;
+
+ if (bgColor && bgColor !== 'transparent' && bgColor !== 'rgba(0, 0, 0, 0)') {
+ return true;
+ }
+
+ if (
+ bg &&
+ bg !== 'transparent' &&
+ bg !== 'none' &&
+ bg !== 'rgba(0, 0, 0, 0)'
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function hasPreservableStyles(styleString: string): boolean {
+ return processStylesForUnlink(styleString) !== null;
+}
+
+/**
+ * Processes styles when unlinking:
+ * - Has background (button-like): preserve all styles
+ * - No background: strip link-indicator styles (color, text-decoration), keep the rest
+ */
+export function processStylesForUnlink(
+ styleString: string | null | undefined,
+): string | null {
+ if (!styleString) {
+ return null;
+ }
+
+ const style = parseStyleString(styleString);
+
+ if (hasBackground(style)) {
+ return styleString;
+ }
+
+ const filtered: string[] = [];
+
+ for (let i = 0; i < style.length; i++) {
+ const prop = style[i];
+
+ if (LINK_INDICATOR_STYLES.includes(prop)) {
+ continue;
+ }
+
+ const value = style.getPropertyValue(prop);
+ if (value) {
+ filtered.push(`${prop}: ${value}`);
+ }
+ }
+
+ return filtered.length > 0 ? filtered.join('; ') : null;
+}
diff --git a/packages/editor/src/extensions/preview-text.ts b/packages/editor/src/extensions/preview-text.ts
new file mode 100644
index 0000000000..b2a0b70b8e
--- /dev/null
+++ b/packages/editor/src/extensions/preview-text.ts
@@ -0,0 +1,82 @@
+import { Node } from '@tiptap/core';
+
+export interface PreviewTextOptions {
+ HTMLAttributes: Record;
+}
+
+export const PreviewText = Node.create({
+ name: 'previewText',
+
+ group: 'block',
+
+ selectable: false,
+ draggable: false,
+ atom: true,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ addStorage() {
+ return {
+ previewText: null,
+ };
+ },
+
+ renderHTML() {
+ return ['div', { style: 'display: none' }];
+ },
+
+ parseHTML() {
+ return [
+ // react-email parsing
+ {
+ tag: 'div[data-skip-in-text="true"]',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+
+ // Extract and store preview text directly
+ let directText = '';
+ for (const child of element.childNodes) {
+ if (child.nodeType === 3) {
+ // TEXT_NODE = 3
+ // Anything other than text will be pruned
+ // This is particularly useful for react email,
+ // because we have a nested div full of white spaces that will just be ignored
+ directText += child.textContent || '';
+ }
+ }
+ const cleanText = directText.trim();
+
+ if (cleanText) {
+ this.storage.previewText = cleanText;
+ }
+
+ return false; // Don't create a node
+ },
+ },
+ // preheader class parsing
+ {
+ tag: 'span.preheader',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const preheaderText = element.textContent?.trim();
+
+ if (preheaderText) {
+ this.storage.previewText = preheaderText;
+ }
+
+ return false; // Don't create a node, just extract to storage
+ },
+ },
+ ];
+ },
+});
diff --git a/packages/editor/src/extensions/prism-plugin.ts b/packages/editor/src/extensions/prism-plugin.ts
new file mode 100644
index 0000000000..c1b0cc2d4b
--- /dev/null
+++ b/packages/editor/src/extensions/prism-plugin.ts
@@ -0,0 +1,242 @@
+import { findChildren } from '@tiptap/core';
+import type { Node as ProsemirrorNode } from '@tiptap/pm/model';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
+import { fromHtml } from 'hast-util-from-html';
+import Prism from 'prismjs';
+import {
+ hasPrismThemeLoaded,
+ loadPrismTheme,
+ removePrismTheme,
+} from '../utils/prism-utils';
+
+const PRISM_LANGUAGE_LOADED_META = 'prismLanguageLoaded';
+
+interface RefractorNode {
+ properties?: { className: string[] };
+ children?: RefractorNode[];
+ value?: string;
+}
+
+function parseNodes(
+ nodes: RefractorNode[],
+ className: string[] = [],
+): { text: string; classes: string[] }[] {
+ return nodes.flatMap((node) => {
+ const classes = [
+ ...className,
+ ...(node.properties ? node.properties.className : []),
+ ];
+
+ if (node.children) {
+ return parseNodes(node.children, classes);
+ }
+
+ return {
+ text: node.value ?? '',
+ classes,
+ };
+ });
+}
+
+function getHighlightNodes(html: string) {
+ return fromHtml(html, { fragment: true }).children;
+}
+
+function registeredLang(aliasOrLanguage: string) {
+ const allSupportLang = Object.keys(Prism.languages).filter(
+ (id) => typeof Prism.languages[id] === 'object',
+ );
+ return Boolean(allSupportLang.find((x) => x === aliasOrLanguage));
+}
+
+function getDecorations({
+ doc,
+ name,
+ defaultLanguage,
+ defaultTheme,
+ loadingLanguages,
+ onLanguageLoaded,
+}: {
+ doc: ProsemirrorNode;
+ name: string;
+ defaultLanguage: string | null | undefined;
+ defaultTheme: string | null | undefined;
+ loadingLanguages: Set;
+ onLanguageLoaded: (language: string) => void;
+}) {
+ const decorations: Decoration[] = [];
+
+ findChildren(doc, (node) => node.type.name === name).forEach((block) => {
+ let from = block.pos + 1;
+ const language = block.node.attrs.language || defaultLanguage;
+ const theme = block.node.attrs.theme || defaultTheme;
+ let html = '';
+
+ try {
+ if (!registeredLang(language) && !loadingLanguages.has(language)) {
+ loadingLanguages.add(language);
+ import(`prismjs/components/prism-${language}`)
+ .then(() => {
+ loadingLanguages.delete(language);
+ onLanguageLoaded(language);
+ })
+ .catch(() => {
+ loadingLanguages.delete(language);
+ });
+ }
+
+ if (!hasPrismThemeLoaded(theme)) {
+ loadPrismTheme(theme);
+ }
+
+ html = Prism.highlight(
+ block.node.textContent,
+ Prism.languages[language],
+ language,
+ );
+ } catch {
+ html = Prism.highlight(
+ block.node.textContent,
+ Prism.languages.javascript,
+ 'js',
+ );
+ }
+
+ const nodes = getHighlightNodes(html);
+
+ parseNodes(nodes as RefractorNode[]).forEach((node) => {
+ const to = from + node.text.length;
+
+ if (node.classes.length) {
+ const decoration = Decoration.inline(from, to, {
+ class: node.classes.join(' '),
+ });
+
+ decorations.push(decoration);
+ }
+
+ from = to;
+ });
+ });
+
+ return DecorationSet.create(doc, decorations);
+}
+
+export function PrismPlugin({
+ name,
+ defaultLanguage,
+ defaultTheme,
+}: {
+ name: string;
+ defaultLanguage: string;
+ defaultTheme: string;
+}) {
+ if (!defaultLanguage) {
+ throw Error('You must specify the defaultLanguage parameter');
+ }
+
+ const loadingLanguages = new Set();
+ let pluginView: EditorView | null = null;
+
+ const onLanguageLoaded = (language: string) => {
+ if (pluginView) {
+ pluginView.dispatch(
+ pluginView.state.tr.setMeta(PRISM_LANGUAGE_LOADED_META, language),
+ );
+ }
+ };
+
+ const prismjsPlugin: Plugin = new Plugin({
+ key: new PluginKey('prism'),
+
+ view(view) {
+ pluginView = view;
+ return {
+ destroy() {
+ pluginView = null;
+ },
+ };
+ },
+
+ state: {
+ init: (_, { doc }) => {
+ return getDecorations({
+ doc,
+ name,
+ defaultLanguage,
+ defaultTheme,
+ loadingLanguages,
+ onLanguageLoaded,
+ });
+ },
+ apply: (transaction, decorationSet, oldState, newState) => {
+ const oldNodeName = oldState.selection.$head.parent.type.name;
+ const newNodeName = newState.selection.$head.parent.type.name;
+
+ const oldNodes = findChildren(
+ oldState.doc,
+ (node) => node.type.name === name,
+ );
+ const newNodes = findChildren(
+ newState.doc,
+ (node) => node.type.name === name,
+ );
+
+ if (
+ transaction.getMeta(PRISM_LANGUAGE_LOADED_META) ||
+ (transaction.docChanged &&
+ // Apply decorations if:
+ // selection includes named node,
+ ([oldNodeName, newNodeName].includes(name) ||
+ // OR transaction adds/removes named node,
+ newNodes.length !== oldNodes.length ||
+ // OR transaction has changes that completely encapsulate a node
+ // (for example, a transaction that affects the entire document).
+ // Such transactions can happen during collab syncing via y-prosemirror, for example.
+ transaction.steps.some((step) => {
+ const rangeStep = step as unknown as {
+ from?: number;
+ to?: number;
+ };
+ return (
+ rangeStep.from !== undefined &&
+ rangeStep.to !== undefined &&
+ oldNodes.some((node) => {
+ return (
+ node.pos >= rangeStep.from! &&
+ node.pos + node.node.nodeSize <= rangeStep.to!
+ );
+ })
+ );
+ })))
+ ) {
+ return getDecorations({
+ doc: transaction.doc,
+ name,
+ defaultLanguage,
+ defaultTheme,
+ loadingLanguages,
+ onLanguageLoaded,
+ });
+ }
+
+ return decorationSet.map(transaction.mapping, transaction.doc);
+ },
+ },
+
+ props: {
+ decorations(state) {
+ return prismjsPlugin.getState(state);
+ },
+ },
+
+ destroy() {
+ pluginView = null;
+ removePrismTheme();
+ },
+ });
+
+ return prismjsPlugin;
+}
diff --git a/packages/editor/src/extensions/section.spec.tsx b/packages/editor/src/extensions/section.spec.tsx
new file mode 100644
index 0000000000..49a8d28956
--- /dev/null
+++ b/packages/editor/src/extensions/section.spec.tsx
@@ -0,0 +1,31 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Section } from './section';
+
+// Resolved style matching snapshot: section only (text-align from getTextAlignment in component)
+const sectionStyle = { ...DEFAULT_STYLES.section };
+
+describe('Section Node', () => {
+ it('renders React Email properly', async () => {
+ const Component = Section.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Section content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/section.tsx b/packages/editor/src/extensions/section.tsx
new file mode 100644
index 0000000000..8e45b26ef0
--- /dev/null
+++ b/packages/editor/src/extensions/section.tsx
@@ -0,0 +1,80 @@
+import { Section as ReactEmailSection } from '@react-email/components';
+import { mergeAttributes, Node } from '@tiptap/core';
+import type * as React from 'react';
+import { getTextAlignment } from '../utils/get-text-alignment';
+import { inlineCssToJs } from '../utils/styles';
+
+export interface SectionOptions {
+ HTMLAttributes: Record;
+ [key: string]: unknown;
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ section: {
+ insertSection: () => ReturnType;
+ };
+ }
+}
+
+export const Section = Node.create({
+ name: 'section',
+ group: 'block',
+ content: 'block+',
+ isolating: true,
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'section[data-type="section"]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'section',
+ mergeAttributes(
+ { 'data-type': 'section', class: 'node-section' },
+ HTMLAttributes,
+ ),
+ 0,
+ ];
+ },
+
+ addCommands() {
+ return {
+ insertSection:
+ () =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: this.name,
+ content: [
+ {
+ type: 'paragraph',
+ content: [],
+ },
+ ],
+ });
+ },
+ };
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ const textAlign = node.attrs?.align || node.attrs?.alignment;
+
+ return (
+
+ {children}
+
+ );
+ },
+});
diff --git a/packages/editor/src/extensions/strike.tsx b/packages/editor/src/extensions/strike.tsx
new file mode 100644
index 0000000000..060c77a45d
--- /dev/null
+++ b/packages/editor/src/extensions/strike.tsx
@@ -0,0 +1,10 @@
+import type { StrikeOptions } from '@tiptap/extension-strike';
+import StrikeBase from '@tiptap/extension-strike';
+
+export { type StrikeOptions };
+
+export const Strike = StrikeBase.extend({
+ renderToReactEmail({ children, style }) {
+ return {children};
+ },
+});
diff --git a/packages/editor/src/extensions/style-attribute.spec.ts b/packages/editor/src/extensions/style-attribute.spec.ts
new file mode 100644
index 0000000000..3afbe79b1c
--- /dev/null
+++ b/packages/editor/src/extensions/style-attribute.spec.ts
@@ -0,0 +1,84 @@
+/* @vitest-environment node */
+
+import { generateJSON } from '@tiptap/html';
+import StarterKit from '@tiptap/starter-kit';
+import { Div } from './div';
+// import { Heading } from './heaidng';
+import { StyleAttribute } from './style-attribute';
+import { Table, TableCell, TableHeader, TableRow } from './table';
+
+const extensions: Parameters[1] = [
+ StarterKit.configure({
+ heading: false,
+ }) as (typeof extensions)[number],
+ // Heading,
+ Div,
+ Table,
+ TableRow,
+ TableCell,
+ TableHeader,
+ StyleAttribute.configure({
+ types: [
+ // 'heading',
+ 'paragraph',
+ 'div',
+ 'table',
+ 'tableRow',
+ 'tableCell',
+ 'tableHeader',
+ ],
+ }),
+];
+
+function getStyleFromJson(json: ReturnType) {
+ return json.content?.[0]?.attrs?.style;
+}
+
+describe('StyleAttribute', () => {
+ it('preserves inline styles on paragraph', () => {
+ const html =
+ 'Hello
';
+ const json = generateJSON(html, extensions);
+ expect(getStyleFromJson(json)).toBe(
+ 'font-size: 14px; color: rgb(0, 0, 0); font-family: Arial',
+ );
+ });
+
+ // it('preserves inline styles on heading', () => {
+ // const html = 'Title
';
+ // const json = generateJSON(html, extensions);
+ // expect(getStyleFromJson(json)).toBe('margin: 0; font-size: 32px');
+ // });
+
+ it('preserves inline styles on div', () => {
+ const html =
+ 'Content
';
+ const json = generateJSON(html, extensions);
+ expect(getStyleFromJson(json)).toBe(
+ 'padding: 20px 0; background-color: #f4f4f5',
+ );
+ });
+
+ it('preserves inline styles on table elements', () => {
+ const html = `
+
+ Cell
+
+
`;
+ const json = generateJSON(html, extensions);
+
+ expect(json.content[0].attrs.style).toBe(
+ 'border-collapse: collapse; width: 100%',
+ );
+ const row = json.content[0].content[0];
+ expect(row.attrs.style).toBe('background-color: #ffffff');
+ const cell = row.content[0];
+ expect(cell.attrs.style).toBe('padding: 8px; border: 1px solid #e5e7eb');
+ });
+
+ it('returns empty string when no style attribute', () => {
+ const html = 'No styles
';
+ const json = generateJSON(html, extensions);
+ expect(getStyleFromJson(json)).toBe('');
+ });
+});
diff --git a/packages/editor/src/extensions/style-attribute.tsx b/packages/editor/src/extensions/style-attribute.tsx
new file mode 100644
index 0000000000..efc17f90d7
--- /dev/null
+++ b/packages/editor/src/extensions/style-attribute.tsx
@@ -0,0 +1,99 @@
+import { Extension } from '@tiptap/core';
+
+export interface StyleAttributeOptions {
+ types: string[];
+ style: string[];
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ textAlign: {
+ /**
+ * Set the style attribute
+ */
+ setStyle: (style: string) => ReturnType;
+ /**
+ * Unset the style attribute
+ */
+ unsetStyle: () => ReturnType;
+ };
+ }
+}
+
+export const StyleAttribute = Extension.create({
+ name: 'styleAttribute',
+ priority: 101,
+
+ addOptions() {
+ return {
+ types: [],
+ style: [],
+ };
+ },
+
+ addGlobalAttributes() {
+ return [
+ {
+ types: this.options.types,
+ attributes: {
+ style: {
+ default: '',
+ parseHTML: (element) => element.getAttribute('style') || '',
+ renderHTML: (attributes) => {
+ return { style: attributes.style ?? '' };
+ },
+ },
+ },
+ },
+ ];
+ },
+
+ addCommands() {
+ return {
+ unsetStyle:
+ () =>
+ ({ commands }) => {
+ return this.options.types.every((type) =>
+ commands.resetAttributes(type, 'style'),
+ );
+ },
+ setStyle:
+ (style: string) =>
+ ({ commands }) => {
+ return this.options.types.every((type) =>
+ commands.updateAttributes(type, { style }),
+ );
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: ({ editor }) => {
+ // Check if any suggestion plugin is active by looking for decorations
+ // that indicate an active suggestion/autocomplete
+ const { state } = editor.view;
+ const { selection } = state;
+ const { $from } = selection;
+
+ // Check if we're in a position where suggestion might be active
+ // by looking at the text before cursor for trigger characters
+ const textBefore = $from.nodeBefore?.text || '';
+ const hasTrigger =
+ textBefore.includes('{{') || textBefore.includes('{{{');
+
+ // If we have trigger characters, assume suggestion might be handling this
+ // Don't reset styles
+ if (hasTrigger) {
+ return false;
+ }
+
+ // Otherwise, reset paragraph styles on Enter
+ requestAnimationFrame(() => {
+ editor.commands.resetAttributes('paragraph', 'style');
+ });
+ return false;
+ },
+ };
+ },
+});
diff --git a/packages/editor/src/extensions/sup.tsx b/packages/editor/src/extensions/sup.tsx
new file mode 100644
index 0000000000..e5bb35fbf4
--- /dev/null
+++ b/packages/editor/src/extensions/sup.tsx
@@ -0,0 +1,52 @@
+import type { SuperscriptExtensionOptions as TipTapSuperscriptOptions } from '@tiptap/extension-superscript';
+import SuperscriptBase from '@tiptap/extension-superscript';
+
+export type SupOptions = TipTapSuperscriptOptions;
+
+declare module '@tiptap/core' {
+ interface Commands {
+ sup: {
+ /**
+ * Set a superscript mark
+ */
+ setSup: () => ReturnType;
+ /**
+ * Toggle a superscript mark
+ */
+ toggleSup: () => ReturnType;
+ /**
+ * Unset a superscript mark
+ */
+ unsetSup: () => ReturnType;
+ };
+ }
+}
+
+export const Sup = SuperscriptBase.extend({
+ name: 'sup',
+
+ addCommands() {
+ return {
+ ...this.parent?.(),
+ setSup:
+ () =>
+ ({ commands }) => {
+ return commands.setMark(this.name);
+ },
+ toggleSup:
+ () =>
+ ({ commands }) => {
+ return commands.toggleMark(this.name);
+ },
+ unsetSup:
+ () =>
+ ({ commands }) => {
+ return commands.unsetMark(this.name);
+ },
+ };
+ },
+
+ renderToReactEmail({ children, style }) {
+ return {children};
+ },
+});
diff --git a/packages/editor/src/extensions/table.spec.tsx b/packages/editor/src/extensions/table.spec.tsx
new file mode 100644
index 0000000000..e3d67a892f
--- /dev/null
+++ b/packages/editor/src/extensions/table.spec.tsx
@@ -0,0 +1,74 @@
+import { render } from '@react-email/components';
+import { DEFAULT_STYLES } from '../utils/default-styles';
+import { Table, TableCell, TableRow } from './table';
+
+const tableStyle = { ...DEFAULT_STYLES.reset };
+const tableRowStyle = { ...DEFAULT_STYLES.reset };
+const tableCellStyle = { ...DEFAULT_STYLES.reset };
+
+describe('Table Nodes', () => {
+ it('renders Table React Email properly', async () => {
+ const Component = Table.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Table content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders TableRow React Email properly', async () => {
+ const Component = TableRow.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Row content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it('renders TableCell React Email properly', async () => {
+ const Component = TableCell.config.renderToReactEmail;
+ expect(Component).toBeDefined();
+ expect(
+ await render(
+
+ Cell content
+ ,
+ { pretty: true },
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/editor/src/extensions/table.tsx b/packages/editor/src/extensions/table.tsx
new file mode 100644
index 0000000000..9327fc3eb3
--- /dev/null
+++ b/packages/editor/src/extensions/table.tsx
@@ -0,0 +1,279 @@
+import { Column, Section } from '@react-email/components';
+import type { ParentConfig } from '@tiptap/core';
+import { mergeAttributes, Node, type NodeConfig } from '@tiptap/core';
+import {
+ COMMON_HTML_ATTRIBUTES,
+ createStandardAttributes,
+ LAYOUT_ATTRIBUTES,
+ TABLE_ATTRIBUTES,
+ TABLE_CELL_ATTRIBUTES,
+ TABLE_HEADER_ATTRIBUTES,
+} from '../utils/attribute-helpers';
+import { inlineCssToJs, resolveConflictingStyles } from '../utils/styles';
+
+declare module '@tiptap/core' {
+ interface NodeConfig {
+ /**
+ * A string or function to determine the role of the table.
+ * @default 'table'
+ * @example () => 'table'
+ */
+ tableRole?:
+ | string
+ | ((this: {
+ name: string;
+ options: Options;
+ storage: Storage;
+ parent: ParentConfig>['tableRole'];
+ }) => string);
+ }
+}
+
+export interface TableOptions {
+ HTMLAttributes: Record;
+}
+
+export const Table = Node.create({
+ name: 'table',
+
+ group: 'block',
+
+ content: 'tableRow+',
+
+ isolating: true,
+
+ tableRole: 'table',
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...TABLE_ATTRIBUTES,
+ ...LAYOUT_ATTRIBUTES,
+ ...COMMON_HTML_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'table',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes);
+
+ return ['table', attrs, ['tbody', {}, 0]];
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ const alignment = node.attrs?.align || node.attrs?.alignment;
+ const width = node.attrs?.width;
+
+ const centeringStyles: Record =
+ alignment === 'center' ? { marginLeft: 'auto', marginRight: 'auto' } : {};
+
+ return (
+
+ {children}
+
+ );
+ },
+});
+
+export interface TableRowOptions extends Record {
+ HTMLAttributes?: Record;
+}
+
+export const TableRow = Node.create({
+ name: 'tableRow',
+
+ group: 'tableRow',
+
+ content: '(tableCell | tableHeader)+',
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...TABLE_CELL_ATTRIBUTES,
+ ...LAYOUT_ATTRIBUTES,
+ ...COMMON_HTML_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'tr',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['tr', HTMLAttributes, 0];
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ return (
+
+ {children}
+
+ );
+ },
+});
+
+export interface TableCellOptions extends Record {
+ HTMLAttributes?: Record;
+}
+
+export const TableCell = Node.create({
+ name: 'tableCell',
+
+ group: 'tableCell',
+
+ content: 'block+',
+
+ isolating: true,
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...TABLE_CELL_ATTRIBUTES,
+ ...LAYOUT_ATTRIBUTES,
+ ...COMMON_HTML_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'td',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['td', HTMLAttributes, 0];
+ },
+
+ renderToReactEmail({ children, node, style }) {
+ const inlineStyles = inlineCssToJs(node.attrs?.style);
+ return (
+
+ {children}
+
+ );
+ },
+});
+
+export const TableHeader = Node.create({
+ name: 'tableHeader',
+
+ group: 'tableCell',
+
+ content: 'block+',
+
+ isolating: true,
+
+ addAttributes() {
+ return {
+ ...createStandardAttributes([
+ ...TABLE_HEADER_ATTRIBUTES,
+ ...TABLE_CELL_ATTRIBUTES,
+ ...LAYOUT_ATTRIBUTES,
+ ...COMMON_HTML_ATTRIBUTES,
+ ]),
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'th',
+ getAttrs: (node) => {
+ if (typeof node === 'string') {
+ return false;
+ }
+ const element = node as HTMLElement;
+ const attrs: Record = {};
+
+ Array.from(element.attributes).forEach((attr) => {
+ attrs[attr.name] = attr.value;
+ });
+
+ return attrs;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['th', HTMLAttributes, 0];
+ },
+});
diff --git a/packages/editor/src/extensions/text.tsx b/packages/editor/src/extensions/text.tsx
new file mode 100644
index 0000000000..eaeb4f717c
--- /dev/null
+++ b/packages/editor/src/extensions/text.tsx
@@ -0,0 +1,7 @@
+import { Text as BaseText } from '@tiptap/extension-text';
+
+export const Text = BaseText.extend({
+ renderToReactEmail({ children }) {
+ return <>{children}>;
+ },
+});
diff --git a/packages/editor/src/extensions/underline.tsx b/packages/editor/src/extensions/underline.tsx
new file mode 100644
index 0000000000..540cdcb5de
--- /dev/null
+++ b/packages/editor/src/extensions/underline.tsx
@@ -0,0 +1,10 @@
+import type { UnderlineOptions as TipTapUnderlineOptions } from '@tiptap/extension-underline';
+import UnderlineBase from '@tiptap/extension-underline';
+
+export type UnderlineOptions = TipTapUnderlineOptions;
+
+export const Underline = UnderlineBase.extend({
+ renderToReactEmail({ children, style }) {
+ return {children};
+ },
+});
diff --git a/packages/editor/src/extensions/uppercase.spec.ts b/packages/editor/src/extensions/uppercase.spec.ts
new file mode 100644
index 0000000000..5f14a87479
--- /dev/null
+++ b/packages/editor/src/extensions/uppercase.spec.ts
@@ -0,0 +1,61 @@
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import { Uppercase } from './uppercase';
+
+function createEditor(content?: string) {
+ return new Editor({
+ extensions: [StarterKit, Uppercase],
+ content: content ?? 'hello world
',
+ });
+}
+
+describe('Uppercase', () => {
+ it('registers toggleUppercase command', () => {
+ const editor = createEditor();
+ expect(editor.commands.toggleUppercase).toBeDefined();
+ editor.destroy();
+ });
+
+ it('toggles uppercase mark on selected text', () => {
+ const editor = createEditor();
+ editor.commands.setTextSelection({ from: 1, to: 6 });
+ editor.commands.toggleUppercase();
+
+ const html = editor.getHTML();
+ expect(html).toContain('text-transform: uppercase');
+ expect(html).toContain(' {
+ const editor = createEditor();
+ editor.commands.setTextSelection({ from: 1, to: 6 });
+ editor.commands.toggleUppercase();
+ editor.commands.toggleUppercase();
+
+ const html = editor.getHTML();
+ expect(html).not.toContain('text-transform: uppercase');
+ editor.destroy();
+ });
+
+ it('parses HTML with text-transform: uppercase', () => {
+ const editor = createEditor(
+ 'hello world
',
+ );
+
+ editor.commands.setTextSelection({ from: 1, to: 6 });
+ expect(editor.isActive('uppercase')).toBe(true);
+ editor.destroy();
+ });
+
+ it('renders uppercase mark as span with inline style', () => {
+ const editor = createEditor();
+ editor.commands.setTextSelection({ from: 1, to: 6 });
+ editor.commands.setUppercase();
+
+ const html = editor.getHTML();
+ expect(html).toContain('text-transform: uppercase');
+ expect(html).toContain(';
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ uppercase: {
+ setUppercase: () => ReturnType;
+ toggleUppercase: () => ReturnType;
+ unsetUppercase: () => ReturnType;
+ };
+ }
+}
+
+export const Uppercase = Mark.create({
+ name: 'uppercase',
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span',
+ getAttrs: (node) => {
+ const el = node as HTMLElement;
+ if (el.style.textTransform === 'uppercase') {
+ return {};
+ }
+ return false;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'span',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ style: 'text-transform: uppercase',
+ }),
+ 0,
+ ];
+ },
+
+ renderToReactEmail({ children, style }) {
+ return (
+
+ {children}
+
+ );
+ },
+
+ addCommands() {
+ return {
+ setUppercase:
+ () =>
+ ({ commands }) => {
+ return commands.setMark(this.name);
+ },
+ toggleUppercase:
+ () =>
+ ({ commands }) => {
+ return commands.toggleMark(this.name);
+ },
+ unsetUppercase:
+ () =>
+ ({ commands }) => {
+ return commands.unsetMark(this.name);
+ },
+ };
+ },
+});
diff --git a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts
new file mode 100644
index 0000000000..a4ec203131
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts
@@ -0,0 +1,352 @@
+import { describe, expect, it } from 'vitest';
+import { transformToCssJs } from './css-transforms';
+import { DEFAULT_INBOX_FONT_SIZE_PX } from './themes';
+import type { PanelGroup } from './types';
+
+describe('transformToCssJs', () => {
+ it('returns empty object for non-array input', () => {
+ const result = transformToCssJs(
+ null as unknown as Parameters[0],
+ DEFAULT_INBOX_FONT_SIZE_PX,
+ );
+ expect(result).toEqual({});
+
+ const result2 = transformToCssJs(
+ undefined as unknown as Parameters[0],
+ DEFAULT_INBOX_FONT_SIZE_PX,
+ );
+ expect(result2).toEqual({});
+
+ const result3 = transformToCssJs(
+ 'not-an-array' as unknown as Parameters[0],
+ DEFAULT_INBOX_FONT_SIZE_PX,
+ );
+ expect(result3).toEqual({});
+ });
+
+ it('returns empty object for empty array', () => {
+ const result = transformToCssJs([], DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({});
+ });
+
+ it('transforms basic styles without units', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Basic',
+ inputs: [
+ {
+ label: 'Color',
+ type: 'color',
+ value: '#FF0000',
+ prop: 'color',
+ classReference: 'body',
+ },
+ {
+ label: 'Background',
+ type: 'color',
+ value: '#FFFFFF',
+ prop: 'backgroundColor',
+ classReference: 'container',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ body: { color: '#FF0000' },
+ container: { backgroundColor: '#FFFFFF' },
+ });
+ });
+
+ it('appends units to numeric values', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'With Units',
+ inputs: [
+ {
+ label: 'Width',
+ type: 'number',
+ value: 100,
+ unit: 'px',
+ prop: 'width',
+ classReference: 'container',
+ },
+ {
+ label: 'Height',
+ type: 'number',
+ value: 200,
+ unit: 'px',
+ prop: 'height',
+ classReference: 'container',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ container: { width: '100px', height: '200px' },
+ });
+ });
+
+ it('converts fontSize from px to em for mobile adjustment', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Font Size',
+ inputs: [
+ {
+ label: 'Font Size',
+ type: 'number',
+ value: 26,
+ unit: 'px',
+ prop: 'fontSize',
+ classReference: 'body',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ body: { fontSize: '1.8571428571428572em' }, // 26 / DEFAULT_INBOX_FONT_SIZE_PX
+ });
+ });
+
+ it('includes container background when body is not white', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Body',
+ inputs: [
+ {
+ label: 'Background',
+ type: 'color',
+ value: '#F0F0F0',
+ prop: 'backgroundColor',
+ classReference: 'body',
+ },
+ ],
+ },
+ {
+ title: 'Container',
+ inputs: [
+ {
+ label: 'Background',
+ type: 'color',
+ value: '#FFFFFF',
+ prop: 'backgroundColor',
+ classReference: 'container',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ body: { backgroundColor: '#F0F0F0' },
+ container: { backgroundColor: '#FFFFFF' },
+ });
+ });
+
+ it('includes non-black text colors for body', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Body',
+ inputs: [
+ {
+ label: 'Color',
+ type: 'color',
+ value: '#333333',
+ prop: 'color',
+ classReference: 'body',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ body: { color: '#333333' },
+ });
+ });
+
+ it('handles mixed input types and properties', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Mixed Properties',
+ inputs: [
+ {
+ label: 'Width',
+ type: 'number',
+ value: 600,
+ unit: 'px',
+ prop: 'width',
+ classReference: 'container',
+ },
+ {
+ label: 'Align',
+ type: 'select',
+ value: 'center',
+ prop: 'align',
+ classReference: 'container',
+ },
+ {
+ label: 'Background',
+ type: 'color',
+ value: '#F5F5F5',
+ prop: 'backgroundColor',
+ classReference: 'container',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ container: {
+ width: '600px',
+ align: 'center',
+ backgroundColor: '#F5F5F5',
+ },
+ });
+ });
+
+ it('handles multiple class references', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Multiple Classes',
+ inputs: [
+ {
+ label: 'Body Color',
+ type: 'color',
+ value: '#333333',
+ prop: 'color',
+ classReference: 'body',
+ },
+ {
+ label: 'Link Color',
+ type: 'color',
+ value: '#0066CC',
+ prop: 'color',
+ classReference: 'link',
+ },
+ {
+ label: 'Button Background',
+ type: 'color',
+ value: '#000000',
+ prop: 'backgroundColor',
+ classReference: 'button',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ body: { color: '#333333' },
+ link: { color: '#0066CC' },
+ button: { backgroundColor: '#000000' },
+ });
+ });
+
+ it('handles string values with units', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'String Values',
+ inputs: [
+ {
+ label: 'Width',
+ type: 'number',
+ value: 'auto',
+ unit: 'px',
+ prop: 'width',
+ classReference: 'button',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ button: { width: 'auto' }, // Should not append unit for string values
+ });
+ });
+
+ it('handles complex nested structure', () => {
+ const styles: PanelGroup[] = [
+ {
+ title: 'Complex Theme',
+ inputs: [
+ {
+ label: 'Body Background',
+ type: 'color',
+ value: '#FFFFFF',
+ prop: 'backgroundColor',
+ classReference: 'body',
+ },
+ {
+ label: 'Container Background',
+ type: 'color',
+ value: '#FFFFFF',
+ prop: 'backgroundColor',
+ classReference: 'container',
+ },
+ {
+ label: 'Container Width',
+ type: 'number',
+ value: 600,
+ unit: 'px',
+ prop: 'width',
+ classReference: 'container',
+ },
+ {
+ label: 'Padding Left',
+ type: 'number',
+ value: 20,
+ unit: 'px',
+ prop: 'paddingLeft',
+ classReference: 'container',
+ },
+ {
+ label: 'Padding Right',
+ type: 'number',
+ value: 20,
+ unit: 'px',
+ prop: 'paddingRight',
+ classReference: 'container',
+ },
+ {
+ label: 'Body Font Size',
+ type: 'number',
+ value: 16,
+ unit: 'px',
+ prop: 'fontSize',
+ classReference: 'body',
+ },
+ {
+ label: 'Body Color',
+ type: 'color',
+ value: '#000000',
+ prop: 'color',
+ classReference: 'body',
+ },
+ ],
+ },
+ ];
+
+ const result = transformToCssJs(styles, DEFAULT_INBOX_FONT_SIZE_PX);
+ expect(result).toEqual({
+ body: {
+ backgroundColor: '#FFFFFF',
+ color: '#000000',
+ fontSize: '1.1428571428571428em', // 16 / DEFAULT_INBOX_FONT_SIZE_PX
+ },
+ container: {
+ backgroundColor: '#FFFFFF',
+ width: '600px',
+ paddingLeft: '20px',
+ paddingRight: '20px',
+ },
+ });
+ });
+});
diff --git a/packages/editor/src/plugins/email-theming/css-transforms.ts b/packages/editor/src/plugins/email-theming/css-transforms.ts
new file mode 100644
index 0000000000..90f005360c
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/css-transforms.ts
@@ -0,0 +1,143 @@
+import { ensureBorderStyleFallback } from '../../utils/styles';
+import type { CssJs, PanelGroup } from './types';
+
+export function transformToCssJs(
+ styleArray: PanelGroup[],
+ baseFontSize: number,
+): CssJs {
+ const cssJS = {} as CssJs;
+
+ if (!Array.isArray(styleArray)) {
+ return cssJS;
+ }
+
+ for (const style of styleArray) {
+ for (const input of style.inputs) {
+ let value = input.value;
+
+ // If there's a unit property, append it to the value
+ if (input.unit && typeof value === 'number') {
+ // if font size prop convert px unit to em to adjust size in mobile
+ if (input.prop === 'fontSize') {
+ value = `${value / baseFontSize}em`;
+ } else {
+ value = `${value}${input.unit}`;
+ }
+ }
+
+ if (!input.classReference) {
+ continue;
+ }
+
+ if (!cssJS[input.classReference]) {
+ cssJS[input.classReference] = {};
+ }
+
+ // @ts-expect-error -- backward compatibility: 'h-padding' is a legacy prop not in KnownCssProperties
+ if (input.prop === 'h-padding') {
+ cssJS[input.classReference].paddingLeft = value;
+ cssJS[input.classReference].paddingRight = value;
+
+ continue;
+ }
+
+ // @ts-expect-error -- input.prop is KnownCssProperties but CssJs values are React.CSSProperties; dynamic assignment is intentional
+ cssJS[input.classReference][input.prop] = value;
+ }
+ }
+
+ for (const key of Object.keys(cssJS)) {
+ ensureBorderStyleFallback(
+ cssJS[key as keyof CssJs] as Record,
+ );
+ }
+
+ return cssJS;
+}
+
+export function mergeCssJs(original: CssJs, newCssJs: CssJs) {
+ const merged = { ...original };
+
+ for (const key in newCssJs) {
+ const keyType = key as keyof CssJs;
+
+ if (
+ Object.hasOwn(merged, key) &&
+ typeof merged[keyType] === 'object' &&
+ !Array.isArray(merged[keyType])
+ ) {
+ merged[keyType] = {
+ ...merged[keyType],
+ ...newCssJs[keyType],
+ };
+ } else {
+ merged[keyType] = newCssJs[keyType];
+ }
+ }
+
+ return merged;
+}
+
+export function injectThemeCss(
+ styles: CssJs,
+ options: { styleId?: string; scopeSelector?: string } = {},
+) {
+ const container =
+ options.scopeSelector ?? '.tiptap-extended .tiptap.ProseMirror';
+ const prefix = '.node-';
+ const styleId = options.styleId ?? 'tiptap-extended-theme-css';
+
+ const css = Object.entries(styles).reduce((acc, [key, value]) => {
+ const className = `${container} ${prefix}${key}`;
+
+ const cssString = Object.entries(value).reduce((acc, [prop, val]) => {
+ const normalizeProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
+
+ return `${acc}${normalizeProp}:${val};`;
+ }, '');
+
+ return `${acc}${className}{${cssString}}`;
+ }, '');
+
+ let styleTag = document.getElementById(styleId) as HTMLStyleElement;
+
+ if (!styleTag) {
+ styleTag = document.createElement('style');
+ styleTag.textContent = css;
+ styleTag.id = styleId;
+
+ document.head.appendChild(styleTag);
+
+ return;
+ }
+
+ styleTag.textContent = css;
+}
+
+export function injectGlobalPlainCss(
+ css?: string | null,
+ options: { styleId?: string; scopeSelector?: string } = {},
+) {
+ if (!css) {
+ return;
+ }
+
+ const styleId = options.styleId ?? 'global-editor-style';
+ const container = options.scopeSelector ?? '.tiptap-extended .ProseMirror';
+ let styleElement = document.getElementById(styleId);
+
+ if (!styleElement) {
+ styleElement = document.createElement('style');
+ styleElement.id = styleId;
+ document.head.appendChild(styleElement);
+ }
+
+ // Remove CSS within @media (prefers-color-scheme: dark) blocks
+ const cleanedCSS = css.replace(
+ /@media\s?\(prefers-color-scheme:\s?dark\)\s?{([\s\S]+?})\s*}/g,
+ '',
+ );
+
+ // TODO: Figure out a way to extract the body and apply the styles out of the nested .tiptap-extended
+ styleElement.textContent = `${container} { ${cleanedCSS} }`;
+}
diff --git a/packages/editor/src/plugins/email-theming/extension.spec.ts b/packages/editor/src/plugins/email-theming/extension.spec.ts
new file mode 100644
index 0000000000..85b1928190
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/extension.spec.ts
@@ -0,0 +1,142 @@
+import { Editor } from '@tiptap/core';
+import { afterEach, describe, expect, it } from 'vitest';
+import { StarterKit } from '../../extensions';
+import { EmailTheming } from './extension';
+
+vi.mock('@tiptap/react', () => ({
+ ReactNodeViewRenderer: () => () => null,
+ useEditorState: vi.fn(),
+}));
+
+vi.mock('tippy.js', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('@/env', () => ({
+ env: new Proxy(
+ {},
+ {
+ get: () => '',
+ },
+ ),
+}));
+
+const BUTTON_DOC = {
+ type: 'doc',
+ content: [
+ {
+ type: 'button',
+ attrs: {
+ url: 'https://resend.com',
+ },
+ content: [{ type: 'text', text: 'Button' }],
+ },
+ ],
+};
+
+const GLOBAL_CSS = '.foo { color: red; }';
+
+const LEGACY_MINIMAL_DOC = {
+ type: 'doc',
+ content: [
+ {
+ type: 'globalContent',
+ attrs: {
+ data: {
+ css: '',
+ styles: [
+ { title: 'Body', classReference: 'body', inputs: [] },
+ { title: 'Container', classReference: 'container', inputs: [] },
+ { title: 'Typography', classReference: 'body', inputs: [] },
+ { title: 'Link', classReference: 'link', inputs: [] },
+ { title: 'Image', classReference: 'image', inputs: [] },
+ { title: 'Button', classReference: 'button', inputs: [] },
+ { title: 'Code Block', classReference: 'codeBlock', inputs: [] },
+ { title: 'Inline Code', classReference: 'inlineCode', inputs: [] },
+ ],
+ theme: 'minimal',
+ },
+ },
+ },
+ ...BUTTON_DOC.content,
+ ],
+};
+
+function createEditor(content = BUTTON_DOC) {
+ return new Editor({
+ extensions: [
+ StarterKit,
+ EmailTheming.configure({
+ theme: 'basic',
+ }),
+ ],
+ content,
+ });
+}
+
+describe('EmailTheming', () => {
+ let editor: Editor;
+
+ afterEach(() => {
+ editor?.destroy();
+ document.head
+ .querySelectorAll('style[id^="tiptap-theme-"]')
+ .forEach((node) => {
+ node.remove();
+ });
+ });
+
+ it('injects default theme styles when no saved styles are available', () => {
+ editor = createEditor();
+
+ const themeStyleTag = document.head.querySelector(
+ 'style[id^="tiptap-theme-"][id$="-theme"]',
+ );
+
+ expect(themeStyleTag).not.toBeNull();
+ expect(themeStyleTag?.textContent).toContain('.node-button');
+ expect(themeStyleTag?.textContent).toContain('background-color:#000000;');
+ expect(themeStyleTag?.textContent).toContain('padding-top:7px;');
+ });
+
+ it('keeps legacy saved minimal theme styles from falling back to basic', () => {
+ editor = new Editor({
+ extensions: [StarterKit, EmailTheming],
+ content: LEGACY_MINIMAL_DOC,
+ });
+
+ const themeStyleTag = document.head.querySelector(
+ 'style[id^="tiptap-theme-"][id$="-theme"]',
+ );
+
+ expect(themeStyleTag).not.toBeNull();
+ expect(themeStyleTag?.textContent).toContain('.node-button');
+ expect(themeStyleTag?.textContent).not.toContain(
+ 'background-color:#000000;',
+ );
+ expect(themeStyleTag?.textContent).not.toContain('padding-top:7px;');
+ });
+
+ it('does not duplicate injected CSS when updating with same global css repeatedly', () => {
+ editor = createEditor();
+
+ editor.commands.setGlobalContent('css', GLOBAL_CSS);
+ editor.commands.setGlobalContent('css', GLOBAL_CSS);
+ editor.commands.setGlobalContent('css', GLOBAL_CSS);
+
+ const globalStyleTags = Array.from(
+ document.head.querySelectorAll(
+ 'style[id^="tiptap-theme-"][id$="-global"]',
+ ),
+ );
+
+ expect(globalStyleTags).toHaveLength(1);
+
+ const globalStyleTag = globalStyleTags[0];
+ expect(globalStyleTag?.textContent).toContain(GLOBAL_CSS);
+
+ const occurrences =
+ globalStyleTag?.textContent?.split(GLOBAL_CSS).length ?? 0;
+ expect(occurrences - 1).toBe(1);
+ });
+});
diff --git a/packages/editor/src/plugins/email-theming/extension.tsx b/packages/editor/src/plugins/email-theming/extension.tsx
new file mode 100644
index 0000000000..085f976aa5
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/extension.tsx
@@ -0,0 +1,370 @@
+import { Body, Head, Html, Preview, Section } from '@react-email/components';
+import type { Editor, JSONContent } from '@tiptap/core';
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { useEditorState } from '@tiptap/react';
+import type * as React from 'react';
+import type { SerializerPlugin } from '../../core/serializer/serializer-plugin';
+import { getGlobalContent } from '../../extensions/global-content';
+import {
+ injectGlobalPlainCss,
+ injectThemeCss,
+ mergeCssJs,
+ transformToCssJs,
+} from './css-transforms';
+import {
+ inferThemeFromPanelStyles,
+ normalizeThemePanelStyles,
+} from './normalization';
+import {
+ DEFAULT_INBOX_FONT_SIZE_PX,
+ EDITOR_THEMES,
+ RESET_THEMES,
+} from './themes';
+import type {
+ CssJs,
+ EditorTheme,
+ KnownThemeComponents,
+ PanelGroup,
+} from './types';
+
+/**
+ * Maps a document node (type + attrs) to the theme component key used for style lookup.
+ * Centralizes all node-type → theme-component knowledge.
+ */
+export function getThemeComponentKey(
+ nodeType: string,
+ depth: number,
+ attrs: Record = {},
+): KnownThemeComponents | null {
+ switch (nodeType) {
+ case 'paragraph':
+ if (depth > 0) {
+ return 'listParagraph';
+ }
+ return 'paragraph';
+ case 'heading': {
+ const level = attrs.level as number | undefined;
+ return `h${level ?? 1}` as KnownThemeComponents;
+ }
+ case 'blockquote':
+ return 'blockquote';
+ case 'button':
+ return 'button';
+ case 'section':
+ return 'section';
+ case 'footer':
+ return 'footer';
+ case 'image':
+ return 'image';
+ case 'youtube':
+ case 'twitter':
+ return 'image';
+ case 'orderedList':
+ case 'bulletList':
+ if (depth > 0) {
+ return 'nestedList';
+ }
+ return 'list';
+ case 'listItem':
+ return 'listItem';
+ case 'codeBlock':
+ return 'codeBlock';
+ case 'code':
+ return 'inlineCode';
+ case 'link':
+ return 'link';
+ case 'horizontalRule':
+ return 'hr';
+ default:
+ return null;
+ }
+}
+
+/**
+ * Returns merged theme styles (reset + panel styles) for the given editor.
+ * Use when you have editor access and need the full CssJs map.
+ */
+export function getMergedCssJs(
+ theme: EditorTheme,
+ panelStyles: PanelGroup[] | undefined,
+): CssJs {
+ const panels: PanelGroup[] =
+ normalizeThemePanelStyles(theme, panelStyles) ?? EDITOR_THEMES[theme];
+ const parsed = transformToCssJs(panels, DEFAULT_INBOX_FONT_SIZE_PX);
+ const merged = mergeCssJs(RESET_THEMES[theme], parsed);
+
+ return merged;
+}
+
+/**
+ * Returns resolved React.CSSProperties for a node when you already have merged CssJs
+ * (e.g. in the serializer where there is no editor). Centralizes which theme keys
+ * apply to which node type.
+ */
+const RESET_NODE_TYPES = new Set([
+ 'body',
+ 'bulletList',
+ 'button',
+ 'columns',
+ 'div',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'list',
+ 'listItem',
+ 'listParagraph',
+ 'nestedList',
+ 'orderedList',
+ 'table',
+ 'paragraph',
+ 'tableCell',
+ 'tableHeader',
+ 'tableRow',
+ 'youtube',
+]);
+
+export function getResolvedNodeStyles(
+ node: JSONContent,
+ depth: number,
+ mergedCssJs: CssJs,
+): React.CSSProperties {
+ const key = getThemeComponentKey(node.type ?? '', depth, node.attrs ?? {});
+ if (!key) {
+ if (RESET_NODE_TYPES.has(node.type ?? '')) {
+ return mergedCssJs.reset ?? {};
+ }
+ return {};
+ }
+ const component = mergedCssJs[key] ?? {};
+ const shouldReset =
+ RESET_NODE_TYPES.has(key) || RESET_NODE_TYPES.has(node.type ?? '');
+ if (shouldReset) {
+ const reset = mergedCssJs.reset ?? {};
+ return { ...reset, ...component };
+ }
+ return { ...component };
+}
+
+export function stylesToCss(
+ styles: PanelGroup[],
+ theme: EditorTheme,
+): Record {
+ const parsed = transformToCssJs(
+ normalizeThemePanelStyles(theme, styles) ?? EDITOR_THEMES[theme],
+ DEFAULT_INBOX_FONT_SIZE_PX,
+ );
+ return mergeCssJs(RESET_THEMES[theme], parsed);
+}
+
+function getEmailTheming(editor: Editor) {
+ const theme = getEmailTheme(editor);
+ const normalizedStyles =
+ normalizeThemePanelStyles(theme, getEmailStyles(editor)) ??
+ EDITOR_THEMES[theme];
+
+ return {
+ styles: normalizedStyles,
+ theme,
+ css: getEmailCss(editor),
+ };
+}
+
+export function useEmailTheming(editor: Editor | null) {
+ return useEditorState({
+ editor,
+ selector({ editor: ed }) {
+ if (!ed) {
+ return null;
+ }
+ return getEmailTheming(ed);
+ },
+ });
+}
+
+function getEmailStyles(editor: Editor) {
+ return getGlobalContent('styles', editor) as PanelGroup[] | null;
+}
+
+/**
+ * Sets the global panel styles on the editor document.
+ * Persists into the `GlobalContent` node under the `'styles'` key.
+ */
+export function setGlobalStyles(editor: Editor, styles: PanelGroup[]): boolean {
+ return editor.commands.setGlobalContent('styles', styles);
+}
+
+/**
+ * Sets the current email theme on the editor document.
+ * Persists into the `GlobalContent` node under the `'theme'` key.
+ */
+export function setCurrentTheme(editor: Editor, theme: EditorTheme): boolean {
+ return editor.commands.setGlobalContent('theme', theme);
+}
+
+/**
+ * Sets the global CSS string injected into the email ``.
+ * Persists into the `GlobalContent` node under the `'css'` key.
+ */
+export function setGlobalCssInjected(editor: Editor, css: string): boolean {
+ return editor.commands.setGlobalContent('css', css);
+}
+
+function getEmailTheme(editor: Editor) {
+ const extensionTheme = (
+ editor.extensionManager.extensions.find(
+ (extension) => extension.name === 'theming',
+ ) as { options?: { theme?: EditorTheme } }
+ )?.options?.theme;
+ if (extensionTheme === 'basic' || extensionTheme === 'minimal') {
+ return extensionTheme;
+ }
+
+ const globalTheme = getGlobalContent('theme', editor) as EditorTheme | null;
+ if (globalTheme === 'basic' || globalTheme === 'minimal') {
+ return globalTheme;
+ }
+
+ const inferredTheme = inferThemeFromPanelStyles(getEmailStyles(editor));
+ if (inferredTheme) {
+ return inferredTheme;
+ }
+
+ return 'basic';
+}
+
+function getEmailCss(editor: Editor) {
+ return getGlobalContent('css', editor) as string | null;
+}
+
+export const EmailTheming = Extension.create<{
+ theme?: EditorTheme;
+ serializerPlugin: SerializerPlugin;
+}>({
+ name: 'theming',
+
+ addOptions() {
+ return {
+ theme: undefined as EditorTheme | undefined,
+ serializerPlugin: {
+ getNodeStyles(
+ node: JSONContent,
+ depth: number,
+ editor: Editor,
+ ): React.CSSProperties {
+ const theming = getEmailTheming(editor);
+
+ return getResolvedNodeStyles(
+ node,
+ depth,
+ getMergedCssJs(theming.theme, theming.styles),
+ );
+ },
+ BaseTemplate({
+ previewText,
+ children,
+ editor,
+ }: {
+ previewText: string | null;
+ children: React.ReactNode;
+ editor: Editor;
+ }) {
+ const { css: globalCss, styles, theme } = getEmailTheming(editor);
+ const mergedStyles = getMergedCssJs(theme, styles);
+
+ return (
+
+
+
+
+
+
+
+ {globalCss && }
+
+ {previewText && previewText !== '' && (
+ {previewText}
+ )}
+
+
+
+
+ {children}
+
+
+
+
+ );
+ },
+ } satisfies SerializerPlugin,
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const { editor } = this;
+ const scopeId = `tiptap-theme-${Math.random().toString(36).slice(2, 10)}`;
+ const scopeAttribute = 'data-editor-theme-scope';
+ const scopeSelector = `.tiptap.ProseMirror[${scopeAttribute}="${scopeId}"]`;
+ const themeStyleId = `${scopeId}-theme`;
+ const globalStyleId = `${scopeId}-global`;
+
+ return [
+ new Plugin({
+ key: new PluginKey('themingStyleInjector'),
+ view(view) {
+ let prevStyles: PanelGroup[] | null = null;
+ let prevTheme: EditorTheme | null = null;
+ let prevCss: string | null = null;
+
+ view.dom.setAttribute(scopeAttribute, scopeId);
+
+ const sync = () => {
+ const theme = getEmailTheme(editor);
+ const styles = getEmailStyles(editor);
+ const resolvedStyles = styles ?? EDITOR_THEMES[theme];
+ const css = getEmailCss(editor);
+
+ if (styles !== prevStyles || theme !== prevTheme) {
+ prevStyles = styles as PanelGroup[] | null;
+ prevTheme = theme;
+ injectThemeCss(getMergedCssJs(theme, resolvedStyles), {
+ scopeSelector,
+ styleId: themeStyleId,
+ });
+ }
+
+ if (css !== prevCss) {
+ prevCss = css;
+ injectGlobalPlainCss(css, {
+ scopeSelector,
+ styleId: globalStyleId,
+ });
+ }
+ };
+
+ sync();
+
+ return {
+ update: sync,
+ destroy() {
+ document.getElementById(themeStyleId)?.remove();
+ document.getElementById(globalStyleId)?.remove();
+ view.dom.removeAttribute(scopeAttribute);
+ },
+ };
+ },
+ }),
+ ];
+ },
+});
diff --git a/packages/editor/src/plugins/email-theming/index.ts b/packages/editor/src/plugins/email-theming/index.ts
new file mode 100644
index 0000000000..d633b2a1c2
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/index.ts
@@ -0,0 +1,4 @@
+export * from './css-transforms';
+export * from './extension';
+export * from './themes';
+export * from './types';
diff --git a/packages/editor/src/plugins/email-theming/normalization.spec.ts b/packages/editor/src/plugins/email-theming/normalization.spec.ts
new file mode 100644
index 0000000000..fdee49ddeb
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/normalization.spec.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+import {
+ inferThemeFromPanelStyles,
+ normalizeThemePanelStyles,
+} from './normalization';
+
+describe('normalizeThemePanelStyles', () => {
+ it('maps legacy global panel groups to the current section ids', () => {
+ const result = normalizeThemePanelStyles('minimal', [
+ {
+ title: 'Body',
+ classReference: 'body',
+ inputs: [],
+ },
+ {
+ title: 'Container',
+ classReference: 'container',
+ inputs: [],
+ },
+ {
+ title: 'Typography',
+ classReference: 'body',
+ inputs: [],
+ },
+ {
+ title: 'Code Block',
+ classReference: 'codeBlock',
+ inputs: [],
+ },
+ ]);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: 'body',
+ title: 'Background',
+ classReference: 'body',
+ }),
+ expect.objectContaining({
+ id: 'container',
+ title: 'Content',
+ classReference: 'container',
+ }),
+ expect.objectContaining({
+ id: 'typography',
+ title: 'Text',
+ classReference: 'body',
+ }),
+ expect.objectContaining({
+ id: 'code-block',
+ title: 'Code Block',
+ classReference: 'codeBlock',
+ }),
+ ]);
+ });
+});
+
+describe('inferThemeFromPanelStyles', () => {
+ it('infers the minimal theme from legacy empty panel groups', () => {
+ expect(
+ inferThemeFromPanelStyles([
+ { title: 'Body', classReference: 'body', inputs: [] },
+ { title: 'Container', classReference: 'container', inputs: [] },
+ ]),
+ ).toBe('minimal');
+ });
+});
diff --git a/packages/editor/src/plugins/email-theming/normalization.ts b/packages/editor/src/plugins/email-theming/normalization.ts
new file mode 100644
index 0000000000..0d564b7c0b
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/normalization.ts
@@ -0,0 +1,164 @@
+import { EDITOR_THEMES } from './themes';
+import type {
+ EditorTheme,
+ KnownThemeComponents,
+ PanelGroup,
+ PanelSectionId,
+} from './types';
+
+const PANEL_SECTION_IDS = new Set([
+ 'body',
+ 'container',
+ 'typography',
+ 'link',
+ 'image',
+ 'button',
+ 'code-block',
+ 'inline-code',
+]);
+
+const PANEL_SECTION_IDS_BY_TITLE: Record = {
+ background: 'body',
+ body: 'body',
+ content: 'container',
+ container: 'container',
+ typography: 'typography',
+ link: 'link',
+ image: 'image',
+ button: 'button',
+ 'code block': 'code-block',
+ 'inline code': 'inline-code',
+};
+
+const PANEL_SECTION_IDS_BY_CLASS_REFERENCE: Partial<
+ Record
+> = {
+ container: 'container',
+ link: 'link',
+ image: 'image',
+ button: 'button',
+ codeBlock: 'code-block',
+ inlineCode: 'inline-code',
+};
+
+function isPanelSectionId(value: unknown): value is PanelSectionId {
+ return (
+ typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId)
+ );
+}
+
+function normalizeTitle(title: string | undefined): string {
+ return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? '';
+}
+
+function resolvePanelSectionId(group: PanelGroup): PanelSectionId | null {
+ if (isPanelSectionId(group.id)) {
+ return group.id;
+ }
+
+ const normalizedTitle = normalizeTitle(group.title);
+
+ if (group.classReference === 'body') {
+ if (normalizedTitle === 'typography') {
+ return 'typography';
+ }
+
+ return 'body';
+ }
+
+ if (
+ group.classReference &&
+ PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference]
+ ) {
+ return PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] ?? null;
+ }
+
+ return PANEL_SECTION_IDS_BY_TITLE[normalizedTitle] ?? null;
+}
+
+function normalizePanelInputs(
+ inputs: PanelGroup['inputs'],
+ defaultInputs: PanelGroup['inputs'],
+ fallbackClassReference?: KnownThemeComponents,
+): PanelGroup['inputs'] {
+ if (!Array.isArray(inputs)) {
+ return [];
+ }
+
+ return inputs.map((input) => {
+ const defaultInput = defaultInputs.find(
+ (candidate) => candidate.prop === input.prop,
+ );
+
+ return {
+ ...defaultInput,
+ ...input,
+ classReference:
+ input.classReference ??
+ defaultInput?.classReference ??
+ fallbackClassReference,
+ };
+ });
+}
+
+export function inferThemeFromPanelStyles(
+ panelStyles: PanelGroup[] | null | undefined,
+): EditorTheme | null {
+ if (!Array.isArray(panelStyles) || panelStyles.length === 0) {
+ return null;
+ }
+
+ let finalTheme: EditorTheme | null = null;
+ for (const group of panelStyles) {
+ if (!Array.isArray(group?.inputs)) {
+ finalTheme = null;
+ break;
+ }
+
+ if (group.inputs.length !== 0) {
+ finalTheme = 'basic';
+ break;
+ }
+
+ finalTheme = 'minimal';
+ }
+
+ return finalTheme;
+}
+
+export function normalizeThemePanelStyles(
+ theme: EditorTheme,
+ panelStyles: PanelGroup[] | null | undefined,
+): PanelGroup[] | null {
+ if (!Array.isArray(panelStyles)) {
+ return null;
+ }
+
+ return panelStyles.map((group) => {
+ const panelId = resolvePanelSectionId(group);
+
+ if (!panelId) {
+ return group;
+ }
+
+ const defaultGroup = EDITOR_THEMES[theme].find(
+ (candidate) => candidate.id === panelId,
+ );
+
+ if (!defaultGroup) {
+ return group;
+ }
+
+ return {
+ ...group,
+ id: panelId,
+ title: defaultGroup.title,
+ classReference: defaultGroup.classReference,
+ inputs: normalizePanelInputs(
+ group.inputs,
+ defaultGroup.inputs,
+ defaultGroup.classReference,
+ ),
+ };
+ });
+}
diff --git a/packages/editor/src/plugins/email-theming/themes.ts b/packages/editor/src/plugins/email-theming/themes.ts
new file mode 100644
index 0000000000..6cf0184405
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/themes.ts
@@ -0,0 +1,761 @@
+import type {
+ EditorTheme,
+ PanelGroup,
+ PanelSectionId,
+ ResetTheme,
+ SupportedCssProperties,
+} from './types';
+
+/**
+ * Single source of truth for panel section display titles.
+ * Titles are resolved from here at render time via `getPanelTitle`,
+ * so they never depend on what's persisted in the DB.
+ */
+const PANEL_SECTION_TITLES: Record = {
+ body: 'Background',
+ container: 'Body',
+ typography: 'Text',
+ h1: 'Title',
+ h2: 'Subtitle',
+ h3: 'Heading',
+ link: 'Link',
+ image: 'Image',
+ button: 'Button',
+ 'code-block': 'Code Block',
+ 'inline-code': 'Inline Code',
+};
+
+/**
+ * Resolves the display title for a panel group.
+ * Uses the `id` lookup when available, falls back to the
+ * DB-persisted `title` for backwards compatibility.
+ */
+export function getPanelTitle(group: PanelGroup): string {
+ if (group.id && group.id in PANEL_SECTION_TITLES) {
+ return PANEL_SECTION_TITLES[group.id];
+ }
+ return group.title;
+}
+
+const THEME_BASIC: PanelGroup[] = [
+ {
+ id: 'body',
+ title: 'Background',
+ classReference: 'body',
+ inputs: [],
+ },
+ {
+ id: 'container',
+ title: 'Content',
+ classReference: 'container',
+ inputs: [
+ {
+ label: 'Align',
+ type: 'select',
+ value: 'left',
+ options: {
+ left: 'Left',
+ center: 'Center',
+ right: 'Right',
+ },
+ prop: 'align',
+ classReference: 'container',
+ },
+ {
+ label: 'Width',
+ type: 'number',
+ value: 600,
+ unit: 'px',
+ prop: 'width',
+ classReference: 'container',
+ },
+ {
+ label: 'Padding Left',
+ type: 'number',
+ value: 0,
+ unit: 'px',
+ prop: 'paddingLeft',
+ classReference: 'container',
+ },
+ {
+ label: 'Padding Right',
+ type: 'number',
+ value: 0,
+ unit: 'px',
+ prop: 'paddingRight',
+ classReference: 'container',
+ },
+ ],
+ },
+ {
+ id: 'typography',
+ title: 'Text',
+ classReference: 'body',
+ inputs: [
+ {
+ label: 'Font size',
+ type: 'number',
+ value: 14,
+ unit: 'px',
+ prop: 'fontSize',
+ classReference: 'body',
+ },
+ {
+ label: 'Line Height',
+ type: 'number',
+ value: 155,
+ unit: '%',
+ prop: 'lineHeight',
+ classReference: 'container',
+ },
+ ],
+ },
+ {
+ id: 'h1',
+ title: 'Title',
+ category: 'Text',
+ classReference: 'h1',
+ inputs: [],
+ },
+ {
+ id: 'h2',
+ title: 'Subtitle',
+ category: 'Text',
+ classReference: 'h2',
+ inputs: [],
+ },
+ {
+ id: 'h3',
+ title: 'Heading',
+ category: 'Text',
+ classReference: 'h3',
+ inputs: [],
+ },
+ {
+ id: 'link',
+ title: 'Link',
+ classReference: 'link',
+ inputs: [
+ {
+ label: 'Color',
+ type: 'color',
+ value: '#0670DB',
+ prop: 'color',
+ classReference: 'link',
+ },
+ {
+ label: 'Decoration',
+ type: 'select',
+ value: 'underline',
+ prop: 'textDecoration',
+ options: {
+ underline: 'Underline',
+ none: 'None',
+ },
+ classReference: 'link',
+ },
+ ],
+ },
+ {
+ id: 'image',
+ title: 'Image',
+ classReference: 'image',
+ inputs: [
+ {
+ label: 'Border radius',
+ type: 'number',
+ value: 8,
+ unit: 'px',
+ prop: 'borderRadius',
+ classReference: 'image',
+ },
+ ],
+ },
+ {
+ id: 'button',
+ title: 'Button',
+ classReference: 'button',
+ inputs: [
+ {
+ label: 'Background',
+ type: 'color',
+ value: '#000000',
+ prop: 'backgroundColor',
+ classReference: 'button',
+ },
+ {
+ label: 'Text color',
+ type: 'color',
+ value: '#ffffff',
+ prop: 'color',
+ classReference: 'button',
+ },
+ {
+ label: 'Radius',
+ type: 'number',
+ value: 4,
+ unit: 'px',
+ prop: 'borderRadius',
+ classReference: 'button',
+ },
+ {
+ label: 'Padding Top',
+ type: 'number',
+ value: 7,
+ unit: 'px',
+ prop: 'paddingTop',
+ classReference: 'button',
+ },
+ {
+ label: 'Padding Right',
+ type: 'number',
+ value: 12,
+ unit: 'px',
+ prop: 'paddingRight',
+ classReference: 'button',
+ },
+ {
+ label: 'Padding Bottom',
+ type: 'number',
+ value: 7,
+ unit: 'px',
+ prop: 'paddingBottom',
+ classReference: 'button',
+ },
+ {
+ label: 'Padding Left',
+ type: 'number',
+ value: 12,
+ unit: 'px',
+ prop: 'paddingLeft',
+ classReference: 'button',
+ },
+ ],
+ },
+ {
+ id: 'code-block',
+ title: 'Code Block',
+ classReference: 'codeBlock',
+ inputs: [
+ {
+ label: 'Border Radius',
+ type: 'number',
+ value: 4,
+ unit: 'px',
+ prop: 'borderRadius',
+ classReference: 'codeBlock',
+ },
+ {
+ label: 'Padding Top',
+ type: 'number',
+ value: 12,
+ unit: 'px',
+ prop: 'paddingTop',
+ classReference: 'codeBlock',
+ },
+ {
+ label: 'Padding Bottom',
+ type: 'number',
+ value: 12,
+ unit: 'px',
+ prop: 'paddingBottom',
+ classReference: 'codeBlock',
+ },
+ {
+ label: 'Padding Left',
+ type: 'number',
+ value: 16,
+ unit: 'px',
+ prop: 'paddingLeft',
+ classReference: 'codeBlock',
+ },
+ {
+ label: 'Padding Right',
+ type: 'number',
+ value: 16,
+ unit: 'px',
+ prop: 'paddingRight',
+ classReference: 'codeBlock',
+ },
+ ],
+ },
+ {
+ id: 'inline-code',
+ title: 'Inline Code',
+ classReference: 'inlineCode',
+ inputs: [
+ {
+ label: 'Background',
+ type: 'color',
+ value: '#e5e7eb',
+ prop: 'backgroundColor',
+ classReference: 'inlineCode',
+ },
+ {
+ label: 'Text color',
+ type: 'color',
+ value: '#1e293b',
+ prop: 'color',
+ classReference: 'inlineCode',
+ },
+ {
+ label: 'Radius',
+ type: 'number',
+ value: 4,
+ unit: 'px',
+ prop: 'borderRadius',
+ classReference: 'inlineCode',
+ },
+ ],
+ },
+];
+
+const THEME_MINIMAL = THEME_BASIC.map((item) => ({ ...item, inputs: [] }));
+
+const RESET_BASIC: ResetTheme = {
+ reset: {
+ margin: '0',
+ padding: '0',
+ },
+ body: {
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
+ fontSize: '14px',
+ minHeight: '100%',
+ lineHeight: '155%',
+ },
+ container: {},
+ h1: {
+ fontSize: '2.25em',
+ lineHeight: '1.44em',
+ paddingTop: '0.389em',
+ fontWeight: 600,
+ },
+ h2: {
+ fontSize: '1.8em',
+ lineHeight: '1.44em',
+ paddingTop: '0.389em',
+ fontWeight: 600,
+ },
+ h3: {
+ fontSize: '1.4em',
+ lineHeight: '1.08em',
+ paddingTop: '0.389em',
+ fontWeight: 600,
+ },
+ paragraph: {
+ fontSize: '1em',
+ paddingTop: '0.5em',
+ paddingBottom: '0.5em',
+ },
+ list: {
+ paddingLeft: '1.1em',
+ paddingBottom: '1em',
+ },
+ nestedList: {
+ paddingLeft: '1.1em',
+ paddingBottom: '0',
+ },
+ listItem: {
+ marginLeft: '1em',
+ marginBottom: '0.3em',
+ marginTop: '0.3em',
+ },
+ listParagraph: { padding: '0', margin: '0' },
+ blockquote: {
+ borderLeft: '3px solid #acb3be',
+ color: '#7e8a9a',
+ marginLeft: 0,
+ paddingLeft: '0.8em',
+ fontSize: '1.1em',
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
+ },
+ link: { textDecoration: 'underline' },
+ footer: {
+ fontSize: '0.8em',
+ },
+ hr: {
+ paddingBottom: '1em',
+ borderWidth: '2px',
+ },
+ image: {
+ maxWidth: '100%',
+ },
+ button: {
+ lineHeight: '100%',
+ display: 'inline-block',
+ },
+ inlineCode: {
+ paddingTop: '0.25em',
+ paddingBottom: '0.25em',
+ paddingLeft: '0.4em',
+ paddingRight: '0.4em',
+ background: '#e5e7eb',
+ color: '#1e293b',
+ borderRadius: '4px',
+ },
+ codeBlock: {
+ fontFamily: 'monospace',
+ fontWeight: '500',
+ fontSize: '.92em',
+ },
+ codeTag: {
+ lineHeight: '130%',
+ fontFamily: 'monospace',
+ fontSize: '.92em',
+ },
+ section: {
+ padding: '10px 20px 10px 20px',
+ boxSizing: 'border-box' as const,
+ },
+};
+
+const RESET_MINIMAL: ResetTheme = {
+ ...Object.keys(RESET_BASIC).reduce((acc, key) => {
+ acc[key as keyof ResetTheme] = {};
+ return acc;
+ }, {} as ResetTheme),
+ reset: RESET_BASIC.reset,
+};
+
+export const RESET_THEMES: Record = {
+ basic: RESET_BASIC,
+ minimal: RESET_MINIMAL,
+};
+
+export function resolveResetValue(
+ value: string | number | undefined,
+ targetUnit: 'px' | '%',
+ bodyFontSizePx: number,
+): number | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+ const str = String(value);
+ const num = Number.parseFloat(str);
+ if (Number.isNaN(num)) {
+ return undefined;
+ }
+ if (str.endsWith('em')) {
+ return targetUnit === 'px' ? Math.floor(num * bodyFontSizePx) : num * 100;
+ }
+ return num;
+}
+
+export const EDITOR_THEMES: Record = {
+ minimal: THEME_MINIMAL,
+ basic: THEME_BASIC,
+};
+
+export function getThemeBodyFontSizePx(theme: EditorTheme): number {
+ for (const group of EDITOR_THEMES[theme]) {
+ if (group.classReference !== 'body') {
+ continue;
+ }
+ for (const input of group.inputs) {
+ if (input.prop === 'fontSize' && typeof input.value === 'number') {
+ return input.value;
+ }
+ }
+ }
+ return 14;
+}
+
+/**
+ * Use to make the preview nicer once the theme might miss some
+ * important properties to make layout accurate
+ */
+export const DEFAULT_INBOX_FONT_SIZE_PX = 14;
+export const INBOX_EMAIL_DEFAULTS: Partial = {
+ body: {
+ color: '#000000',
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
+ fontSize: `${DEFAULT_INBOX_FONT_SIZE_PX}px`,
+ lineHeight: '155%',
+ },
+ container: {
+ width: 600,
+ },
+};
+
+export const SUPPORTED_CSS_PROPERTIES: SupportedCssProperties = {
+ align: {
+ label: 'Align',
+ type: 'select',
+ options: {
+ left: 'Left',
+ center: 'Center',
+ right: 'Right',
+ },
+ defaultValue: 'left',
+ category: 'layout',
+ },
+ backgroundColor: {
+ label: 'Background',
+ type: 'color',
+ excludeNodes: ['image', 'youtube'],
+ defaultValue: '#ffffff',
+ category: 'appearance',
+ },
+ color: {
+ label: 'Text color',
+ type: 'color',
+ excludeNodes: ['image', 'youtube'],
+ defaultValue: '#000000',
+ category: 'typography',
+ },
+ fontSize: {
+ label: 'Font size',
+ type: 'number',
+ unit: 'px',
+ excludeNodes: ['image', 'youtube'],
+ defaultValue: 14,
+ category: 'typography',
+ },
+ fontWeight: {
+ label: 'Font weight',
+ type: 'select',
+ options: {
+ 300: 'Light',
+ 400: 'Normal',
+ 600: 'Semi Bold',
+ 700: 'Bold',
+ 800: 'Extra Bold',
+ },
+ excludeNodes: ['image', 'youtube'],
+ defaultValue: 400,
+ category: 'typography',
+ },
+ letterSpacing: {
+ label: 'Letter spacing',
+ type: 'number',
+ unit: 'px',
+ excludeNodes: ['image', 'youtube'],
+ defaultValue: 0,
+ category: 'typography',
+ },
+ lineHeight: {
+ label: 'Line height',
+ type: 'number',
+ unit: '%',
+ defaultValue: 155,
+ category: 'typography',
+ },
+ textDecoration: {
+ label: 'Text decoration',
+ type: 'select',
+ options: {
+ none: 'None',
+ underline: 'Underline',
+ 'line-through': 'Line-through',
+ },
+ defaultValue: 'none',
+ category: 'typography',
+ },
+ borderRadius: {
+ label: 'Border Radius',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'appearance',
+ },
+ borderTopLeftRadius: {
+ label: 'Border Radius (Top-Left)',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'appearance',
+ },
+ borderTopRightRadius: {
+ label: 'Border Radius (Top-Right)',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'appearance',
+ },
+ borderBottomLeftRadius: {
+ label: 'Border Radius (Bottom-Left)',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'appearance',
+ },
+ borderBottomRightRadius: {
+ label: 'Border Radius (Bottom-Right)',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'appearance',
+ },
+ borderWidth: {
+ label: 'Border Width',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 1,
+ category: 'appearance',
+ },
+ borderTopWidth: {
+ label: 'Border Top Width',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 1,
+ category: 'appearance',
+ },
+ borderRightWidth: {
+ label: 'Border Right Width',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 1,
+ category: 'appearance',
+ },
+ borderBottomWidth: {
+ label: 'Border Bottom Width',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 1,
+ category: 'appearance',
+ },
+ borderLeftWidth: {
+ label: 'Border Left Width',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 1,
+ category: 'appearance',
+ },
+ borderStyle: {
+ label: 'Border Style',
+ type: 'select',
+ options: {
+ solid: 'Solid',
+ dashed: 'Dashed',
+ dotted: 'Dotted',
+ },
+ defaultValue: 'solid',
+ category: 'appearance',
+ },
+ borderTopStyle: {
+ label: 'Border Top Style',
+ type: 'select',
+ options: {
+ solid: 'Solid',
+ dashed: 'Dashed',
+ dotted: 'Dotted',
+ },
+ defaultValue: 'solid',
+ category: 'appearance',
+ },
+ borderRightStyle: {
+ label: 'Border Right Style',
+ type: 'select',
+ options: {
+ solid: 'Solid',
+ dashed: 'Dashed',
+ dotted: 'Dotted',
+ },
+ defaultValue: 'solid',
+ category: 'appearance',
+ },
+ borderBottomStyle: {
+ label: 'Border Bottom Style',
+ type: 'select',
+ options: {
+ solid: 'Solid',
+ dashed: 'Dashed',
+ dotted: 'Dotted',
+ },
+ defaultValue: 'solid',
+ category: 'appearance',
+ },
+ borderLeftStyle: {
+ label: 'Border Left Style',
+ type: 'select',
+ options: {
+ solid: 'Solid',
+ dashed: 'Dashed',
+ dotted: 'Dotted',
+ },
+ defaultValue: 'solid',
+ category: 'appearance',
+ },
+ borderColor: {
+ label: 'Border Color',
+ type: 'color',
+ defaultValue: '#000000',
+ category: 'appearance',
+ },
+ borderTopColor: {
+ label: 'Border Top Color',
+ type: 'color',
+ defaultValue: '#000000',
+ category: 'appearance',
+ },
+ borderRightColor: {
+ label: 'Border Right Color',
+ type: 'color',
+ defaultValue: '#000000',
+ category: 'appearance',
+ },
+ borderBottomColor: {
+ label: 'Border Bottom Color',
+ type: 'color',
+ defaultValue: '#000000',
+ category: 'appearance',
+ },
+ borderLeftColor: {
+ label: 'Border Left Color',
+ type: 'color',
+ defaultValue: '#000000',
+ category: 'appearance',
+ },
+ padding: {
+ label: 'Padding',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'layout',
+ },
+ paddingTop: {
+ label: 'Padding Top',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'layout',
+ },
+ paddingLeft: {
+ label: 'Padding Left',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'layout',
+ },
+ paddingBottom: {
+ label: 'Padding Bottom',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'layout',
+ },
+ paddingRight: {
+ label: 'Padding Right',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 8,
+ category: 'layout',
+ },
+ width: {
+ label: 'Width',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 600,
+ category: 'layout',
+ },
+ height: {
+ label: 'Height',
+ type: 'number',
+ unit: 'px',
+ defaultValue: 400,
+ category: 'layout',
+ },
+};
diff --git a/packages/editor/src/plugins/email-theming/types.ts b/packages/editor/src/plugins/email-theming/types.ts
new file mode 100644
index 0000000000..810c3fe5d1
--- /dev/null
+++ b/packages/editor/src/plugins/email-theming/types.ts
@@ -0,0 +1,124 @@
+import type * as React from 'react';
+
+type InputType = 'color' | 'number' | 'select' | 'text' | 'textarea';
+type InputUnit = 'px' | '%';
+type Options = Record;
+
+export type EditorTheme = 'basic' | 'minimal';
+export type PanelSectionId =
+ | 'body'
+ | 'container'
+ | 'typography'
+ | 'h1'
+ | 'h2'
+ | 'h3'
+ | 'link'
+ | 'image'
+ | 'button'
+ | 'code-block'
+ | 'inline-code';
+export type KnownThemeComponents =
+ | 'reset'
+ | 'body'
+ | 'container'
+ | 'h1'
+ | 'h2'
+ | 'h3'
+ | 'paragraph'
+ | 'nestedList'
+ | 'list'
+ | 'listItem'
+ | 'listParagraph'
+ | 'blockquote'
+ | 'codeBlock'
+ | 'inlineCode'
+ | 'codeTag'
+ | 'link'
+ | 'footer'
+ | 'hr'
+ | 'image'
+ | 'button'
+ | 'section';
+
+export type KnownCssProperties =
+ | 'align'
+ | 'backgroundColor'
+ | 'color'
+ | 'fontSize'
+ | 'fontWeight'
+ | 'letterSpacing'
+ | 'lineHeight'
+ | 'textDecoration'
+ | 'borderRadius'
+ | 'borderTopLeftRadius'
+ | 'borderTopRightRadius'
+ | 'borderBottomLeftRadius'
+ | 'borderBottomRightRadius'
+ | 'borderWidth'
+ | 'borderTopWidth'
+ | 'borderRightWidth'
+ | 'borderBottomWidth'
+ | 'borderLeftWidth'
+ | 'borderStyle'
+ | 'borderTopStyle'
+ | 'borderRightStyle'
+ | 'borderBottomStyle'
+ | 'borderLeftStyle'
+ | 'borderColor'
+ | 'borderTopColor'
+ | 'borderRightColor'
+ | 'borderBottomColor'
+ | 'borderLeftColor'
+ | 'padding'
+ | 'paddingTop'
+ | 'paddingRight'
+ | 'paddingBottom'
+ | 'paddingLeft'
+ | 'width'
+ | 'height';
+
+export type ResetTheme = Record;
+
+export type CssJs = {
+ [K in KnownThemeComponents]: React.CSSProperties & {
+ // TODO: remove align as soon as possible
+ align?: 'center' | 'left' | 'right';
+ };
+};
+export type SupportedCssProperties = {
+ [K in KnownCssProperties]: {
+ category: 'layout' | 'appearance' | 'typography';
+ label: string;
+ type: InputType;
+ defaultValue: string | number;
+ unit?: InputUnit;
+ options?: Options;
+ excludeNodes?: string[];
+ placeholder?: string;
+ customUpdate?: (
+ props: Record,
+ update: (func: (tree: PanelGroup[]) => PanelGroup[]) => void,
+ ) => void;
+ };
+};
+
+export interface PanelInputProperty {
+ label: string;
+ type: InputType;
+ value: string | number;
+ prop: KnownCssProperties;
+ classReference?: KnownThemeComponents;
+ unit?: InputUnit;
+ options?: Options;
+ placeholder?: string;
+ category: SupportedCssProperties[KnownCssProperties]['category'];
+}
+
+export interface PanelGroup {
+ id?: PanelSectionId;
+ title: string;
+ category?: string;
+ headerSlot?: React.ReactNode;
+ classReference?: KnownThemeComponents;
+ inputs: Omit[];
+}
diff --git a/packages/editor/src/plugins/index.ts b/packages/editor/src/plugins/index.ts
new file mode 100644
index 0000000000..9ef381834e
--- /dev/null
+++ b/packages/editor/src/plugins/index.ts
@@ -0,0 +1 @@
+export * from './email-theming';
diff --git a/packages/editor/src/ui/bubble-menu/align-center.tsx b/packages/editor/src/ui/bubble-menu/align-center.tsx
new file mode 100644
index 0000000000..3b8f6cf6b4
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/align-center.tsx
@@ -0,0 +1,30 @@
+import { useEditorState } from '@tiptap/react';
+import { setTextAlignment } from '../../utils/set-text-alignment';
+import { AlignCenterIcon } from '../icons';
+import { useBubbleMenuContext } from './context';
+import type { PreWiredItemProps } from './create-mark-bubble-item';
+import { BubbleMenuItem } from './item';
+
+export function BubbleMenuAlignCenter({
+ className,
+ children,
+}: PreWiredItemProps) {
+ const { editor } = useBubbleMenuContext();
+
+ const isActive = useEditorState({
+ editor,
+ selector: ({ editor }) =>
+ editor?.isActive({ alignment: 'center' }) ?? false,
+ });
+
+ return (
+ setTextAlignment(editor, 'center')}
+ className={className}
+ >
+ {children ?? }
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/align-left.tsx b/packages/editor/src/ui/bubble-menu/align-left.tsx
new file mode 100644
index 0000000000..b32dc75195
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/align-left.tsx
@@ -0,0 +1,29 @@
+import { useEditorState } from '@tiptap/react';
+import { setTextAlignment } from '../../utils/set-text-alignment';
+import { AlignLeftIcon } from '../icons';
+import { useBubbleMenuContext } from './context';
+import type { PreWiredItemProps } from './create-mark-bubble-item';
+import { BubbleMenuItem } from './item';
+
+export function BubbleMenuAlignLeft({
+ className,
+ children,
+}: PreWiredItemProps) {
+ const { editor } = useBubbleMenuContext();
+
+ const isActive = useEditorState({
+ editor,
+ selector: ({ editor }) => editor?.isActive({ alignment: 'left' }) ?? false,
+ });
+
+ return (
+ setTextAlignment(editor, 'left')}
+ className={className}
+ >
+ {children ?? }
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/align-right.tsx b/packages/editor/src/ui/bubble-menu/align-right.tsx
new file mode 100644
index 0000000000..16f52b7c11
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/align-right.tsx
@@ -0,0 +1,29 @@
+import { useEditorState } from '@tiptap/react';
+import { setTextAlignment } from '../../utils/set-text-alignment';
+import { AlignRightIcon } from '../icons';
+import { useBubbleMenuContext } from './context';
+import type { PreWiredItemProps } from './create-mark-bubble-item';
+import { BubbleMenuItem } from './item';
+
+export function BubbleMenuAlignRight({
+ className,
+ children,
+}: PreWiredItemProps) {
+ const { editor } = useBubbleMenuContext();
+
+ const isActive = useEditorState({
+ editor,
+ selector: ({ editor }) => editor?.isActive({ alignment: 'right' }) ?? false,
+ });
+
+ return (
+ setTextAlignment(editor, 'right')}
+ className={className}
+ >
+ {children ?? }
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/bold.tsx b/packages/editor/src/ui/bubble-menu/bold.tsx
new file mode 100644
index 0000000000..05a7c250c0
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/bold.tsx
@@ -0,0 +1,9 @@
+import { BoldIcon } from '../icons';
+import { createMarkBubbleItem } from './create-mark-bubble-item';
+
+export const BubbleMenuBold = createMarkBubbleItem({
+ name: 'bold',
+ activeName: 'bold',
+ command: 'toggleBold',
+ icon: ,
+});
diff --git a/packages/editor/src/ui/bubble-menu/bubble-menu.css b/packages/editor/src/ui/bubble-menu/bubble-menu.css
new file mode 100644
index 0000000000..e4a5a1062e
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/bubble-menu.css
@@ -0,0 +1,146 @@
+/* Minimal functional styles for BubbleMenu compound components.
+ * This file handles layout and positioning only - no visual design.
+ * Import this optionally: import '@react-email/editor/styles/bubble-menu.css';
+ */
+
+[data-re-bubble-menu] {
+ display: flex;
+ align-items: center;
+ gap: 0.125rem;
+}
+
+[data-re-bubble-menu-group] {
+ display: flex;
+ align-items: center;
+ gap: 0.125rem;
+ padding: 0 0.125rem;
+ border: none;
+ margin: 0;
+ min-width: 0;
+}
+
+[data-re-bubble-menu-separator] {
+ align-self: stretch;
+ width: 1px;
+ margin: 0.25rem 0;
+}
+
+[data-re-bubble-menu-item] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.375rem;
+}
+
+[data-re-bubble-menu-item] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-re-node-selector] {
+ position: relative;
+}
+
+[data-re-node-selector-trigger] {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ cursor: pointer;
+ border: none;
+ background: none;
+ white-space: nowrap;
+ font-size: 0.8125rem;
+ padding: 0.375rem 0.5rem;
+}
+
+[data-re-node-selector-trigger] svg {
+ width: 0.75rem;
+ height: 0.75rem;
+ opacity: 0.5;
+}
+
+[data-re-node-selector-content] {
+ display: flex;
+ flex-direction: column;
+ min-width: 10rem;
+}
+
+[data-re-node-selector-item] {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.375rem 0.5rem;
+ font-size: 0.8125rem;
+ width: 100%;
+ text-align: left;
+}
+
+[data-re-node-selector-item] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-re-link-selector] {
+ display: flex;
+ position: relative;
+}
+
+[data-re-link-selector-trigger] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.375rem;
+}
+
+[data-re-link-selector-trigger] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-re-link-selector-form] {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 0.25rem;
+ width: max-content;
+ min-width: 16rem;
+ padding: 0.25rem;
+}
+
+[data-re-link-selector-input] {
+ flex: 1;
+ border: none;
+ outline: none;
+ font-size: 0.8125rem;
+ padding: 0.25rem;
+ background: transparent;
+}
+
+[data-re-link-selector-apply],
+[data-re-link-selector-unlink] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.25rem;
+}
+
+[data-re-link-selector-apply] svg,
+[data-re-link-selector-unlink] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
diff --git a/packages/editor/src/ui/bubble-menu/code.tsx b/packages/editor/src/ui/bubble-menu/code.tsx
new file mode 100644
index 0000000000..cc0f4e0ef1
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/code.tsx
@@ -0,0 +1,9 @@
+import { CodeIcon } from '../icons';
+import { createMarkBubbleItem } from './create-mark-bubble-item';
+
+export const BubbleMenuCode = createMarkBubbleItem({
+ name: 'code',
+ activeName: 'code',
+ command: 'toggleCode',
+ icon: ,
+});
diff --git a/packages/editor/src/ui/bubble-menu/context.tsx b/packages/editor/src/ui/bubble-menu/context.tsx
new file mode 100644
index 0000000000..afe7ded67f
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/context.tsx
@@ -0,0 +1,19 @@
+import type { Editor } from '@tiptap/core';
+import * as React from 'react';
+
+export interface BubbleMenuContextValue {
+ editor: Editor;
+}
+
+export const BubbleMenuContext =
+ React.createContext(null);
+
+export function useBubbleMenuContext(): BubbleMenuContextValue {
+ const context = React.useContext(BubbleMenuContext);
+ if (!context) {
+ throw new Error(
+ 'BubbleMenu compound components must be used within ',
+ );
+ }
+ return context;
+}
diff --git a/packages/editor/src/ui/bubble-menu/create-mark-bubble-item.tsx b/packages/editor/src/ui/bubble-menu/create-mark-bubble-item.tsx
new file mode 100644
index 0000000000..476a9895f5
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/create-mark-bubble-item.tsx
@@ -0,0 +1,61 @@
+import { useEditorState } from '@tiptap/react';
+import type * as React from 'react';
+import { useBubbleMenuContext } from './context';
+import { BubbleMenuItem } from './item';
+
+export interface PreWiredItemProps {
+ className?: string;
+ /** Override the default icon */
+ children?: React.ReactNode;
+}
+
+interface MarkBubbleItemConfig {
+ name: string;
+ activeName: string;
+ activeParams?: Record;
+ command: string;
+ icon: React.ReactNode;
+}
+
+export function createMarkBubbleItem(config: MarkBubbleItemConfig) {
+ function MarkBubbleItem({ className, children }: PreWiredItemProps) {
+ const { editor } = useBubbleMenuContext();
+
+ const isActive = useEditorState({
+ editor,
+ selector: ({ editor }) => {
+ if (config.activeParams) {
+ return (
+ editor?.isActive(config.activeName, config.activeParams) ?? false
+ );
+ }
+ return editor?.isActive(config.activeName) ?? false;
+ },
+ });
+
+ const handleCommand = () => {
+ const chain = editor.chain().focus();
+ const method = (chain as unknown as Record typeof chain>)[
+ config.command
+ ];
+ if (method) {
+ method.call(chain).run();
+ }
+ };
+
+ return (
+
+ {children ?? config.icon}
+
+ );
+ }
+
+ MarkBubbleItem.displayName = `BubbleMenu${config.name.charAt(0).toUpperCase() + config.name.slice(1)}`;
+
+ return MarkBubbleItem;
+}
diff --git a/packages/editor/src/ui/bubble-menu/default.spec.tsx b/packages/editor/src/ui/bubble-menu/default.spec.tsx
new file mode 100644
index 0000000000..bba19c120f
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/default.spec.tsx
@@ -0,0 +1,315 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/react';
+import { BubbleMenuDefault } from './default';
+
+const mockEditor = {
+ isActive: vi.fn().mockReturnValue(false),
+ getAttributes: vi.fn().mockReturnValue({}),
+ chain: vi.fn().mockReturnValue({
+ focus: vi.fn().mockReturnThis(),
+ clearNodes: vi.fn().mockReturnThis(),
+ toggleNode: vi.fn().mockReturnThis(),
+ toggleHeading: vi.fn().mockReturnThis(),
+ toggleBulletList: vi.fn().mockReturnThis(),
+ toggleOrderedList: vi.fn().mockReturnThis(),
+ toggleBlockquote: vi.fn().mockReturnThis(),
+ toggleCodeBlock: vi.fn().mockReturnThis(),
+ toggleBold: vi.fn().mockReturnThis(),
+ toggleItalic: vi.fn().mockReturnThis(),
+ toggleUnderline: vi.fn().mockReturnThis(),
+ toggleStrike: vi.fn().mockReturnThis(),
+ toggleCode: vi.fn().mockReturnThis(),
+ toggleUppercase: vi.fn().mockReturnThis(),
+ unsetLink: vi.fn().mockReturnThis(),
+ setLink: vi.fn().mockReturnThis(),
+ extendMarkRange: vi.fn().mockReturnThis(),
+ setTextSelection: vi.fn().mockReturnThis(),
+ run: vi.fn(),
+ }),
+ view: {
+ state: {
+ selection: {
+ content: () => ({ size: 1 }),
+ $from: { depth: 0, node: () => ({ type: { name: 'doc' } }) },
+ },
+ },
+ dom: { classList: { contains: vi.fn().mockReturnValue(false) } },
+ },
+ state: { selection: { from: 0, to: 5 } },
+ commands: { focus: vi.fn() },
+ on: vi.fn(),
+ off: vi.fn(),
+};
+
+vi.mock('@tiptap/react', () => ({
+ useCurrentEditor: () => ({ editor: mockEditor }),
+ useEditorState: ({
+ selector,
+ }: {
+ selector: (ctx: { editor: unknown }) => unknown;
+ }) => selector({ editor: mockEditor }),
+}));
+
+let capturedOnHide: (() => void) | undefined;
+let capturedShouldShow:
+ | ((ctx: {
+ editor: typeof mockEditor;
+ view: unknown;
+ state: unknown;
+ }) => boolean)
+ | undefined;
+
+vi.mock('@tiptap/react/menus', () => ({
+ BubbleMenu: ({
+ children,
+ className,
+ options,
+ shouldShow,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ options?: { placement?: string; offset?: number; onHide?: () => void };
+ shouldShow?: (ctx: {
+ editor: unknown;
+ view: unknown;
+ state: unknown;
+ }) => boolean;
+ }) => {
+ capturedOnHide = options?.onHide;
+ capturedShouldShow = shouldShow as typeof capturedShouldShow;
+ return (
+
+ {children}
+
+ );
+ },
+}));
+
+vi.mock('@radix-ui/react-popover', () => ({
+ Root: ({ children }: { children: React.ReactNode }) => {children},
+ Trigger: ({
+ children,
+ ...props
+ }: { children: React.ReactNode } & Record) => (
+
+ ),
+ Content: ({
+ children,
+ ...props
+ }: { children: React.ReactNode } & Record) => (
+ {children}
+ ),
+}));
+
+vi.mock('../../core/event-bus', () => ({
+ editorEventBus: {
+ on: () => ({ unsubscribe: vi.fn() }),
+ },
+}));
+
+vi.mock('../../utils/set-text-alignment', () => ({
+ setTextAlignment: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('BubbleMenuDefault', () => {
+ it('renders all sections by default', () => {
+ render( );
+
+ // Node selector
+ expect(screen.getByText('Text')).toBeDefined();
+
+ // Link selector
+ expect(screen.getByLabelText('Add link')).toBeDefined();
+
+ // Formatting items
+ expect(screen.getByLabelText('bold')).toBeDefined();
+ expect(screen.getByLabelText('italic')).toBeDefined();
+ expect(screen.getByLabelText('underline')).toBeDefined();
+ expect(screen.getByLabelText('strike')).toBeDefined();
+ expect(screen.getByLabelText('code')).toBeDefined();
+ expect(screen.getByLabelText('uppercase')).toBeDefined();
+
+ // Alignment items
+ expect(screen.getByLabelText('align-left')).toBeDefined();
+ expect(screen.getByLabelText('align-center')).toBeDefined();
+ expect(screen.getByLabelText('align-right')).toBeDefined();
+
+ // Two item groups
+ expect(screen.getAllByRole('group')).toHaveLength(2);
+ });
+
+ it('hides specific items via excludeItems', () => {
+ render( );
+
+ expect(screen.queryByLabelText('bold')).toBeNull();
+ expect(screen.queryByLabelText('italic')).toBeNull();
+
+ // Others still present
+ expect(screen.getByLabelText('underline')).toBeDefined();
+ expect(screen.getByLabelText('strike')).toBeDefined();
+ expect(screen.getByLabelText('code')).toBeDefined();
+ expect(screen.getByLabelText('uppercase')).toBeDefined();
+ });
+
+ it('hides node-selector when excluded', () => {
+ render( );
+
+ expect(screen.queryByText('Text')).toBeNull();
+ // Link selector still present
+ expect(screen.getByLabelText('Add link')).toBeDefined();
+ });
+
+ it('hides link-selector when excluded', () => {
+ render( );
+
+ expect(screen.queryByLabelText('Add link')).toBeNull();
+ });
+
+ it('omits entire formatting group when all 6 marks are excluded', () => {
+ render(
+ ,
+ );
+
+ // Only alignment group remains
+ expect(screen.getAllByRole('group')).toHaveLength(1);
+ });
+
+ it('omits entire alignment group when all 3 alignment items are excluded', () => {
+ render(
+ ,
+ );
+
+ // Only formatting group remains
+ expect(screen.getAllByRole('group')).toHaveLength(1);
+ });
+
+ it('forwards excludeNodes to Root so shouldShow rejects excluded nodes', () => {
+ render( );
+
+ expect(capturedShouldShow).toBeDefined();
+
+ // When an excluded node is active, shouldShow returns false
+ mockEditor.isActive.mockReturnValueOnce(true);
+ expect(
+ capturedShouldShow!({
+ editor: mockEditor,
+ view: mockEditor.view,
+ state: mockEditor.view.state,
+ }),
+ ).toBe(false);
+
+ // When no excluded node is active, shouldShow returns true
+ mockEditor.isActive.mockReturnValue(false);
+ expect(
+ capturedShouldShow!({
+ editor: mockEditor,
+ view: mockEditor.view,
+ state: mockEditor.view.state,
+ }),
+ ).toBe(true);
+ });
+
+ it('forwards placement and offset to Root', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.dataset.placement).toBe('top');
+ expect(root.dataset.offset).toBe('16');
+ });
+
+ it('applies className to the root element', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.className).toBe('custom-menu');
+ });
+
+ it('invokes onHide callback when bubble menu hides', () => {
+ const onHide = vi.fn();
+ render( );
+
+ expect(capturedOnHide).toBeDefined();
+ capturedOnHide!();
+ expect(onHide).toHaveBeenCalledOnce();
+ });
+
+ it('onHide resets both selector open states', () => {
+ render( );
+
+ // Open link selector
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(screen.getByPlaceholderText('Paste a link')).toBeDefined();
+
+ // Simulate bubble menu hiding (called outside React event system)
+ act(() => {
+ capturedOnHide!();
+ });
+
+ // Link form should be gone (isLinkSelectorOpen reset to false)
+ expect(screen.queryByPlaceholderText('Paste a link')).toBeNull();
+ });
+
+ it('opening node-selector closes link-selector', () => {
+ render( );
+
+ // Open link selector first
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(screen.getByPlaceholderText('Paste a link')).toBeDefined();
+
+ // Open node selector — should close link selector
+ // Trigger shows "Multiple" since mock editor has no active node type
+ const nodeTrigger = document.querySelector(
+ '[data-re-node-selector-trigger]',
+ )!;
+ fireEvent.click(nodeTrigger);
+
+ expect(screen.queryByPlaceholderText('Paste a link')).toBeNull();
+ });
+
+ it('opening link-selector closes node-selector', () => {
+ render( );
+
+ // Open node selector first via the trigger
+ const nodeTrigger = document.querySelector(
+ '[data-re-node-selector-trigger]',
+ )!;
+ fireEvent.click(nodeTrigger);
+
+ // Verify node selector is open via data attribute
+ const nodeSelector = document.querySelector('[data-re-node-selector]')!;
+ expect(nodeSelector.hasAttribute('data-open')).toBe(true);
+
+ // Open link selector — should close node selector
+ fireEvent.click(screen.getByLabelText('Add link'));
+
+ expect(nodeSelector.hasAttribute('data-open')).toBe(false);
+ });
+});
diff --git a/packages/editor/src/ui/bubble-menu/default.tsx b/packages/editor/src/ui/bubble-menu/default.tsx
new file mode 100644
index 0000000000..984580dec5
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/default.tsx
@@ -0,0 +1,132 @@
+import * as React from 'react';
+import { BubbleMenuAlignCenter } from './align-center';
+import { BubbleMenuAlignLeft } from './align-left';
+import { BubbleMenuAlignRight } from './align-right';
+import { BubbleMenuBold } from './bold';
+import { BubbleMenuCode } from './code';
+import { BubbleMenuItemGroup } from './group';
+import { BubbleMenuItalic } from './italic';
+import { BubbleMenuLinkSelector } from './link-selector';
+import { BubbleMenuNodeSelector } from './node-selector';
+import { BubbleMenuRoot } from './root';
+import { BubbleMenuStrike } from './strike';
+import { BubbleMenuUnderline } from './underline';
+import { BubbleMenuUppercase } from './uppercase';
+
+type ExcludableItem =
+ | 'bold'
+ | 'italic'
+ | 'underline'
+ | 'strike'
+ | 'code'
+ | 'uppercase'
+ | 'align-left'
+ | 'align-center'
+ | 'align-right'
+ | 'node-selector'
+ | 'link-selector';
+
+export interface BubbleMenuDefaultProps
+ extends Omit, 'children'> {
+ /** Items to exclude from the default layout */
+ excludeItems?: ExcludableItem[];
+ /** Node types that should NOT trigger the bubble menu (forwarded to Root) */
+ excludeNodes?: string[];
+ /** Mark types that should NOT trigger the bubble menu (forwarded to Root) */
+ excludeMarks?: string[];
+ /** Placement relative to selection (forwarded to Root, default: 'bottom') */
+ placement?: 'top' | 'bottom';
+ /** Offset from selection in px (forwarded to Root, default: 8) */
+ offset?: number;
+ /** Called when the bubble menu hides (forwarded to Root) */
+ onHide?: () => void;
+}
+
+export function BubbleMenuDefault({
+ excludeItems = [],
+ excludeNodes,
+ excludeMarks,
+ placement,
+ offset,
+ onHide,
+ className,
+ ...rest
+}: BubbleMenuDefaultProps) {
+ const [isNodeSelectorOpen, setIsNodeSelectorOpen] = React.useState(false);
+ const [isLinkSelectorOpen, setIsLinkSelectorOpen] = React.useState(false);
+
+ const has = (item: ExcludableItem) => !excludeItems.includes(item);
+
+ const handleNodeSelectorOpenChange = React.useCallback((open: boolean) => {
+ setIsNodeSelectorOpen(open);
+ if (open) {
+ setIsLinkSelectorOpen(false);
+ }
+ }, []);
+
+ const handleLinkSelectorOpenChange = React.useCallback((open: boolean) => {
+ setIsLinkSelectorOpen(open);
+ if (open) {
+ setIsNodeSelectorOpen(false);
+ }
+ }, []);
+
+ const handleHide = React.useCallback(() => {
+ setIsNodeSelectorOpen(false);
+ setIsLinkSelectorOpen(false);
+ onHide?.();
+ }, [onHide]);
+
+ const hasFormattingItems =
+ has('bold') ||
+ has('italic') ||
+ has('underline') ||
+ has('strike') ||
+ has('code') ||
+ has('uppercase');
+
+ const hasAlignmentItems =
+ has('align-left') || has('align-center') || has('align-right');
+
+ return (
+
+ {has('node-selector') && (
+
+ )}
+ {has('link-selector') && (
+
+ )}
+ {hasFormattingItems && (
+
+ {has('bold') && }
+ {has('italic') && }
+ {has('underline') && }
+ {has('strike') && }
+ {has('code') && }
+ {has('uppercase') && }
+
+ )}
+ {hasAlignmentItems && (
+
+ {has('align-left') && }
+ {has('align-center') && }
+ {has('align-right') && }
+
+ )}
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/group.spec.tsx b/packages/editor/src/ui/bubble-menu/group.spec.tsx
new file mode 100644
index 0000000000..c6f9295a37
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/group.spec.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react';
+import { BubbleMenuItemGroup } from './group';
+import { BubbleMenuSeparator } from './separator';
+
+describe('BubbleMenuItemGroup', () => {
+ it('renders children with correct data attribute and role', () => {
+ render(
+
+
+ ,
+ );
+ const group = screen.getByRole('group');
+ expect(group).toBeDefined();
+ expect(group.dataset.reBubbleMenuGroup).toBeDefined();
+ expect(group.textContent).toBe('Bold');
+ });
+
+ it('applies className', () => {
+ render(
+
+
+ ,
+ );
+ const group = screen.getByRole('group');
+ expect(group.className).toBe('custom-class');
+ });
+});
+
+describe('BubbleMenuSeparator', () => {
+ it('renders a separator with correct data attribute', () => {
+ render( );
+ const separator = screen.getByRole('separator');
+ expect(separator).toBeDefined();
+ expect(separator.dataset.reBubbleMenuSeparator).toBeDefined();
+ });
+
+ it('applies className', () => {
+ render( );
+ const separator = screen.getByRole('separator');
+ expect(separator.className).toBe('divider');
+ });
+});
diff --git a/packages/editor/src/ui/bubble-menu/group.tsx b/packages/editor/src/ui/bubble-menu/group.tsx
new file mode 100644
index 0000000000..51c61920b1
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/group.tsx
@@ -0,0 +1,17 @@
+import type * as React from 'react';
+
+export interface BubbleMenuItemGroupProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function BubbleMenuItemGroup({
+ className,
+ children,
+}: BubbleMenuItemGroupProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/index.ts b/packages/editor/src/ui/bubble-menu/index.ts
new file mode 100644
index 0000000000..9792069ef8
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/index.ts
@@ -0,0 +1,82 @@
+import { BubbleMenuAlignCenter } from './align-center';
+import { BubbleMenuAlignLeft } from './align-left';
+import { BubbleMenuAlignRight } from './align-right';
+import { BubbleMenuBold } from './bold';
+import { BubbleMenuCode } from './code';
+import { BubbleMenuDefault } from './default';
+import { BubbleMenuItemGroup } from './group';
+import { BubbleMenuItalic } from './italic';
+import { BubbleMenuItem } from './item';
+import { BubbleMenuLinkSelector } from './link-selector';
+import {
+ BubbleMenuNodeSelector,
+ NodeSelectorContent,
+ NodeSelectorRoot,
+ NodeSelectorTrigger,
+} from './node-selector';
+import { BubbleMenuRoot } from './root';
+import { BubbleMenuSeparator } from './separator';
+import { BubbleMenuStrike } from './strike';
+import { BubbleMenuUnderline } from './underline';
+import { BubbleMenuUppercase } from './uppercase';
+
+export { BubbleMenuAlignCenter } from './align-center';
+export { BubbleMenuAlignLeft } from './align-left';
+export { BubbleMenuAlignRight } from './align-right';
+export { BubbleMenuBold } from './bold';
+export { BubbleMenuCode } from './code';
+export type { PreWiredItemProps } from './create-mark-bubble-item';
+export type { BubbleMenuDefaultProps } from './default';
+export { BubbleMenuDefault } from './default';
+export type { BubbleMenuItemGroupProps } from './group';
+export { BubbleMenuItemGroup } from './group';
+export { BubbleMenuItalic } from './italic';
+export type { BubbleMenuItemProps } from './item';
+export { BubbleMenuItem } from './item';
+export type { BubbleMenuLinkSelectorProps } from './link-selector';
+export { BubbleMenuLinkSelector } from './link-selector';
+export type {
+ BubbleMenuNodeSelectorProps,
+ NodeSelectorContentProps,
+ NodeSelectorItem,
+ NodeSelectorRootProps,
+ NodeSelectorTriggerProps,
+ NodeType,
+} from './node-selector';
+export {
+ BubbleMenuNodeSelector,
+ NodeSelectorContent,
+ NodeSelectorRoot,
+ NodeSelectorTrigger,
+} from './node-selector';
+export type { BubbleMenuRootProps } from './root';
+export { BubbleMenuRoot } from './root';
+export type { BubbleMenuSeparatorProps } from './separator';
+export { BubbleMenuSeparator } from './separator';
+export { BubbleMenuStrike } from './strike';
+export { BubbleMenuUnderline } from './underline';
+export { BubbleMenuUppercase } from './uppercase';
+
+// Compound component namespace for convenient `BubbleMenu.Root` usage
+export const BubbleMenu = {
+ Root: BubbleMenuRoot,
+ ItemGroup: BubbleMenuItemGroup,
+ Separator: BubbleMenuSeparator,
+ Item: BubbleMenuItem,
+ Bold: BubbleMenuBold,
+ Italic: BubbleMenuItalic,
+ Underline: BubbleMenuUnderline,
+ Strike: BubbleMenuStrike,
+ Code: BubbleMenuCode,
+ Uppercase: BubbleMenuUppercase,
+ AlignLeft: BubbleMenuAlignLeft,
+ AlignCenter: BubbleMenuAlignCenter,
+ AlignRight: BubbleMenuAlignRight,
+ NodeSelector: Object.assign(BubbleMenuNodeSelector, {
+ Root: NodeSelectorRoot,
+ Trigger: NodeSelectorTrigger,
+ Content: NodeSelectorContent,
+ }),
+ LinkSelector: BubbleMenuLinkSelector,
+ Default: BubbleMenuDefault,
+} as const;
diff --git a/packages/editor/src/ui/bubble-menu/italic.tsx b/packages/editor/src/ui/bubble-menu/italic.tsx
new file mode 100644
index 0000000000..a9da36223c
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/italic.tsx
@@ -0,0 +1,9 @@
+import { ItalicIcon } from '../icons';
+import { createMarkBubbleItem } from './create-mark-bubble-item';
+
+export const BubbleMenuItalic = createMarkBubbleItem({
+ name: 'italic',
+ activeName: 'italic',
+ command: 'toggleItalic',
+ icon: ,
+});
diff --git a/packages/editor/src/ui/bubble-menu/item.spec.tsx b/packages/editor/src/ui/bubble-menu/item.spec.tsx
new file mode 100644
index 0000000000..9eeaeed1fa
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/item.spec.tsx
@@ -0,0 +1,79 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { BubbleMenuItem } from './item';
+
+describe('BubbleMenuItem', () => {
+ it('renders a button with correct aria attributes when inactive', () => {
+ const onCommand = vi.fn();
+ render(
+
+ B
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'bold' });
+ expect(button).toBeDefined();
+ expect(button.getAttribute('aria-pressed')).toBe('false');
+ expect(button.dataset.reBubbleMenuItem).toBeDefined();
+ expect(button.dataset.item).toBe('bold');
+ expect(button.dataset.active).toBeUndefined();
+ });
+
+ it('sets data-active and aria-pressed when active', () => {
+ render(
+ {}}>
+ B
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'bold' });
+ expect(button.getAttribute('aria-pressed')).toBe('true');
+ expect(button.dataset.active).toBeDefined();
+ });
+
+ it('calls onCommand on click', () => {
+ const onCommand = vi.fn();
+ render(
+
+ B
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'bold' }));
+ expect(onCommand).toHaveBeenCalledOnce();
+ });
+
+ it('applies className', () => {
+ render(
+ {}}
+ className="custom"
+ >
+ B
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'bold' }).className).toBe(
+ 'custom',
+ );
+ });
+
+ it('spreads additional button props', () => {
+ render(
+ {}}
+ data-testid="custom-button"
+ disabled
+ >
+ B
+ ,
+ );
+
+ const button = screen.getByTestId('custom-button');
+ expect(button).toBeDefined();
+ expect(button.getAttribute('disabled')).toBe('');
+ });
+});
diff --git a/packages/editor/src/ui/bubble-menu/item.tsx b/packages/editor/src/ui/bubble-menu/item.tsx
new file mode 100644
index 0000000000..293f71cae9
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/item.tsx
@@ -0,0 +1,36 @@
+import type * as React from 'react';
+
+export interface BubbleMenuItemProps extends React.ComponentProps<'button'> {
+ /** Used for aria-label and data-item attribute */
+ name: string;
+ /** Whether this item is currently active */
+ isActive: boolean;
+ /** Called when clicked */
+ onCommand: () => void;
+}
+
+export function BubbleMenuItem({
+ name,
+ isActive,
+ onCommand,
+ className,
+ children,
+ ...rest
+}: BubbleMenuItemProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/link-selector.spec.tsx b/packages/editor/src/ui/bubble-menu/link-selector.spec.tsx
new file mode 100644
index 0000000000..e34e5040cc
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/link-selector.spec.tsx
@@ -0,0 +1,103 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { BubbleMenuLinkSelector } from './link-selector';
+
+const mockEditor = {
+ isActive: vi.fn().mockReturnValue(false),
+ getAttributes: vi.fn().mockReturnValue({}),
+ chain: vi.fn().mockReturnValue({
+ focus: vi.fn().mockReturnThis(),
+ unsetLink: vi.fn().mockReturnThis(),
+ setLink: vi.fn().mockReturnThis(),
+ extendMarkRange: vi.fn().mockReturnThis(),
+ setTextSelection: vi.fn().mockReturnThis(),
+ run: vi.fn(),
+ }),
+ state: { selection: { from: 0, to: 5 } },
+ commands: { focus: vi.fn() },
+};
+
+vi.mock('@tiptap/react', () => ({
+ useEditorState: ({
+ selector,
+ }: {
+ selector: (ctx: { editor: unknown }) => unknown;
+ }) => selector({ editor: mockEditor }),
+}));
+
+vi.mock('./context', () => ({
+ useBubbleMenuContext: () => ({ editor: mockEditor }),
+}));
+
+vi.mock('../../core/event-bus', () => ({
+ editorEventBus: {
+ on: () => ({ unsubscribe: vi.fn() }),
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('BubbleMenuLinkSelector', () => {
+ describe('uncontrolled mode (default)', () => {
+ it('toggles open state on trigger click', () => {
+ render( );
+
+ expect(screen.queryByPlaceholderText('Paste a link')).toBeNull();
+
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(screen.getByPlaceholderText('Paste a link')).toBeDefined();
+
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(screen.queryByPlaceholderText('Paste a link')).toBeNull();
+ });
+ });
+
+ describe('controlled mode', () => {
+ it('renders open when open=true', () => {
+ render( {}} />);
+
+ expect(screen.getByPlaceholderText('Paste a link')).toBeDefined();
+ });
+
+ it('renders closed when open=false', () => {
+ render( {}} />);
+
+ expect(screen.queryByPlaceholderText('Paste a link')).toBeNull();
+ });
+
+ it('calls onOpenChange when trigger is clicked', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it('calls onOpenChange with false when toggling off', () => {
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not update internal state in controlled mode', () => {
+ const { rerender } = render(
+ {}} />,
+ );
+
+ // Click trigger — in controlled mode, open stays false unless parent updates
+ fireEvent.click(screen.getByLabelText('Add link'));
+ expect(screen.queryByPlaceholderText('Paste a link')).toBeNull();
+
+ // Parent updates to open
+ rerender( {}} />);
+ expect(screen.getByPlaceholderText('Paste a link')).toBeDefined();
+ });
+ });
+});
diff --git a/packages/editor/src/ui/bubble-menu/link-selector.tsx b/packages/editor/src/ui/bubble-menu/link-selector.tsx
new file mode 100644
index 0000000000..b9d05c684a
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/link-selector.tsx
@@ -0,0 +1,261 @@
+import type { Editor } from '@tiptap/core';
+import { useEditorState } from '@tiptap/react';
+import * as React from 'react';
+import { editorEventBus } from '../../core/event-bus';
+import { Check, LinkIcon, UnlinkIcon } from '../icons';
+import { useBubbleMenuContext } from './context';
+import { focusEditor, getUrlFromString, setLinkHref } from './utils';
+
+export interface BubbleMenuLinkSelectorProps {
+ className?: string;
+ /** Whether to show the link icon toggle button (default: true) */
+ showToggle?: boolean;
+ /** Custom URL validator. Return the valid URL string or null. */
+ validateUrl?: (value: string) => string | null;
+ /** Called after link is applied */
+ onLinkApply?: (href: string) => void;
+ /** Called after link is removed */
+ onLinkRemove?: () => void;
+ /** Plugin slot: extra actions rendered inside the link input form */
+ children?: React.ReactNode;
+ /** Controlled open state */
+ open?: boolean;
+ /** Called when open state changes */
+ onOpenChange?: (open: boolean) => void;
+}
+
+export function BubbleMenuLinkSelector({
+ className,
+ showToggle = true,
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+ children,
+ open: controlledOpen,
+ onOpenChange,
+}: BubbleMenuLinkSelectorProps) {
+ const { editor } = useBubbleMenuContext();
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
+
+ const isControlled = controlledOpen !== undefined;
+ const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
+ const setIsOpen = React.useCallback(
+ (value: boolean) => {
+ if (!isControlled) {
+ setUncontrolledOpen(value);
+ }
+ onOpenChange?.(value);
+ },
+ [isControlled, onOpenChange],
+ );
+
+ const editorState = useEditorState({
+ editor,
+ selector: ({ editor }) => ({
+ isLinkActive: editor?.isActive('link') ?? false,
+ hasLink: Boolean(editor?.getAttributes('link').href),
+ currentHref: (editor?.getAttributes('link').href as string) || '',
+ }),
+ });
+
+ const setIsOpenRef = React.useRef(setIsOpen);
+ setIsOpenRef.current = setIsOpen;
+
+ React.useEffect(() => {
+ const subscription = editorEventBus.on('bubble-menu:add-link', () => {
+ setIsOpenRef.current(true);
+ });
+
+ return () => {
+ setIsOpenRef.current(false);
+ subscription.unsubscribe();
+ };
+ }, []);
+
+ if (!editorState) {
+ return null;
+ }
+
+ const handleOpenLink = () => {
+ setIsOpen(!isOpen);
+ };
+
+ return (
+
+ {showToggle && (
+
+ )}
+ {isOpen && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+interface LinkFormProps {
+ editor: Editor;
+ currentHref: string;
+ validateUrl?: (value: string) => string | null;
+ onLinkApply?: (href: string) => void;
+ onLinkRemove?: () => void;
+ setIsOpen: (state: boolean) => void;
+ children?: React.ReactNode;
+}
+
+function LinkForm({
+ editor,
+ currentHref,
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+ setIsOpen,
+ children,
+}: LinkFormProps) {
+ const inputRef = React.useRef(null);
+ const formRef = React.useRef(null);
+ const displayHref = currentHref === '#' ? '' : currentHref;
+ const [inputValue, setInputValue] = React.useState(displayHref);
+
+ React.useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 0);
+ return () => clearTimeout(timeoutId);
+ }, []);
+
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ if (editor.getAttributes('link').href === '#') {
+ editor.chain().unsetLink().run();
+ }
+ setIsOpen(false);
+ }
+ };
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (formRef.current && !formRef.current.contains(event.target as Node)) {
+ const form = formRef.current;
+ const submitEvent = new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ });
+ form.dispatchEvent(submitEvent);
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [editor, setIsOpen]);
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+
+ const value = inputValue.trim();
+
+ if (value === '') {
+ setLinkHref(editor, '');
+ setIsOpen(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ return;
+ }
+
+ const validate = validateUrl ?? getUrlFromString;
+ const finalValue = validate(value);
+
+ if (!finalValue) {
+ setLinkHref(editor, '');
+ setIsOpen(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ return;
+ }
+
+ setLinkHref(editor, finalValue);
+ setIsOpen(false);
+ focusEditor(editor);
+ onLinkApply?.(finalValue);
+ }
+
+ function handleUnlink(e: React.MouseEvent) {
+ e.stopPropagation();
+ setLinkHref(editor, '');
+ setIsOpen(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/node-selector.tsx b/packages/editor/src/ui/bubble-menu/node-selector.tsx
new file mode 100644
index 0000000000..1dad8970fb
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/node-selector.tsx
@@ -0,0 +1,319 @@
+import * as Popover from '@radix-ui/react-popover';
+import { useEditorState } from '@tiptap/react';
+import * as React from 'react';
+import {
+ Check,
+ ChevronDown,
+ Code,
+ Heading1,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ TextIcon,
+ TextQuote,
+} from '../icons';
+import { useBubbleMenuContext } from './context';
+
+export type NodeType =
+ | 'Text'
+ | 'Title'
+ | 'Subtitle'
+ | 'Heading'
+ | 'Bullet List'
+ | 'Numbered List'
+ | 'Quote'
+ | 'Code';
+
+export interface NodeSelectorItem {
+ name: NodeType;
+ icon: React.ComponentType>;
+ command: () => void;
+ isActive: boolean;
+}
+
+interface NodeSelectorContextValue {
+ items: NodeSelectorItem[];
+ activeItem: NodeSelectorItem | { name: 'Multiple' };
+ isOpen: boolean;
+ setIsOpen: (value: boolean) => void;
+}
+
+const NodeSelectorContext =
+ React.createContext(null);
+
+function useNodeSelectorContext(): NodeSelectorContextValue {
+ const context = React.useContext(NodeSelectorContext);
+ if (!context) {
+ throw new Error(
+ 'NodeSelector compound components must be used within ',
+ );
+ }
+ return context;
+}
+
+export interface NodeSelectorRootProps {
+ /** Block types to exclude */
+ omit?: string[];
+ /** Controlled open state */
+ open?: boolean;
+ /** Called when open state changes */
+ onOpenChange?: (open: boolean) => void;
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function NodeSelectorRoot({
+ omit = [],
+ open: controlledOpen,
+ onOpenChange,
+ className,
+ children,
+}: NodeSelectorRootProps) {
+ const { editor } = useBubbleMenuContext();
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
+
+ const isControlled = controlledOpen !== undefined;
+ const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
+ const setIsOpen = React.useCallback(
+ (value: boolean) => {
+ if (!isControlled) {
+ setUncontrolledOpen(value);
+ }
+ onOpenChange?.(value);
+ },
+ [isControlled, onOpenChange],
+ );
+
+ const editorState = useEditorState({
+ editor,
+ selector: ({ editor }) => ({
+ isParagraphActive:
+ (editor?.isActive('paragraph') ?? false) &&
+ !editor?.isActive('bulletList') &&
+ !editor?.isActive('orderedList'),
+ isHeading1Active: editor?.isActive('heading', { level: 1 }) ?? false,
+ isHeading2Active: editor?.isActive('heading', { level: 2 }) ?? false,
+ isHeading3Active: editor?.isActive('heading', { level: 3 }) ?? false,
+ isBulletListActive: editor?.isActive('bulletList') ?? false,
+ isOrderedListActive: editor?.isActive('orderedList') ?? false,
+ isBlockquoteActive: editor?.isActive('blockquote') ?? false,
+ isCodeBlockActive: editor?.isActive('codeBlock') ?? false,
+ }),
+ });
+
+ const allItems: NodeSelectorItem[] = React.useMemo(
+ () => [
+ {
+ name: 'Text' as const,
+ icon: TextIcon,
+ command: () =>
+ editor
+ .chain()
+ .focus()
+ .clearNodes()
+ .toggleNode('paragraph', 'paragraph')
+ .run(),
+ isActive: editorState?.isParagraphActive ?? false,
+ },
+ {
+ name: 'Title' as const,
+ icon: Heading1,
+ command: () =>
+ editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(),
+ isActive: editorState?.isHeading1Active ?? false,
+ },
+ {
+ name: 'Subtitle' as const,
+ icon: Heading2,
+ command: () =>
+ editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
+ isActive: editorState?.isHeading2Active ?? false,
+ },
+ {
+ name: 'Heading' as const,
+ icon: Heading3,
+ command: () =>
+ editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
+ isActive: editorState?.isHeading3Active ?? false,
+ },
+ {
+ name: 'Bullet List' as const,
+ icon: List,
+ command: () =>
+ editor.chain().focus().clearNodes().toggleBulletList().run(),
+ isActive: editorState?.isBulletListActive ?? false,
+ },
+ {
+ name: 'Numbered List' as const,
+ icon: ListOrdered,
+ command: () =>
+ editor.chain().focus().clearNodes().toggleOrderedList().run(),
+ isActive: editorState?.isOrderedListActive ?? false,
+ },
+ {
+ name: 'Quote' as const,
+ icon: TextQuote,
+ command: () =>
+ editor
+ .chain()
+ .focus()
+ .clearNodes()
+ .toggleNode('paragraph', 'paragraph')
+ .toggleBlockquote()
+ .run(),
+ isActive: editorState?.isBlockquoteActive ?? false,
+ },
+ {
+ name: 'Code' as const,
+ icon: Code,
+ command: () =>
+ editor.chain().focus().clearNodes().toggleCodeBlock().run(),
+ isActive: editorState?.isCodeBlockActive ?? false,
+ },
+ ],
+ [editor, editorState],
+ );
+
+ const items = React.useMemo(
+ () => allItems.filter((item) => !omit.includes(item.name)),
+ [allItems, omit],
+ );
+
+ const activeItem = React.useMemo(
+ () =>
+ items.find((item) => item.isActive) ?? {
+ name: 'Multiple' as const,
+ },
+ [items],
+ );
+
+ const contextValue = React.useMemo(
+ () => ({ items, activeItem, isOpen, setIsOpen }),
+ [items, activeItem, isOpen, setIsOpen],
+ );
+
+ if (!editorState || items.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export interface NodeSelectorTriggerProps {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export function NodeSelectorTrigger({
+ className,
+ children,
+}: NodeSelectorTriggerProps) {
+ const { activeItem, isOpen, setIsOpen } = useNodeSelectorContext();
+
+ return (
+ setIsOpen(!isOpen)}
+ >
+ {children ?? (
+ <>
+ {activeItem.name}
+
+ >
+ )}
+
+ );
+}
+
+export interface NodeSelectorContentProps {
+ className?: string;
+ /** Popover alignment (default: "start") */
+ align?: 'start' | 'center' | 'end';
+ /** Render-prop for full control over item rendering.
+ * Receives the filtered items and a `close` function to dismiss the popover. */
+ children?: (items: NodeSelectorItem[], close: () => void) => React.ReactNode;
+}
+
+export function NodeSelectorContent({
+ className,
+ align = 'start',
+ children,
+}: NodeSelectorContentProps) {
+ const { items, setIsOpen } = useNodeSelectorContext();
+
+ return (
+
+ {children
+ ? children(items, () => setIsOpen(false))
+ : items.map((item) => {
+ const Icon = item.icon;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export interface BubbleMenuNodeSelectorProps {
+ /** Block types to exclude */
+ omit?: string[];
+ className?: string;
+ /** Override the trigger content (default: active item name + chevron icon) */
+ triggerContent?: React.ReactNode;
+ /** Controlled open state */
+ open?: boolean;
+ /** Called when open state changes */
+ onOpenChange?: (open: boolean) => void;
+}
+
+export function BubbleMenuNodeSelector({
+ omit = [],
+ className,
+ triggerContent,
+ open,
+ onOpenChange,
+}: BubbleMenuNodeSelectorProps) {
+ return (
+
+ {triggerContent}
+
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/root.spec.tsx b/packages/editor/src/ui/bubble-menu/root.spec.tsx
new file mode 100644
index 0000000000..f96b1763c7
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/root.spec.tsx
@@ -0,0 +1,13 @@
+import { render } from '@testing-library/react';
+import { BubbleMenuRoot } from './root';
+
+describe('BubbleMenuRoot', () => {
+ it('renders null when no editor context is available', () => {
+ const { container } = render(
+
+ child
+ ,
+ );
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/packages/editor/src/ui/bubble-menu/root.tsx b/packages/editor/src/ui/bubble-menu/root.tsx
new file mode 100644
index 0000000000..bcec09c149
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/root.tsx
@@ -0,0 +1,77 @@
+import { useCurrentEditor } from '@tiptap/react';
+import { BubbleMenu } from '@tiptap/react/menus';
+import type * as React from 'react';
+import { BubbleMenuContext } from './context';
+
+export interface BubbleMenuRootProps
+ extends Omit, 'children'> {
+ /** Node types that should NOT trigger the bubble menu */
+ excludeNodes?: string[];
+ /** Mark types that should NOT trigger the bubble menu */
+ excludeMarks?: string[];
+ /** Placement relative to selection */
+ placement?: 'top' | 'bottom';
+ /** Offset from selection in px */
+ offset?: number;
+ /** Called when the bubble menu is hidden (e.g., click outside, selection cleared) */
+ onHide?: () => void;
+ children: React.ReactNode;
+}
+
+export function BubbleMenuRoot({
+ excludeNodes = [],
+ excludeMarks = [],
+ placement = 'bottom',
+ offset = 8,
+ onHide,
+ className,
+ children,
+ ...rest
+}: BubbleMenuRootProps) {
+ const { editor } = useCurrentEditor();
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+ {
+ for (const node of excludeNodes) {
+ if (editor.isActive(node)) {
+ return false;
+ }
+ const { $from } = state.selection;
+ for (let d = $from.depth; d > 0; d--) {
+ if ($from.node(d).type.name === node) {
+ return false;
+ }
+ }
+ }
+ for (const mark of excludeMarks) {
+ if (editor.isActive(mark)) {
+ return false;
+ }
+ }
+ if (view.dom.classList.contains('dragging')) {
+ return false;
+ }
+ return editor.view.state.selection.content().size > 0;
+ }}
+ options={{
+ placement,
+ offset,
+ onHide,
+ }}
+ className={className}
+ {...rest}
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/packages/editor/src/ui/bubble-menu/separator.tsx b/packages/editor/src/ui/bubble-menu/separator.tsx
new file mode 100644
index 0000000000..8cf2d711f7
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/separator.tsx
@@ -0,0 +1,7 @@
+export interface BubbleMenuSeparatorProps {
+ className?: string;
+}
+
+export function BubbleMenuSeparator({ className }: BubbleMenuSeparatorProps) {
+ return
;
+}
diff --git a/packages/editor/src/ui/bubble-menu/strike.tsx b/packages/editor/src/ui/bubble-menu/strike.tsx
new file mode 100644
index 0000000000..cafda5b972
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/strike.tsx
@@ -0,0 +1,9 @@
+import { StrikethroughIcon } from '../icons';
+import { createMarkBubbleItem } from './create-mark-bubble-item';
+
+export const BubbleMenuStrike = createMarkBubbleItem({
+ name: 'strike',
+ activeName: 'strike',
+ command: 'toggleStrike',
+ icon: ,
+});
diff --git a/packages/editor/src/ui/bubble-menu/underline.tsx b/packages/editor/src/ui/bubble-menu/underline.tsx
new file mode 100644
index 0000000000..47d741ad16
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/underline.tsx
@@ -0,0 +1,9 @@
+import { UnderlineIcon } from '../icons';
+import { createMarkBubbleItem } from './create-mark-bubble-item';
+
+export const BubbleMenuUnderline = createMarkBubbleItem({
+ name: 'underline',
+ activeName: 'underline',
+ command: 'toggleUnderline',
+ icon: ,
+});
diff --git a/packages/editor/src/ui/bubble-menu/uppercase.tsx b/packages/editor/src/ui/bubble-menu/uppercase.tsx
new file mode 100644
index 0000000000..85feecc4b1
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/uppercase.tsx
@@ -0,0 +1,9 @@
+import { CaseUpperIcon } from '../icons';
+import { createMarkBubbleItem } from './create-mark-bubble-item';
+
+export const BubbleMenuUppercase = createMarkBubbleItem({
+ name: 'uppercase',
+ activeName: 'uppercase',
+ command: 'toggleUppercase',
+ icon: ,
+});
diff --git a/packages/editor/src/ui/bubble-menu/utils.spec.ts b/packages/editor/src/ui/bubble-menu/utils.spec.ts
new file mode 100644
index 0000000000..bc1728fca2
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/utils.spec.ts
@@ -0,0 +1,132 @@
+import { focusEditor, getUrlFromString, setLinkHref } from './utils';
+
+describe('getUrlFromString', () => {
+ it('returns hash as-is', () => {
+ expect(getUrlFromString('#')).toBe('#');
+ });
+
+ it('returns valid https URLs as-is', () => {
+ expect(getUrlFromString('https://example.com')).toBe('https://example.com');
+ });
+
+ it('returns valid http URLs as-is', () => {
+ expect(getUrlFromString('http://example.com')).toBe('http://example.com');
+ });
+
+ it('returns mailto URLs as-is', () => {
+ expect(getUrlFromString('mailto:user@example.com')).toBe(
+ 'mailto:user@example.com',
+ );
+ });
+
+ it('returns tel URLs as-is', () => {
+ expect(getUrlFromString('tel:+1234567890')).toBe('tel:+1234567890');
+ });
+
+ it('rejects javascript: URLs', () => {
+ expect(getUrlFromString('javascript:alert(1)')).toBeNull();
+ });
+
+ it('rejects data: URLs', () => {
+ expect(
+ getUrlFromString('data:text/html,'),
+ ).toBeNull();
+ });
+
+ it('rejects vbscript: URLs', () => {
+ expect(getUrlFromString('vbscript:msgbox')).toBeNull();
+ });
+
+ it('auto-prefixes URLs with dots', () => {
+ const result = getUrlFromString('example.com');
+ expect(result).toBe('https://example.com/');
+ });
+
+ it('returns null for invalid strings', () => {
+ expect(getUrlFromString('not a url')).toBeNull();
+ });
+
+ it('returns null for strings without dots', () => {
+ expect(getUrlFromString('justtext')).toBeNull();
+ });
+
+ it('returns null for empty string', () => {
+ expect(getUrlFromString('')).toBeNull();
+ });
+});
+
+describe('setLinkHref', () => {
+ function createMockEditor({
+ from = 0,
+ to = 0,
+ }: {
+ from?: number;
+ to?: number;
+ } = {}) {
+ const run = vi.fn();
+ const setTextSelection = vi.fn(() => ({ run }));
+ const setLink = vi.fn(() => ({ run, setTextSelection }));
+ const extendMarkRange = vi.fn(() => ({ setLink }));
+ const unsetLink = vi.fn(() => ({ run }));
+ const chain = vi.fn(() => ({
+ unsetLink,
+ extendMarkRange,
+ setLink,
+ }));
+
+ return {
+ editor: { chain, state: { selection: { from, to } } } as any,
+ mocks: {
+ chain,
+ unsetLink,
+ extendMarkRange,
+ setLink,
+ setTextSelection,
+ run,
+ },
+ };
+ }
+
+ it('unsets link when href is empty', () => {
+ const { editor, mocks } = createMockEditor();
+ setLinkHref(editor, '');
+ expect(mocks.chain).toHaveBeenCalled();
+ expect(mocks.unsetLink).toHaveBeenCalled();
+ expect(mocks.run).toHaveBeenCalled();
+ });
+
+ it('does not call setLink when href is empty', () => {
+ const { editor, mocks } = createMockEditor();
+ setLinkHref(editor, '');
+ expect(mocks.setLink).not.toHaveBeenCalled();
+ });
+
+ it('uses extendMarkRange for collapsed selection', () => {
+ const { editor, mocks } = createMockEditor({ from: 5, to: 5 });
+ setLinkHref(editor, 'https://example.com');
+ expect(mocks.extendMarkRange).toHaveBeenCalledWith('link');
+ expect(mocks.setLink).toHaveBeenCalledWith({ href: 'https://example.com' });
+ expect(mocks.setTextSelection).toHaveBeenCalledWith({ from: 5, to: 5 });
+ expect(mocks.run).toHaveBeenCalled();
+ });
+
+ it('uses setLink directly for range selection', () => {
+ const { editor, mocks } = createMockEditor({ from: 2, to: 10 });
+ setLinkHref(editor, 'https://example.com');
+ expect(mocks.setLink).toHaveBeenCalledWith({ href: 'https://example.com' });
+ expect(mocks.run).toHaveBeenCalled();
+ expect(mocks.extendMarkRange).not.toHaveBeenCalled();
+ });
+});
+
+describe('focusEditor', () => {
+ it('calls editor.commands.focus() via setTimeout', () => {
+ vi.useFakeTimers();
+ const editor = { commands: { focus: vi.fn() } } as any;
+ focusEditor(editor);
+ expect(editor.commands.focus).not.toHaveBeenCalled();
+ vi.runAllTimers();
+ expect(editor.commands.focus).toHaveBeenCalledOnce();
+ vi.useRealTimers();
+ });
+});
diff --git a/packages/editor/src/ui/bubble-menu/utils.ts b/packages/editor/src/ui/bubble-menu/utils.ts
new file mode 100644
index 0000000000..3b1fb6c653
--- /dev/null
+++ b/packages/editor/src/ui/bubble-menu/utils.ts
@@ -0,0 +1,60 @@
+import type { Editor } from '@tiptap/core';
+
+const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
+
+/**
+ * Basic URL validation and auto-prefixing.
+ * Rejects dangerous schemes (javascript:, data:, vbscript:, etc.).
+ * Returns the valid URL string or null.
+ */
+export function getUrlFromString(str: string): string | null {
+ if (str === '#') {
+ return str;
+ }
+
+ try {
+ const url = new URL(str);
+ if (SAFE_PROTOCOLS.has(url.protocol)) {
+ return str;
+ }
+ return null;
+ } catch {
+ // not a valid URL as-is
+ }
+
+ try {
+ if (str.includes('.') && !str.includes(' ')) {
+ return new URL(`https://${str}`).toString();
+ }
+ } catch {
+ // still not valid
+ }
+
+ return null;
+}
+
+export function setLinkHref(editor: Editor, href: string): void {
+ if (href.length === 0) {
+ editor.chain().unsetLink().run();
+ return;
+ }
+
+ const { from, to } = editor.state.selection;
+ if (from === to) {
+ editor
+ .chain()
+ .extendMarkRange('link')
+ .setLink({ href })
+ .setTextSelection({ from, to })
+ .run();
+ return;
+ }
+
+ editor.chain().setLink({ href }).run();
+}
+
+export function focusEditor(editor: Editor): void {
+ setTimeout(() => {
+ editor.commands.focus();
+ }, 0);
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/button-bubble-menu.css b/packages/editor/src/ui/button-bubble-menu/button-bubble-menu.css
new file mode 100644
index 0000000000..65247d0b1d
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/button-bubble-menu.css
@@ -0,0 +1,63 @@
+/* Minimal functional styles for ButtonBubbleMenu compound components.
+ * Layout and positioning only - no visual design.
+ * Import optionally: import '@react-email/editor/styles/button-bubble-menu.css';
+ */
+
+[data-re-btn-bm] {
+ display: flex;
+ align-items: center;
+}
+
+[data-re-btn-bm-toolbar] {
+ display: flex;
+ align-items: center;
+}
+
+[data-re-btn-bm-item] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.375rem;
+}
+
+[data-re-btn-bm-item] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-re-btn-bm-form] {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ min-width: 16rem;
+ padding: 0.25rem;
+}
+
+[data-re-btn-bm-input] {
+ flex: 1;
+ border: none;
+ outline: none;
+ font-size: 0.8125rem;
+ padding: 0.25rem;
+ background: transparent;
+}
+
+[data-re-btn-bm-apply],
+[data-re-btn-bm-unlink] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.25rem;
+}
+
+[data-re-btn-bm-apply] svg,
+[data-re-btn-bm-unlink] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/context.spec.tsx b/packages/editor/src/ui/button-bubble-menu/context.spec.tsx
new file mode 100644
index 0000000000..753d4f516e
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/context.spec.tsx
@@ -0,0 +1,12 @@
+import { renderHook } from '@testing-library/react';
+import { useButtonBubbleMenuContext } from './context';
+
+describe('useButtonBubbleMenuContext', () => {
+ it('throws when used outside a Provider', () => {
+ expect(() => {
+ renderHook(() => useButtonBubbleMenuContext());
+ }).toThrow(
+ 'ButtonBubbleMenu compound components must be used within ',
+ );
+ });
+});
diff --git a/packages/editor/src/ui/button-bubble-menu/context.tsx b/packages/editor/src/ui/button-bubble-menu/context.tsx
new file mode 100644
index 0000000000..3aa0091bba
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/context.tsx
@@ -0,0 +1,22 @@
+import type { Editor } from '@tiptap/core';
+import * as React from 'react';
+
+export interface ButtonBubbleMenuContextValue {
+ editor: Editor;
+ buttonHref: string;
+ isEditing: boolean;
+ setIsEditing: (value: boolean) => void;
+}
+
+export const ButtonBubbleMenuContext =
+ React.createContext(null);
+
+export function useButtonBubbleMenuContext(): ButtonBubbleMenuContextValue {
+ const context = React.useContext(ButtonBubbleMenuContext);
+ if (!context) {
+ throw new Error(
+ 'ButtonBubbleMenu compound components must be used within ',
+ );
+ }
+ return context;
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/default.spec.tsx b/packages/editor/src/ui/button-bubble-menu/default.spec.tsx
new file mode 100644
index 0000000000..bd20dbaf8b
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/default.spec.tsx
@@ -0,0 +1,117 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { ButtonBubbleMenuDefault } from './default';
+
+const mockEditor = {
+ isActive: vi.fn().mockReturnValue(false),
+ getAttributes: vi.fn().mockReturnValue({ href: '#' }),
+ view: {
+ dom: { classList: { contains: vi.fn().mockReturnValue(false) } },
+ },
+ on: vi.fn(),
+ off: vi.fn(),
+};
+
+vi.mock('@tiptap/react', () => ({
+ useCurrentEditor: () => ({ editor: mockEditor }),
+ useEditorState: ({
+ selector,
+ }: {
+ selector: (ctx: { editor: unknown }) => unknown;
+ }) => selector({ editor: mockEditor }),
+}));
+
+let capturedOnHide: (() => void) | undefined;
+let capturedShouldShow:
+ | ((ctx: { editor: typeof mockEditor; view: unknown }) => boolean)
+ | undefined;
+
+vi.mock('@tiptap/react/menus', () => ({
+ BubbleMenu: ({
+ children,
+ className,
+ options,
+ shouldShow,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ options?: { placement?: string; offset?: number; onHide?: () => void };
+ shouldShow?: (ctx: { editor: unknown; view: unknown }) => boolean;
+ }) => {
+ capturedOnHide = options?.onHide;
+ capturedShouldShow = shouldShow as typeof capturedShouldShow;
+ return (
+
+ {children}
+
+ );
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+ mockEditor.isActive.mockReturnValue(false);
+ capturedOnHide = undefined;
+ capturedShouldShow = undefined;
+});
+
+describe('ButtonBubbleMenuDefault', () => {
+ it('renders EditLink by default', () => {
+ render( );
+
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ expect(document.querySelector('[data-re-btn-bm-toolbar]')).toBeDefined();
+ });
+
+ it('shows unlink button when button has a link', () => {
+ mockEditor.getAttributes.mockReturnValue({ href: 'https://react.email' });
+ render( );
+
+ expect(screen.getByLabelText('Remove link')).toBeDefined();
+ mockEditor.getAttributes.mockReturnValue({ href: '#' });
+ });
+
+ it('forwards placement and offset to Root', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.dataset.placement).toBe('bottom');
+ expect(root.dataset.offset).toBe('16');
+ });
+
+ it('applies className to the root element', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.className).toBe('custom');
+ });
+
+ it('invokes onHide callback', () => {
+ const onHide = vi.fn();
+ render( );
+
+ expect(capturedOnHide).toBeDefined();
+ capturedOnHide!();
+ expect(onHide).toHaveBeenCalledOnce();
+ });
+
+ it('shouldShow returns true when button node is active', () => {
+ render( );
+
+ expect(capturedShouldShow).toBeDefined();
+
+ mockEditor.isActive.mockReturnValue(true);
+ expect(
+ capturedShouldShow!({ editor: mockEditor, view: mockEditor.view }),
+ ).toBe(true);
+
+ mockEditor.isActive.mockReturnValue(false);
+ expect(
+ capturedShouldShow!({ editor: mockEditor, view: mockEditor.view }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/editor/src/ui/button-bubble-menu/default.tsx b/packages/editor/src/ui/button-bubble-menu/default.tsx
new file mode 100644
index 0000000000..f6fe4a46b8
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/default.tsx
@@ -0,0 +1,70 @@
+import type * as React from 'react';
+import { useButtonBubbleMenuContext } from './context';
+import { ButtonBubbleMenuEditLink } from './edit-link';
+import { ButtonBubbleMenuForm } from './form';
+import { ButtonBubbleMenuRoot } from './root';
+import { ButtonBubbleMenuToolbar } from './toolbar';
+import { ButtonBubbleMenuUnlink } from './unlink';
+
+export interface ButtonBubbleMenuDefaultProps
+ extends Omit, 'children'> {
+ placement?: 'top' | 'bottom';
+ offset?: number;
+ onHide?: () => void;
+ validateUrl?: (value: string) => string | null;
+ onLinkApply?: (href: string) => void;
+ onLinkRemove?: () => void;
+}
+
+function ButtonBubbleMenuDefaultInner({
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+}: Pick<
+ ButtonBubbleMenuDefaultProps,
+ 'validateUrl' | 'onLinkApply' | 'onLinkRemove'
+>) {
+ const { buttonHref } = useButtonBubbleMenuContext();
+ const hasLink = buttonHref !== '' && buttonHref !== '#';
+
+ return (
+ <>
+
+
+ {hasLink && }
+
+
+ >
+ );
+}
+
+export function ButtonBubbleMenuDefault({
+ placement,
+ offset,
+ onHide,
+ className,
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+ ...rest
+}: ButtonBubbleMenuDefaultProps) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/edit-link.spec.tsx b/packages/editor/src/ui/button-bubble-menu/edit-link.spec.tsx
new file mode 100644
index 0000000000..6433d2119d
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/edit-link.spec.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { ButtonBubbleMenuContext } from './context';
+import { ButtonBubbleMenuEditLink } from './edit-link';
+
+const mockSetIsEditing = vi.fn();
+
+function renderWithContext(ui: ReactNode) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('ButtonBubbleMenuEditLink', () => {
+ beforeEach(() => {
+ mockSetIsEditing.mockClear();
+ });
+
+ it('renders a button with edit link aria-label', () => {
+ renderWithContext( );
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ });
+
+ it('calls setIsEditing(true) on click', () => {
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Edit link'));
+ expect(mockSetIsEditing).toHaveBeenCalledWith(true);
+ });
+
+ it('prevents default on mousedown to avoid editor blur', () => {
+ renderWithContext( );
+ const button = screen.getByLabelText('Edit link');
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ const preventSpy = vi.spyOn(event, 'preventDefault');
+ button.dispatchEvent(event);
+ expect(preventSpy).toHaveBeenCalled();
+ });
+
+ it('renders custom children instead of default icon', () => {
+ renderWithContext(
+
+ Custom
+ ,
+ );
+ expect(screen.getByText('Custom')).toBeDefined();
+ });
+
+ it('applies className', () => {
+ const { container } = renderWithContext(
+ ,
+ );
+ expect(container.querySelector('[data-item="edit-link"]')?.className).toBe(
+ 'my-class',
+ );
+ });
+
+ it('has correct data attributes', () => {
+ const { container } = renderWithContext( );
+ const button = container.querySelector('[data-re-btn-bm-item]');
+ expect(button).toBeDefined();
+ expect(button?.getAttribute('data-item')).toBe('edit-link');
+ });
+
+ it('composes user onClick with setIsEditing', () => {
+ const userOnClick = vi.fn();
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Edit link'));
+ expect(userOnClick).toHaveBeenCalledTimes(1);
+ expect(mockSetIsEditing).toHaveBeenCalledWith(true);
+ });
+
+ it('spreads rest props onto button', () => {
+ renderWithContext( );
+ expect(screen.getByTestId('custom')).toBeDefined();
+ });
+
+ it('composes user onMouseDown while still preventing default', () => {
+ const userOnMouseDown = vi.fn();
+ renderWithContext(
+ ,
+ );
+ const button = screen.getByLabelText('Edit link');
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ const preventSpy = vi.spyOn(event, 'preventDefault');
+ button.dispatchEvent(event);
+ expect(preventSpy).toHaveBeenCalled();
+ expect(userOnMouseDown).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/editor/src/ui/button-bubble-menu/edit-link.tsx b/packages/editor/src/ui/button-bubble-menu/edit-link.tsx
new file mode 100644
index 0000000000..417f7c6d0c
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/edit-link.tsx
@@ -0,0 +1,37 @@
+import type * as React from 'react';
+import { PencilIcon } from '../icons';
+import { useButtonBubbleMenuContext } from './context';
+
+export interface ButtonBubbleMenuEditLinkProps
+ extends Omit, 'type'> {}
+
+export function ButtonBubbleMenuEditLink({
+ className,
+ children,
+ onClick,
+ onMouseDown,
+ ...rest
+}: ButtonBubbleMenuEditLinkProps) {
+ const { setIsEditing } = useButtonBubbleMenuContext();
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/form.tsx b/packages/editor/src/ui/button-bubble-menu/form.tsx
new file mode 100644
index 0000000000..2fc50599da
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/form.tsx
@@ -0,0 +1,152 @@
+import * as React from 'react';
+import { focusEditor, getUrlFromString } from '../bubble-menu/utils';
+import { Check, UnlinkIcon } from '../icons';
+import { useButtonBubbleMenuContext } from './context';
+
+export interface ButtonBubbleMenuFormProps {
+ className?: string;
+ validateUrl?: (value: string) => string | null;
+ onLinkApply?: (href: string) => void;
+ onLinkRemove?: () => void;
+}
+
+export function ButtonBubbleMenuForm({
+ className,
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+}: ButtonBubbleMenuFormProps) {
+ const { editor, buttonHref, isEditing, setIsEditing } =
+ useButtonBubbleMenuContext();
+ const inputRef = React.useRef(null);
+ const formRef = React.useRef(null);
+ const displayHref = buttonHref === '#' ? '' : buttonHref;
+ const [inputValue, setInputValue] = React.useState(displayHref);
+
+ React.useEffect(() => {
+ if (!isEditing) {
+ return;
+ }
+ setInputValue(displayHref);
+ const timeoutId = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 0);
+ return () => clearTimeout(timeoutId);
+ }, [isEditing, displayHref]);
+
+ React.useEffect(() => {
+ if (!isEditing) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setIsEditing(false);
+ }
+ };
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (formRef.current && !formRef.current.contains(event.target as Node)) {
+ const form = formRef.current;
+ const submitEvent = new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ });
+ form.dispatchEvent(submitEvent);
+ setIsEditing(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isEditing, setIsEditing]);
+
+ if (!isEditing) {
+ return null;
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+
+ const value = inputValue.trim();
+
+ if (value === '') {
+ editor.commands.updateButton({ href: '#' });
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ return;
+ }
+
+ const validate = validateUrl ?? getUrlFromString;
+ const finalValue = validate(value);
+
+ if (!finalValue) {
+ editor.commands.updateButton({ href: '#' });
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ return;
+ }
+
+ editor.commands.updateButton({ href: finalValue });
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkApply?.(finalValue);
+ }
+
+ function handleUnlink(e: React.MouseEvent) {
+ e.stopPropagation();
+ editor.commands.updateButton({ href: '#' });
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/index.ts b/packages/editor/src/ui/button-bubble-menu/index.ts
new file mode 100644
index 0000000000..e186611258
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/index.ts
@@ -0,0 +1,29 @@
+import { ButtonBubbleMenuDefault } from './default';
+import { ButtonBubbleMenuEditLink } from './edit-link';
+import { ButtonBubbleMenuForm } from './form';
+import { ButtonBubbleMenuRoot } from './root';
+import { ButtonBubbleMenuToolbar } from './toolbar';
+import { ButtonBubbleMenuUnlink } from './unlink';
+
+export { useButtonBubbleMenuContext } from './context';
+export type { ButtonBubbleMenuDefaultProps } from './default';
+export { ButtonBubbleMenuDefault } from './default';
+export type { ButtonBubbleMenuEditLinkProps } from './edit-link';
+export { ButtonBubbleMenuEditLink } from './edit-link';
+export type { ButtonBubbleMenuFormProps } from './form';
+export { ButtonBubbleMenuForm } from './form';
+export type { ButtonBubbleMenuRootProps } from './root';
+export { ButtonBubbleMenuRoot } from './root';
+export type { ButtonBubbleMenuToolbarProps } from './toolbar';
+export { ButtonBubbleMenuToolbar } from './toolbar';
+export type { ButtonBubbleMenuUnlinkProps } from './unlink';
+export { ButtonBubbleMenuUnlink } from './unlink';
+
+export const ButtonBubbleMenu = {
+ Root: ButtonBubbleMenuRoot,
+ Toolbar: ButtonBubbleMenuToolbar,
+ EditLink: ButtonBubbleMenuEditLink,
+ Unlink: ButtonBubbleMenuUnlink,
+ Form: ButtonBubbleMenuForm,
+ Default: ButtonBubbleMenuDefault,
+} as const;
diff --git a/packages/editor/src/ui/button-bubble-menu/root.spec.tsx b/packages/editor/src/ui/button-bubble-menu/root.spec.tsx
new file mode 100644
index 0000000000..9d63700125
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/root.spec.tsx
@@ -0,0 +1,24 @@
+import { render } from '@testing-library/react';
+import { ButtonBubbleMenuRoot } from './root';
+
+vi.mock('@tiptap/react', () => ({
+ useCurrentEditor: () => ({ editor: null }),
+ useEditorState: () => null,
+}));
+
+vi.mock('@tiptap/react/menus', () => ({
+ BubbleMenu: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+describe('ButtonBubbleMenuRoot', () => {
+ it('returns null when editor is null', () => {
+ const { container } = render(
+
+ child
+ ,
+ );
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/packages/editor/src/ui/button-bubble-menu/root.tsx b/packages/editor/src/ui/button-bubble-menu/root.tsx
new file mode 100644
index 0000000000..689357dc7a
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/root.tsx
@@ -0,0 +1,69 @@
+import { useCurrentEditor, useEditorState } from '@tiptap/react';
+import { BubbleMenu } from '@tiptap/react/menus';
+import * as React from 'react';
+import { ButtonBubbleMenuContext } from './context';
+
+export interface ButtonBubbleMenuRootProps
+ extends Omit, 'children'> {
+ /** Called when the bubble menu hides */
+ onHide?: () => void;
+ /** Placement relative to cursor (default: 'top') */
+ placement?: 'top' | 'bottom';
+ /** Offset from cursor in px (default: 8) */
+ offset?: number;
+ children: React.ReactNode;
+}
+
+export function ButtonBubbleMenuRoot({
+ onHide,
+ placement = 'top',
+ offset = 8,
+ className,
+ children,
+ ...rest
+}: ButtonBubbleMenuRootProps) {
+ const { editor } = useCurrentEditor();
+ const [isEditing, setIsEditing] = React.useState(false);
+
+ const buttonHref = useEditorState({
+ editor,
+ selector: ({ editor: e }) =>
+ (e?.getAttributes('button').href as string) ?? '',
+ });
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+
+ e.isActive('button') && !view.dom.classList.contains('dragging')
+ }
+ options={{
+ placement,
+ offset,
+ onHide: () => {
+ setIsEditing(false);
+ onHide?.();
+ },
+ }}
+ className={className}
+ {...rest}
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/toolbar.spec.tsx b/packages/editor/src/ui/button-bubble-menu/toolbar.spec.tsx
new file mode 100644
index 0000000000..29af3aa708
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/toolbar.spec.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { ButtonBubbleMenuContext } from './context';
+import { ButtonBubbleMenuToolbar } from './toolbar';
+
+const mockEditor = {} as any;
+
+function renderWithContext(
+ ui: ReactNode,
+ { isEditing = false }: { isEditing?: boolean } = {},
+) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('ButtonBubbleMenuToolbar', () => {
+ it('renders children when not editing', () => {
+ renderWithContext(
+
+
+ ,
+ { isEditing: false },
+ );
+ expect(screen.getByRole('button', { name: 'Edit' })).toBeDefined();
+ });
+
+ it('renders null when editing', () => {
+ const { container } = renderWithContext(
+
+
+ ,
+ { isEditing: true },
+ );
+ expect(container.querySelector('[data-re-btn-bm-toolbar]')).toBeNull();
+ });
+
+ it('spreads rest props onto div', () => {
+ const { container } = renderWithContext(
+
+ child
+ ,
+ );
+ const toolbar = container.querySelector('[data-re-btn-bm-toolbar]');
+ expect(toolbar?.getAttribute('data-testid')).toBe('custom');
+ expect(toolbar?.getAttribute('aria-label')).toBe('toolbar');
+ });
+});
diff --git a/packages/editor/src/ui/button-bubble-menu/toolbar.tsx b/packages/editor/src/ui/button-bubble-menu/toolbar.tsx
new file mode 100644
index 0000000000..f5d2ae1e24
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/toolbar.tsx
@@ -0,0 +1,22 @@
+import type * as React from 'react';
+import { useButtonBubbleMenuContext } from './context';
+
+export interface ButtonBubbleMenuToolbarProps
+ extends React.ComponentProps<'div'> {}
+
+export function ButtonBubbleMenuToolbar({
+ children,
+ ...rest
+}: ButtonBubbleMenuToolbarProps) {
+ const { isEditing } = useButtonBubbleMenuContext();
+
+ if (isEditing) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/editor/src/ui/button-bubble-menu/unlink.tsx b/packages/editor/src/ui/button-bubble-menu/unlink.tsx
new file mode 100644
index 0000000000..fddfa7a04a
--- /dev/null
+++ b/packages/editor/src/ui/button-bubble-menu/unlink.tsx
@@ -0,0 +1,43 @@
+import type * as React from 'react';
+import { focusEditor } from '../bubble-menu/utils';
+import { UnlinkIcon } from '../icons';
+import { useButtonBubbleMenuContext } from './context';
+
+export interface ButtonBubbleMenuUnlinkProps
+ extends Omit, 'type'> {
+ onLinkRemove?: () => void;
+}
+
+export function ButtonBubbleMenuUnlink({
+ className,
+ children,
+ onClick,
+ onMouseDown,
+ onLinkRemove,
+ ...rest
+}: ButtonBubbleMenuUnlinkProps) {
+ const { editor } = useButtonBubbleMenuContext();
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/align-center.tsx b/packages/editor/src/ui/icons/align-center.tsx
new file mode 100644
index 0000000000..9c4265a6b1
--- /dev/null
+++ b/packages/editor/src/ui/icons/align-center.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function AlignCenterIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/align-left.tsx b/packages/editor/src/ui/icons/align-left.tsx
new file mode 100644
index 0000000000..6e07faeabb
--- /dev/null
+++ b/packages/editor/src/ui/icons/align-left.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function AlignLeftIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/align-right.tsx b/packages/editor/src/ui/icons/align-right.tsx
new file mode 100644
index 0000000000..73c264665f
--- /dev/null
+++ b/packages/editor/src/ui/icons/align-right.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function AlignRightIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/bold.tsx b/packages/editor/src/ui/icons/bold.tsx
new file mode 100644
index 0000000000..02687b4042
--- /dev/null
+++ b/packages/editor/src/ui/icons/bold.tsx
@@ -0,0 +1,25 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function BoldIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/case-upper.tsx b/packages/editor/src/ui/icons/case-upper.tsx
new file mode 100644
index 0000000000..c0d3ceb975
--- /dev/null
+++ b/packages/editor/src/ui/icons/case-upper.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function CaseUpperIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/check.tsx b/packages/editor/src/ui/icons/check.tsx
new file mode 100644
index 0000000000..2754fe2588
--- /dev/null
+++ b/packages/editor/src/ui/icons/check.tsx
@@ -0,0 +1,25 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Check({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/chevron-down.tsx b/packages/editor/src/ui/icons/chevron-down.tsx
new file mode 100644
index 0000000000..8d5b0155af
--- /dev/null
+++ b/packages/editor/src/ui/icons/chevron-down.tsx
@@ -0,0 +1,25 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function ChevronDown({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/code.tsx b/packages/editor/src/ui/icons/code.tsx
new file mode 100644
index 0000000000..be50998c35
--- /dev/null
+++ b/packages/editor/src/ui/icons/code.tsx
@@ -0,0 +1,28 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function CodeIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export const Code = CodeIcon;
diff --git a/packages/editor/src/ui/icons/columns-2.tsx b/packages/editor/src/ui/icons/columns-2.tsx
new file mode 100644
index 0000000000..584ee6467c
--- /dev/null
+++ b/packages/editor/src/ui/icons/columns-2.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Columns2({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/columns-3.tsx b/packages/editor/src/ui/icons/columns-3.tsx
new file mode 100644
index 0000000000..142093f64c
--- /dev/null
+++ b/packages/editor/src/ui/icons/columns-3.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Columns3({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/columns-4.tsx b/packages/editor/src/ui/icons/columns-4.tsx
new file mode 100644
index 0000000000..230cc3305a
--- /dev/null
+++ b/packages/editor/src/ui/icons/columns-4.tsx
@@ -0,0 +1,28 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Columns4({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/external-link.tsx b/packages/editor/src/ui/icons/external-link.tsx
new file mode 100644
index 0000000000..43986fb3e9
--- /dev/null
+++ b/packages/editor/src/ui/icons/external-link.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function ExternalLinkIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/heading-1.tsx b/packages/editor/src/ui/icons/heading-1.tsx
new file mode 100644
index 0000000000..3edca40969
--- /dev/null
+++ b/packages/editor/src/ui/icons/heading-1.tsx
@@ -0,0 +1,28 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Heading1({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/heading-2.tsx b/packages/editor/src/ui/icons/heading-2.tsx
new file mode 100644
index 0000000000..85e30f558a
--- /dev/null
+++ b/packages/editor/src/ui/icons/heading-2.tsx
@@ -0,0 +1,28 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Heading2({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/heading-3.tsx b/packages/editor/src/ui/icons/heading-3.tsx
new file mode 100644
index 0000000000..8758fd9018
--- /dev/null
+++ b/packages/editor/src/ui/icons/heading-3.tsx
@@ -0,0 +1,29 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Heading3({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/index.ts b/packages/editor/src/ui/icons/index.ts
new file mode 100644
index 0000000000..7b657ae3d6
--- /dev/null
+++ b/packages/editor/src/ui/icons/index.ts
@@ -0,0 +1,29 @@
+export { AlignCenterIcon } from './align-center';
+export { AlignLeftIcon } from './align-left';
+export { AlignRightIcon } from './align-right';
+export { BoldIcon } from './bold';
+export { CaseUpperIcon } from './case-upper';
+export { Check } from './check';
+export { ChevronDown } from './chevron-down';
+export { Code, CodeIcon } from './code';
+export { Columns2 } from './columns-2';
+export { Columns3 } from './columns-3';
+export { Columns4 } from './columns-4';
+export { ExternalLinkIcon } from './external-link';
+export { Heading1 } from './heading-1';
+export { Heading2 } from './heading-2';
+export { Heading3 } from './heading-3';
+export { ItalicIcon } from './italic';
+export { LinkIcon } from './link';
+export { List } from './list';
+export { ListOrdered } from './list-ordered';
+export { MousePointer } from './mouse-pointer';
+export { PencilIcon } from './pencil';
+export { Rows2 } from './rows-2';
+export { SplitSquareVertical } from './split-square-vertical';
+export { SquareCode } from './square-code';
+export { StrikethroughIcon } from './strikethrough';
+export { Text, TextIcon } from './text';
+export { TextQuote } from './text-quote';
+export { UnderlineIcon } from './underline';
+export { UnlinkIcon } from './unlink';
diff --git a/packages/editor/src/ui/icons/italic.tsx b/packages/editor/src/ui/icons/italic.tsx
new file mode 100644
index 0000000000..cc15538917
--- /dev/null
+++ b/packages/editor/src/ui/icons/italic.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function ItalicIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/link.tsx b/packages/editor/src/ui/icons/link.tsx
new file mode 100644
index 0000000000..fcf18f1e66
--- /dev/null
+++ b/packages/editor/src/ui/icons/link.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function LinkIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/list-ordered.tsx b/packages/editor/src/ui/icons/list-ordered.tsx
new file mode 100644
index 0000000000..06ae9be9b0
--- /dev/null
+++ b/packages/editor/src/ui/icons/list-ordered.tsx
@@ -0,0 +1,30 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function ListOrdered({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/list.tsx b/packages/editor/src/ui/icons/list.tsx
new file mode 100644
index 0000000000..2c95f8fe34
--- /dev/null
+++ b/packages/editor/src/ui/icons/list.tsx
@@ -0,0 +1,30 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function List({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/mouse-pointer.tsx b/packages/editor/src/ui/icons/mouse-pointer.tsx
new file mode 100644
index 0000000000..a165f261f4
--- /dev/null
+++ b/packages/editor/src/ui/icons/mouse-pointer.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function MousePointer({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/pencil.tsx b/packages/editor/src/ui/icons/pencil.tsx
new file mode 100644
index 0000000000..6e1bc8103f
--- /dev/null
+++ b/packages/editor/src/ui/icons/pencil.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function PencilIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/rows-2.tsx b/packages/editor/src/ui/icons/rows-2.tsx
new file mode 100644
index 0000000000..fc362b68d6
--- /dev/null
+++ b/packages/editor/src/ui/icons/rows-2.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function Rows2({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/split-square-vertical.tsx b/packages/editor/src/ui/icons/split-square-vertical.tsx
new file mode 100644
index 0000000000..bd029aae51
--- /dev/null
+++ b/packages/editor/src/ui/icons/split-square-vertical.tsx
@@ -0,0 +1,32 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function SplitSquareVertical({
+ size,
+ width,
+ height,
+ ...props
+}: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/square-code.tsx b/packages/editor/src/ui/icons/square-code.tsx
new file mode 100644
index 0000000000..9d50d9728e
--- /dev/null
+++ b/packages/editor/src/ui/icons/square-code.tsx
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function SquareCode({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/strikethrough.tsx b/packages/editor/src/ui/icons/strikethrough.tsx
new file mode 100644
index 0000000000..d069a1e076
--- /dev/null
+++ b/packages/editor/src/ui/icons/strikethrough.tsx
@@ -0,0 +1,32 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function StrikethroughIcon({
+ size,
+ width,
+ height,
+ ...props
+}: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/text-quote.tsx b/packages/editor/src/ui/icons/text-quote.tsx
new file mode 100644
index 0000000000..b34a32f2b7
--- /dev/null
+++ b/packages/editor/src/ui/icons/text-quote.tsx
@@ -0,0 +1,28 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function TextQuote({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/text.tsx b/packages/editor/src/ui/icons/text.tsx
new file mode 100644
index 0000000000..dd7881dda7
--- /dev/null
+++ b/packages/editor/src/ui/icons/text.tsx
@@ -0,0 +1,29 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function TextIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export const Text = TextIcon;
diff --git a/packages/editor/src/ui/icons/underline.tsx b/packages/editor/src/ui/icons/underline.tsx
new file mode 100644
index 0000000000..218c5cdcc2
--- /dev/null
+++ b/packages/editor/src/ui/icons/underline.tsx
@@ -0,0 +1,26 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function UnderlineIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/icons/unlink.tsx b/packages/editor/src/ui/icons/unlink.tsx
new file mode 100644
index 0000000000..f10be93613
--- /dev/null
+++ b/packages/editor/src/ui/icons/unlink.tsx
@@ -0,0 +1,30 @@
+import type * as React from 'react';
+
+interface IconProps extends React.SVGAttributes {
+ size?: number | string;
+}
+
+export function UnlinkIcon({ size, width, height, ...props }: IconProps) {
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/image-bubble-menu/context.spec.tsx b/packages/editor/src/ui/image-bubble-menu/context.spec.tsx
new file mode 100644
index 0000000000..0197d748ba
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/context.spec.tsx
@@ -0,0 +1,12 @@
+import { renderHook } from '@testing-library/react';
+import { useImageBubbleMenuContext } from './context';
+
+describe('useImageBubbleMenuContext', () => {
+ it('throws when used outside a Provider', () => {
+ expect(() => {
+ renderHook(() => useImageBubbleMenuContext());
+ }).toThrow(
+ 'ImageBubbleMenu compound components must be used within ',
+ );
+ });
+});
diff --git a/packages/editor/src/ui/image-bubble-menu/context.tsx b/packages/editor/src/ui/image-bubble-menu/context.tsx
new file mode 100644
index 0000000000..44767741e6
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/context.tsx
@@ -0,0 +1,21 @@
+import type { Editor } from '@tiptap/core';
+import * as React from 'react';
+
+export interface ImageBubbleMenuContextValue {
+ editor: Editor;
+ isEditing: boolean;
+ setIsEditing: (value: boolean) => void;
+}
+
+export const ImageBubbleMenuContext =
+ React.createContext(null);
+
+export function useImageBubbleMenuContext(): ImageBubbleMenuContextValue {
+ const context = React.useContext(ImageBubbleMenuContext);
+ if (!context) {
+ throw new Error(
+ 'ImageBubbleMenu compound components must be used within ',
+ );
+ }
+ return context;
+}
diff --git a/packages/editor/src/ui/image-bubble-menu/default.spec.tsx b/packages/editor/src/ui/image-bubble-menu/default.spec.tsx
new file mode 100644
index 0000000000..1562e714d5
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/default.spec.tsx
@@ -0,0 +1,110 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { ImageBubbleMenuDefault } from './default';
+
+const mockEditor = {
+ isActive: vi.fn().mockReturnValue(false),
+ view: {
+ dom: { classList: { contains: vi.fn().mockReturnValue(false) } },
+ },
+ on: vi.fn(),
+ off: vi.fn(),
+};
+
+vi.mock('@tiptap/react', () => ({
+ useCurrentEditor: () => ({ editor: mockEditor }),
+}));
+
+let capturedOnHide: (() => void) | undefined;
+let capturedShouldShow:
+ | ((ctx: { editor: typeof mockEditor; view: unknown }) => boolean)
+ | undefined;
+
+vi.mock('@tiptap/react/menus', () => ({
+ BubbleMenu: ({
+ children,
+ className,
+ options,
+ shouldShow,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ options?: { placement?: string; offset?: number; onHide?: () => void };
+ shouldShow?: (ctx: { editor: unknown; view: unknown }) => boolean;
+ }) => {
+ capturedOnHide = options?.onHide;
+ capturedShouldShow = shouldShow as typeof capturedShouldShow;
+ return (
+
+ {children}
+
+ );
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+ mockEditor.isActive.mockReturnValue(false);
+ capturedOnHide = undefined;
+ capturedShouldShow = undefined;
+});
+
+describe('ImageBubbleMenuDefault', () => {
+ it('renders EditLink by default', () => {
+ render( );
+
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ expect(document.querySelector('[data-re-img-bm-toolbar]')).toBeDefined();
+ });
+
+ it('excludeItems: ["edit-link"] removes toolbar entirely', () => {
+ render( );
+
+ expect(screen.queryByLabelText('Edit link')).toBeNull();
+ expect(document.querySelector('[data-re-img-bm-toolbar]')).toBeNull();
+ });
+
+ it('forwards placement and offset to Root', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.dataset.placement).toBe('bottom');
+ expect(root.dataset.offset).toBe('16');
+ });
+
+ it('applies className to the root element', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.className).toBe('custom');
+ });
+
+ it('invokes onHide callback', () => {
+ const onHide = vi.fn();
+ render( );
+
+ expect(capturedOnHide).toBeDefined();
+ capturedOnHide!();
+ expect(onHide).toHaveBeenCalledOnce();
+ });
+
+ it('returns true when image node is active', () => {
+ render( );
+
+ expect(capturedShouldShow).toBeDefined();
+
+ mockEditor.isActive.mockReturnValue(true);
+ expect(
+ capturedShouldShow!({ editor: mockEditor, view: mockEditor.view }),
+ ).toBe(true);
+
+ mockEditor.isActive.mockReturnValue(false);
+ expect(
+ capturedShouldShow!({ editor: mockEditor, view: mockEditor.view }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/editor/src/ui/image-bubble-menu/default.tsx b/packages/editor/src/ui/image-bubble-menu/default.tsx
new file mode 100644
index 0000000000..07ba27820e
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/default.tsx
@@ -0,0 +1,41 @@
+import type * as React from 'react';
+import { ImageBubbleMenuEditLink } from './edit-link';
+import { ImageBubbleMenuRoot } from './root';
+import { ImageBubbleMenuToolbar } from './toolbar';
+
+type ExcludableItem = 'edit-link';
+
+export interface ImageBubbleMenuDefaultProps
+ extends Omit, 'children'> {
+ excludeItems?: ExcludableItem[];
+ placement?: 'top' | 'bottom';
+ offset?: number;
+ onHide?: () => void;
+}
+
+export function ImageBubbleMenuDefault({
+ excludeItems = [],
+ placement,
+ offset,
+ onHide,
+ className,
+ ...rest
+}: ImageBubbleMenuDefaultProps) {
+ const hasEditLink = !excludeItems.includes('edit-link');
+
+ return (
+
+ {hasEditLink && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/editor/src/ui/image-bubble-menu/edit-link.spec.tsx b/packages/editor/src/ui/image-bubble-menu/edit-link.spec.tsx
new file mode 100644
index 0000000000..035c7182ad
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/edit-link.spec.tsx
@@ -0,0 +1,88 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { ImageBubbleMenuContext } from './context';
+import { ImageBubbleMenuEditLink } from './edit-link';
+
+const mockSetIsEditing = vi.fn();
+
+function renderWithContext(ui: ReactNode) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('ImageBubbleMenuEditLink', () => {
+ beforeEach(() => {
+ mockSetIsEditing.mockClear();
+ });
+
+ it('renders a button with edit link aria-label', () => {
+ renderWithContext( );
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ });
+
+ it('calls setIsEditing(true) on click', () => {
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Edit link'));
+ expect(mockSetIsEditing).toHaveBeenCalledWith(true);
+ });
+
+ it('prevents default on mousedown to avoid editor blur', () => {
+ renderWithContext( );
+ const button = screen.getByLabelText('Edit link');
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ const preventSpy = vi.spyOn(event, 'preventDefault');
+ button.dispatchEvent(event);
+ expect(preventSpy).toHaveBeenCalled();
+ });
+
+ it('composes user onMouseDown while still preventing default', () => {
+ const userOnMouseDown = vi.fn();
+ renderWithContext(
+ ,
+ );
+ const button = screen.getByLabelText('Edit link');
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ const preventSpy = vi.spyOn(event, 'preventDefault');
+ button.dispatchEvent(event);
+ expect(preventSpy).toHaveBeenCalled();
+ expect(userOnMouseDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders custom children instead of default icon', () => {
+ renderWithContext(
+
+ Custom
+ ,
+ );
+ expect(screen.getByText('Custom')).toBeDefined();
+ });
+
+ it('has correct data attributes', () => {
+ const { container } = renderWithContext( );
+ const button = container.querySelector('[data-re-img-bm-item]');
+ expect(button).toBeDefined();
+ expect(button?.getAttribute('data-item')).toBe('edit-link');
+ });
+
+ it('composes user onClick with setIsEditing', () => {
+ const userOnClick = vi.fn();
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Edit link'));
+ expect(userOnClick).toHaveBeenCalledTimes(1);
+ expect(mockSetIsEditing).toHaveBeenCalledWith(true);
+ });
+
+ it('spreads rest props onto button', () => {
+ renderWithContext( );
+ expect(screen.getByTestId('custom')).toBeDefined();
+ });
+});
diff --git a/packages/editor/src/ui/image-bubble-menu/edit-link.tsx b/packages/editor/src/ui/image-bubble-menu/edit-link.tsx
new file mode 100644
index 0000000000..29e9a21204
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/edit-link.tsx
@@ -0,0 +1,37 @@
+import type * as React from 'react';
+import { LinkIcon } from '../icons';
+import { useImageBubbleMenuContext } from './context';
+
+export interface ImageBubbleMenuEditLinkProps
+ extends Omit, 'type'> {}
+
+export function ImageBubbleMenuEditLink({
+ className,
+ children,
+ onClick,
+ onMouseDown,
+ ...rest
+}: ImageBubbleMenuEditLinkProps) {
+ const { setIsEditing } = useImageBubbleMenuContext();
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/image-bubble-menu/image-bubble-menu.css b/packages/editor/src/ui/image-bubble-menu/image-bubble-menu.css
new file mode 100644
index 0000000000..c5317f7ac9
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/image-bubble-menu.css
@@ -0,0 +1,29 @@
+/* Minimal functional styles for ImageBubbleMenu compound components.
+ * Layout and positioning only - no visual design.
+ * Import optionally: import '@react-email/editor/styles/image-bubble-menu.css';
+ */
+
+[data-re-img-bm] {
+ display: flex;
+ align-items: center;
+}
+
+[data-re-img-bm-toolbar] {
+ display: flex;
+ align-items: center;
+}
+
+[data-re-img-bm-item] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.5rem;
+}
+
+[data-re-img-bm-item] svg {
+ width: 1rem;
+ height: 1rem;
+}
diff --git a/packages/editor/src/ui/image-bubble-menu/index.ts b/packages/editor/src/ui/image-bubble-menu/index.ts
new file mode 100644
index 0000000000..021bb3d146
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/index.ts
@@ -0,0 +1,21 @@
+import { ImageBubbleMenuDefault } from './default';
+import { ImageBubbleMenuEditLink } from './edit-link';
+import { ImageBubbleMenuRoot } from './root';
+import { ImageBubbleMenuToolbar } from './toolbar';
+
+export { useImageBubbleMenuContext } from './context';
+export type { ImageBubbleMenuDefaultProps } from './default';
+export { ImageBubbleMenuDefault } from './default';
+export type { ImageBubbleMenuEditLinkProps } from './edit-link';
+export { ImageBubbleMenuEditLink } from './edit-link';
+export type { ImageBubbleMenuRootProps } from './root';
+export { ImageBubbleMenuRoot } from './root';
+export type { ImageBubbleMenuToolbarProps } from './toolbar';
+export { ImageBubbleMenuToolbar } from './toolbar';
+
+export const ImageBubbleMenu = {
+ Root: ImageBubbleMenuRoot,
+ Toolbar: ImageBubbleMenuToolbar,
+ EditLink: ImageBubbleMenuEditLink,
+ Default: ImageBubbleMenuDefault,
+} as const;
diff --git a/packages/editor/src/ui/image-bubble-menu/root.spec.tsx b/packages/editor/src/ui/image-bubble-menu/root.spec.tsx
new file mode 100644
index 0000000000..270c5eca9d
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/root.spec.tsx
@@ -0,0 +1,24 @@
+import { render } from '@testing-library/react';
+import { ImageBubbleMenuRoot } from './root';
+
+vi.mock('@tiptap/react', () => ({
+ useCurrentEditor: () => ({ editor: null }),
+ useEditorState: () => null,
+}));
+
+vi.mock('@tiptap/react/menus', () => ({
+ BubbleMenu: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+describe('ImageBubbleMenuRoot', () => {
+ it('returns null when editor is null', () => {
+ const { container } = render(
+
+ child
+ ,
+ );
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/packages/editor/src/ui/image-bubble-menu/root.tsx b/packages/editor/src/ui/image-bubble-menu/root.tsx
new file mode 100644
index 0000000000..eba5c7ec72
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/root.tsx
@@ -0,0 +1,58 @@
+import { useCurrentEditor } from '@tiptap/react';
+import { BubbleMenu } from '@tiptap/react/menus';
+import * as React from 'react';
+import { ImageBubbleMenuContext } from './context';
+
+export interface ImageBubbleMenuRootProps
+ extends Omit, 'children'> {
+ /** Called when the bubble menu hides */
+ onHide?: () => void;
+ /** Placement relative to cursor (default: 'top') */
+ placement?: 'top' | 'bottom';
+ /** Offset from cursor in px (default: 8) */
+ offset?: number;
+ children: React.ReactNode;
+}
+
+export function ImageBubbleMenuRoot({
+ onHide,
+ placement = 'top',
+ offset = 8,
+ className,
+ children,
+ ...rest
+}: ImageBubbleMenuRootProps) {
+ const { editor } = useCurrentEditor();
+ const [isEditing, setIsEditing] = React.useState(false);
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+
+ e.isActive('image') && !view.dom.classList.contains('dragging')
+ }
+ options={{
+ placement,
+ offset,
+ onHide: () => {
+ setIsEditing(false);
+ onHide?.();
+ },
+ }}
+ className={className}
+ {...rest}
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/packages/editor/src/ui/image-bubble-menu/toolbar.spec.tsx b/packages/editor/src/ui/image-bubble-menu/toolbar.spec.tsx
new file mode 100644
index 0000000000..7b7be5f3b8
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/toolbar.spec.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { ImageBubbleMenuContext } from './context';
+import { ImageBubbleMenuToolbar } from './toolbar';
+
+const mockEditor = {} as any;
+
+function renderWithContext(
+ ui: ReactNode,
+ { isEditing = false }: { isEditing?: boolean } = {},
+) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('ImageBubbleMenuToolbar', () => {
+ it('renders children when not editing', () => {
+ renderWithContext(
+
+
+ ,
+ { isEditing: false },
+ );
+ expect(screen.getByRole('button', { name: 'Edit' })).toBeDefined();
+ });
+
+ it('renders null when editing', () => {
+ const { container } = renderWithContext(
+
+
+ ,
+ { isEditing: true },
+ );
+ expect(container.querySelector('[data-re-img-bm-toolbar]')).toBeNull();
+ });
+
+ it('spreads rest props onto div', () => {
+ const { container } = renderWithContext(
+
+ child
+ ,
+ );
+ const toolbar = container.querySelector('[data-re-img-bm-toolbar]');
+ expect(toolbar?.getAttribute('data-testid')).toBe('custom');
+ expect(toolbar?.getAttribute('aria-label')).toBe('toolbar');
+ });
+});
diff --git a/packages/editor/src/ui/image-bubble-menu/toolbar.tsx b/packages/editor/src/ui/image-bubble-menu/toolbar.tsx
new file mode 100644
index 0000000000..22d2d8cda5
--- /dev/null
+++ b/packages/editor/src/ui/image-bubble-menu/toolbar.tsx
@@ -0,0 +1,22 @@
+import type * as React from 'react';
+import { useImageBubbleMenuContext } from './context';
+
+export interface ImageBubbleMenuToolbarProps
+ extends React.ComponentProps<'div'> {}
+
+export function ImageBubbleMenuToolbar({
+ children,
+ ...rest
+}: ImageBubbleMenuToolbarProps) {
+ const { isEditing } = useImageBubbleMenuContext();
+
+ if (isEditing) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/editor/src/ui/index.ts b/packages/editor/src/ui/index.ts
new file mode 100644
index 0000000000..68cebfe404
--- /dev/null
+++ b/packages/editor/src/ui/index.ts
@@ -0,0 +1,6 @@
+export * from './bubble-menu';
+export * from './button-bubble-menu';
+export * from './icons';
+export * from './image-bubble-menu';
+export * from './link-bubble-menu';
+export * from './slash-command';
diff --git a/packages/editor/src/ui/link-bubble-menu/context.spec.tsx b/packages/editor/src/ui/link-bubble-menu/context.spec.tsx
new file mode 100644
index 0000000000..5782794d09
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/context.spec.tsx
@@ -0,0 +1,12 @@
+import { renderHook } from '@testing-library/react';
+import { useLinkBubbleMenuContext } from './context';
+
+describe('useLinkBubbleMenuContext', () => {
+ it('throws when used outside a Provider', () => {
+ expect(() => {
+ renderHook(() => useLinkBubbleMenuContext());
+ }).toThrow(
+ 'LinkBubbleMenu compound components must be used within ',
+ );
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/context.tsx b/packages/editor/src/ui/link-bubble-menu/context.tsx
new file mode 100644
index 0000000000..712d81bc0b
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/context.tsx
@@ -0,0 +1,22 @@
+import type { Editor } from '@tiptap/core';
+import * as React from 'react';
+
+export interface LinkBubbleMenuContextValue {
+ editor: Editor;
+ linkHref: string;
+ isEditing: boolean;
+ setIsEditing: (value: boolean) => void;
+}
+
+export const LinkBubbleMenuContext =
+ React.createContext(null);
+
+export function useLinkBubbleMenuContext(): LinkBubbleMenuContextValue {
+ const context = React.useContext(LinkBubbleMenuContext);
+ if (!context) {
+ throw new Error(
+ 'LinkBubbleMenu compound components must be used within ',
+ );
+ }
+ return context;
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/default.spec.tsx b/packages/editor/src/ui/link-bubble-menu/default.spec.tsx
new file mode 100644
index 0000000000..81aa577352
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/default.spec.tsx
@@ -0,0 +1,162 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { LinkBubbleMenuDefault } from './default';
+
+const mockEditor = {
+ isActive: vi.fn().mockReturnValue(false),
+ getAttributes: vi.fn().mockReturnValue({ href: 'https://example.com' }),
+ chain: vi.fn().mockReturnValue({
+ focus: vi.fn().mockReturnThis(),
+ unsetLink: vi.fn().mockReturnThis(),
+ setLink: vi.fn().mockReturnThis(),
+ extendMarkRange: vi.fn().mockReturnThis(),
+ run: vi.fn(),
+ }),
+ view: {
+ state: { selection: { content: () => ({ size: 0 }) } },
+ dom: { classList: { contains: vi.fn().mockReturnValue(false) } },
+ },
+ commands: { focus: vi.fn() },
+ on: vi.fn(),
+ off: vi.fn(),
+};
+
+vi.mock('@tiptap/react', () => ({
+ useCurrentEditor: () => ({ editor: mockEditor }),
+ useEditorState: ({
+ selector,
+ }: {
+ selector: (ctx: { editor: unknown }) => unknown;
+ }) => selector({ editor: mockEditor }),
+}));
+
+let capturedOnHide: (() => void) | undefined;
+let capturedShouldShow:
+ | ((ctx: { editor: typeof mockEditor; view: unknown }) => boolean)
+ | undefined;
+
+vi.mock('@tiptap/react/menus', () => ({
+ BubbleMenu: ({
+ children,
+ className,
+ options,
+ shouldShow,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ options?: { placement?: string; offset?: number; onHide?: () => void };
+ shouldShow?: (ctx: { editor: unknown; view: unknown }) => boolean;
+ }) => {
+ capturedOnHide = options?.onHide;
+ capturedShouldShow = shouldShow as typeof capturedShouldShow;
+ return (
+
+ {children}
+
+ );
+ },
+}));
+
+vi.mock('../bubble-menu/utils', () => ({
+ focusEditor: vi.fn(),
+ getUrlFromString: vi.fn((v: string) => v),
+ setLinkHref: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ mockEditor.isActive.mockReturnValue(false);
+ capturedOnHide = undefined;
+ capturedShouldShow = undefined;
+});
+
+describe('LinkBubbleMenuDefault', () => {
+ it('renders all toolbar items by default', () => {
+ render( );
+
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ expect(screen.getByLabelText('Open link')).toBeDefined();
+ expect(screen.getByLabelText('Remove link')).toBeDefined();
+ expect(document.querySelector('[data-re-link-bm-toolbar]')).toBeDefined();
+ });
+
+ it('excludeItems: ["edit-link"] hides it, others remain', () => {
+ render( );
+
+ expect(screen.queryByLabelText('Edit link')).toBeNull();
+ expect(screen.getByLabelText('Open link')).toBeDefined();
+ expect(screen.getByLabelText('Remove link')).toBeDefined();
+ });
+
+ it('excludeItems: ["open-link"] hides it, others remain', () => {
+ render( );
+
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ expect(screen.queryByLabelText('Open link')).toBeNull();
+ expect(screen.getByLabelText('Remove link')).toBeDefined();
+ });
+
+ it('all 3 excluded removes toolbar entirely', () => {
+ render(
+ ,
+ );
+
+ expect(document.querySelector('[data-re-link-bm-toolbar]')).toBeNull();
+ });
+
+ it('renders root even when all toolbar items excluded', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('bubble-menu-root')).toBeDefined();
+ });
+
+ it('forwards placement and offset to Root', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.dataset.placement).toBe('bottom');
+ expect(root.dataset.offset).toBe('16');
+ });
+
+ it('applies className to the root element', () => {
+ render( );
+
+ const root = screen.getByTestId('bubble-menu-root');
+ expect(root.className).toBe('custom');
+ });
+
+ it('invokes onHide callback', () => {
+ const onHide = vi.fn();
+ render( );
+
+ expect(capturedOnHide).toBeDefined();
+ capturedOnHide!();
+ expect(onHide).toHaveBeenCalledOnce();
+ });
+
+ it('shouldShow returns true when link is active and selection is empty', () => {
+ render( );
+
+ expect(capturedShouldShow).toBeDefined();
+
+ mockEditor.isActive.mockReturnValue(true);
+ expect(
+ capturedShouldShow!({ editor: mockEditor, view: mockEditor.view }),
+ ).toBe(true);
+
+ mockEditor.isActive.mockReturnValue(false);
+ expect(
+ capturedShouldShow!({ editor: mockEditor, view: mockEditor.view }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/default.tsx b/packages/editor/src/ui/link-bubble-menu/default.tsx
new file mode 100644
index 0000000000..95f595d2a2
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/default.tsx
@@ -0,0 +1,59 @@
+import type * as React from 'react';
+import { LinkBubbleMenuEditLink } from './edit-link';
+import { LinkBubbleMenuForm } from './form';
+import { LinkBubbleMenuOpenLink } from './open-link';
+import { LinkBubbleMenuRoot } from './root';
+import { LinkBubbleMenuToolbar } from './toolbar';
+import { LinkBubbleMenuUnlink } from './unlink';
+
+type ExcludableItem = 'edit-link' | 'open-link' | 'unlink';
+
+export interface LinkBubbleMenuDefaultProps
+ extends Omit, 'children'> {
+ excludeItems?: ExcludableItem[];
+ placement?: 'top' | 'bottom';
+ offset?: number;
+ onHide?: () => void;
+ validateUrl?: (value: string) => string | null;
+ onLinkApply?: (href: string) => void;
+ onLinkRemove?: () => void;
+}
+
+export function LinkBubbleMenuDefault({
+ excludeItems = [],
+ placement,
+ offset,
+ onHide,
+ className,
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+ ...rest
+}: LinkBubbleMenuDefaultProps) {
+ const has = (item: ExcludableItem) => !excludeItems.includes(item);
+
+ const hasToolbarItems = has('edit-link') || has('open-link') || has('unlink');
+
+ return (
+
+ {hasToolbarItems && (
+
+ {has('edit-link') && }
+ {has('open-link') && }
+ {has('unlink') && }
+
+ )}
+
+
+ );
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/edit-link.spec.tsx b/packages/editor/src/ui/link-bubble-menu/edit-link.spec.tsx
new file mode 100644
index 0000000000..4bf209656f
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/edit-link.spec.tsx
@@ -0,0 +1,85 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { LinkBubbleMenuContext } from './context';
+import { LinkBubbleMenuEditLink } from './edit-link';
+
+const mockSetIsEditing = vi.fn();
+
+function renderWithContext(ui: ReactNode) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('LinkBubbleMenuEditLink', () => {
+ beforeEach(() => {
+ mockSetIsEditing.mockClear();
+ });
+
+ it('renders a button with edit link aria-label', () => {
+ renderWithContext( );
+ expect(screen.getByLabelText('Edit link')).toBeDefined();
+ });
+
+ it('calls setIsEditing(true) on click', () => {
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Edit link'));
+ expect(mockSetIsEditing).toHaveBeenCalledWith(true);
+ });
+
+ it('prevents default on mousedown to avoid editor blur', () => {
+ renderWithContext( );
+ const button = screen.getByLabelText('Edit link');
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ const preventSpy = vi.spyOn(event, 'preventDefault');
+ button.dispatchEvent(event);
+ expect(preventSpy).toHaveBeenCalled();
+ });
+
+ it('renders custom children instead of default icon', () => {
+ renderWithContext(
+
+ Custom
+ ,
+ );
+ expect(screen.getByText('Custom')).toBeDefined();
+ });
+
+ it('applies className', () => {
+ const { container } = renderWithContext(
+ ,
+ );
+ expect(container.querySelector('[data-item="edit-link"]')?.className).toBe(
+ 'my-class',
+ );
+ });
+
+ it('has correct data attributes', () => {
+ const { container } = renderWithContext( );
+ const button = container.querySelector('[data-re-link-bm-item]');
+ expect(button).toBeDefined();
+ expect(button?.getAttribute('data-item')).toBe('edit-link');
+ });
+
+ it('composes user onClick with setIsEditing', () => {
+ const userOnClick = vi.fn();
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Edit link'));
+ expect(userOnClick).toHaveBeenCalledTimes(1);
+ expect(mockSetIsEditing).toHaveBeenCalledWith(true);
+ });
+
+ it('spreads rest props onto button', () => {
+ renderWithContext( );
+ expect(screen.getByTestId('custom')).toBeDefined();
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/edit-link.tsx b/packages/editor/src/ui/link-bubble-menu/edit-link.tsx
new file mode 100644
index 0000000000..812c6efafd
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/edit-link.tsx
@@ -0,0 +1,37 @@
+import type * as React from 'react';
+import { PencilIcon } from '../icons';
+import { useLinkBubbleMenuContext } from './context';
+
+export interface LinkBubbleMenuEditLinkProps
+ extends Omit, 'type'> {}
+
+export function LinkBubbleMenuEditLink({
+ className,
+ children,
+ onClick,
+ onMouseDown,
+ ...rest
+}: LinkBubbleMenuEditLinkProps) {
+ const { setIsEditing } = useLinkBubbleMenuContext();
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/form.spec.tsx b/packages/editor/src/ui/link-bubble-menu/form.spec.tsx
new file mode 100644
index 0000000000..2ab55d424a
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/form.spec.tsx
@@ -0,0 +1,274 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { LinkBubbleMenuContext } from './context';
+import { LinkBubbleMenuForm } from './form';
+
+const mockRun = vi.fn();
+
+function createMockEditor(
+ { from, to }: { from: number; to: number } = { from: 0, to: 0 },
+) {
+ const run = mockRun;
+ const setTextSelection = vi.fn(() => ({ run }));
+ const setLink = vi.fn(() => ({ run, setTextSelection }));
+ const extendMarkRange = vi.fn(() => ({ setLink }));
+ const unsetLink = vi.fn(() => ({ run }));
+ return {
+ chain: vi.fn(() => ({ unsetLink, extendMarkRange, setLink })),
+ state: { selection: { from, to } },
+ commands: { focus: vi.fn() },
+ } as any;
+}
+
+const mockSetIsEditing = vi.fn();
+
+function renderWithContext(
+ ui: ReactNode,
+ {
+ isEditing = true,
+ linkHref = 'https://example.com',
+ editor = createMockEditor(),
+ }: { isEditing?: boolean; linkHref?: string; editor?: any } = {},
+) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('LinkBubbleMenuForm', () => {
+ beforeEach(() => {
+ mockSetIsEditing.mockClear();
+ mockRun.mockClear();
+ vi.useRealTimers();
+ });
+
+ describe('rendering', () => {
+ it('renders null when not editing', () => {
+ const { container } = renderWithContext( , {
+ isEditing: false,
+ });
+ expect(container.querySelector('[data-re-link-bm-form]')).toBeNull();
+ });
+
+ it('renders form when editing', () => {
+ renderWithContext( );
+ expect(screen.getByPlaceholderText('Paste a link')).toBeDefined();
+ });
+
+ it('prefills input with current link href', () => {
+ renderWithContext( , {
+ linkHref: 'https://test.com',
+ });
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ expect(input.value).toBe('https://test.com');
+ });
+
+ it('treats # href as empty', () => {
+ renderWithContext( , { linkHref: '#' });
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ expect(input.value).toBe('');
+ });
+
+ it('shows unlink button when href exists', () => {
+ renderWithContext( , {
+ linkHref: 'https://example.com',
+ });
+ expect(screen.getByLabelText('Remove link')).toBeDefined();
+ });
+
+ it('shows apply button when no href', () => {
+ renderWithContext( , { linkHref: '' });
+ expect(screen.getByLabelText('Apply link')).toBeDefined();
+ });
+
+ it('renders children slot', () => {
+ renderWithContext(
+
+
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Variables' })).toBeDefined();
+ });
+
+ it('applies className', () => {
+ const { container } = renderWithContext(
+ ,
+ );
+ expect(container.querySelector('[data-re-link-bm-form]')?.className).toBe(
+ 'custom-form',
+ );
+ });
+ });
+
+ describe('submit with valid URL', () => {
+ it('applies link and calls onLinkApply', () => {
+ const editor = createMockEditor();
+ const onLinkApply = vi.fn();
+ renderWithContext( , {
+ linkHref: '',
+ editor,
+ });
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'https://new-link.com' } });
+ fireEvent.submit(input.closest('form')!);
+
+ expect(editor.chain).toHaveBeenCalled();
+ expect(mockSetIsEditing).toHaveBeenCalledWith(false);
+ expect(onLinkApply).toHaveBeenCalledWith('https://new-link.com');
+ });
+ });
+
+ describe('submit with empty value', () => {
+ it('removes link and calls onLinkRemove', () => {
+ const editor = createMockEditor();
+ const onLinkRemove = vi.fn();
+ renderWithContext( , {
+ linkHref: '',
+ editor,
+ });
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '' } });
+ fireEvent.submit(input.closest('form')!);
+
+ expect(editor.chain).toHaveBeenCalled();
+ expect(mockSetIsEditing).toHaveBeenCalledWith(false);
+ expect(onLinkRemove).toHaveBeenCalled();
+ });
+ });
+
+ describe('submit with invalid URL', () => {
+ it('removes link and calls onLinkRemove for invalid input', () => {
+ const editor = createMockEditor();
+ const onLinkRemove = vi.fn();
+ const onLinkApply = vi.fn();
+ renderWithContext(
+ ,
+ { linkHref: '', editor },
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'not a valid url' } });
+ fireEvent.submit(input.closest('form')!);
+
+ expect(onLinkRemove).toHaveBeenCalled();
+ expect(onLinkApply).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('custom validateUrl', () => {
+ it('uses custom validator instead of default', () => {
+ const editor = createMockEditor();
+ const validateUrl = vi.fn(() => 'https://custom-validated.com');
+ const onLinkApply = vi.fn();
+ renderWithContext(
+ ,
+ { linkHref: '', editor },
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'anything' } });
+ fireEvent.submit(input.closest('form')!);
+
+ expect(validateUrl).toHaveBeenCalledWith('anything');
+ expect(onLinkApply).toHaveBeenCalledWith('https://custom-validated.com');
+ });
+
+ it('removes link when custom validator returns null', () => {
+ const editor = createMockEditor();
+ const validateUrl = vi.fn(() => null);
+ const onLinkRemove = vi.fn();
+ renderWithContext(
+ ,
+ { linkHref: '', editor },
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'test' } });
+ fireEvent.submit(input.closest('form')!);
+
+ expect(onLinkRemove).toHaveBeenCalled();
+ });
+ });
+
+ describe('unlink button', () => {
+ it('removes link on click and calls onLinkRemove', () => {
+ const editor = createMockEditor();
+ const onLinkRemove = vi.fn();
+ renderWithContext( , {
+ linkHref: 'https://example.com',
+ editor,
+ });
+
+ fireEvent.click(screen.getByLabelText('Remove link'));
+
+ expect(editor.chain).toHaveBeenCalled();
+ expect(mockSetIsEditing).toHaveBeenCalledWith(false);
+ expect(onLinkRemove).toHaveBeenCalled();
+ });
+ });
+
+ describe('escape key', () => {
+ it('sets isEditing to false on Escape', () => {
+ renderWithContext( );
+
+ fireEvent.keyDown(window, { key: 'Escape' });
+
+ expect(mockSetIsEditing).toHaveBeenCalledWith(false);
+ });
+
+ it('does not react to other keys', () => {
+ renderWithContext( );
+
+ fireEvent.keyDown(window, { key: 'Enter' });
+
+ expect(mockSetIsEditing).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('input interaction', () => {
+ it('updates input value on change', () => {
+ renderWithContext( , { linkHref: '' });
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a link',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'https://typed.com' } });
+
+ expect(input.value).toBe('https://typed.com');
+ });
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/form.tsx b/packages/editor/src/ui/link-bubble-menu/form.tsx
new file mode 100644
index 0000000000..8d9f805f84
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/form.tsx
@@ -0,0 +1,164 @@
+import * as React from 'react';
+import {
+ focusEditor,
+ getUrlFromString,
+ setLinkHref,
+} from '../bubble-menu/utils';
+import { Check, UnlinkIcon } from '../icons';
+import { useLinkBubbleMenuContext } from './context';
+
+export interface LinkBubbleMenuFormProps {
+ className?: string;
+ /** Custom URL validator (default: getUrlFromString) */
+ validateUrl?: (value: string) => string | null;
+ /** Called after link is applied */
+ onLinkApply?: (href: string) => void;
+ /** Called after link is removed */
+ onLinkRemove?: () => void;
+ /** Extra content inside the form (e.g. a variables dropdown slot) */
+ children?: React.ReactNode;
+}
+
+export function LinkBubbleMenuForm({
+ className,
+ validateUrl,
+ onLinkApply,
+ onLinkRemove,
+ children,
+}: LinkBubbleMenuFormProps) {
+ const { editor, linkHref, isEditing, setIsEditing } =
+ useLinkBubbleMenuContext();
+ const inputRef = React.useRef(null);
+ const formRef = React.useRef(null);
+ const displayHref = linkHref === '#' ? '' : linkHref;
+ const [inputValue, setInputValue] = React.useState(displayHref);
+
+ React.useEffect(() => {
+ if (!isEditing) {
+ return;
+ }
+ setInputValue(displayHref);
+ const timeoutId = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 0);
+ return () => clearTimeout(timeoutId);
+ }, [isEditing, displayHref]);
+
+ React.useEffect(() => {
+ if (!isEditing) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setIsEditing(false);
+ }
+ };
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (formRef.current && !formRef.current.contains(event.target as Node)) {
+ const form = formRef.current;
+ const submitEvent = new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ });
+ form.dispatchEvent(submitEvent);
+ setIsEditing(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isEditing, setIsEditing]);
+
+ if (!isEditing) {
+ return null;
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+
+ const value = inputValue.trim();
+
+ if (value === '') {
+ setLinkHref(editor, '');
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ return;
+ }
+
+ const validate = validateUrl ?? getUrlFromString;
+ const finalValue = validate(value);
+
+ if (!finalValue) {
+ setLinkHref(editor, '');
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ return;
+ }
+
+ setLinkHref(editor, finalValue);
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkApply?.(finalValue);
+ }
+
+ function handleUnlink(e: React.MouseEvent) {
+ e.stopPropagation();
+ setLinkHref(editor, '');
+ setIsEditing(false);
+ focusEditor(editor);
+ onLinkRemove?.();
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/index.ts b/packages/editor/src/ui/link-bubble-menu/index.ts
new file mode 100644
index 0000000000..d46aa3422c
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/index.ts
@@ -0,0 +1,33 @@
+import { LinkBubbleMenuDefault } from './default';
+import { LinkBubbleMenuEditLink } from './edit-link';
+import { LinkBubbleMenuForm } from './form';
+import { LinkBubbleMenuOpenLink } from './open-link';
+import { LinkBubbleMenuRoot } from './root';
+import { LinkBubbleMenuToolbar } from './toolbar';
+import { LinkBubbleMenuUnlink } from './unlink';
+
+export { useLinkBubbleMenuContext } from './context';
+export type { LinkBubbleMenuDefaultProps } from './default';
+export { LinkBubbleMenuDefault } from './default';
+export type { LinkBubbleMenuEditLinkProps } from './edit-link';
+export { LinkBubbleMenuEditLink } from './edit-link';
+export type { LinkBubbleMenuFormProps } from './form';
+export { LinkBubbleMenuForm } from './form';
+export type { LinkBubbleMenuOpenLinkProps } from './open-link';
+export { LinkBubbleMenuOpenLink } from './open-link';
+export type { LinkBubbleMenuRootProps } from './root';
+export { LinkBubbleMenuRoot } from './root';
+export type { LinkBubbleMenuToolbarProps } from './toolbar';
+export { LinkBubbleMenuToolbar } from './toolbar';
+export type { LinkBubbleMenuUnlinkProps } from './unlink';
+export { LinkBubbleMenuUnlink } from './unlink';
+
+export const LinkBubbleMenu = {
+ Root: LinkBubbleMenuRoot,
+ Toolbar: LinkBubbleMenuToolbar,
+ Form: LinkBubbleMenuForm,
+ EditLink: LinkBubbleMenuEditLink,
+ Unlink: LinkBubbleMenuUnlink,
+ OpenLink: LinkBubbleMenuOpenLink,
+ Default: LinkBubbleMenuDefault,
+} as const;
diff --git a/packages/editor/src/ui/link-bubble-menu/link-bubble-menu.css b/packages/editor/src/ui/link-bubble-menu/link-bubble-menu.css
new file mode 100644
index 0000000000..36d5b66689
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/link-bubble-menu.css
@@ -0,0 +1,68 @@
+/* Minimal functional styles for LinkBubbleMenu compound components.
+ * Layout and positioning only - no visual design.
+ * Import optionally: import '@react-email/editor/styles/link-bubble-menu.css';
+ */
+
+[data-re-link-bm] {
+ display: flex;
+ align-items: center;
+}
+
+[data-re-link-bm-toolbar] {
+ display: flex;
+ align-items: center;
+}
+
+[data-re-link-bm-item] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.375rem;
+}
+
+[data-re-link-bm-item] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+a[data-re-link-bm-item] {
+ text-decoration: none;
+ color: inherit;
+}
+
+[data-re-link-bm-form] {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ min-width: 16rem;
+ padding: 0.25rem;
+}
+
+[data-re-link-bm-input] {
+ flex: 1;
+ border: none;
+ outline: none;
+ font-size: 0.8125rem;
+ padding: 0.25rem;
+ background: transparent;
+}
+
+[data-re-link-bm-apply],
+[data-re-link-bm-unlink] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0.25rem;
+}
+
+[data-re-link-bm-apply] svg,
+[data-re-link-bm-unlink] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/open-link.spec.tsx b/packages/editor/src/ui/link-bubble-menu/open-link.spec.tsx
new file mode 100644
index 0000000000..9aef3afe19
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/open-link.spec.tsx
@@ -0,0 +1,91 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { LinkBubbleMenuContext } from './context';
+import { LinkBubbleMenuOpenLink } from './open-link';
+
+function renderWithContext(
+ ui: ReactNode,
+ { linkHref = 'https://example.com' }: { linkHref?: string } = {},
+) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('LinkBubbleMenuOpenLink', () => {
+ it('renders an anchor with correct href', () => {
+ renderWithContext( );
+ const link = screen.getByLabelText('Open link') as HTMLAnchorElement;
+ expect(link.tagName).toBe('A');
+ expect(link.href).toBe('https://example.com/');
+ });
+
+ it('opens in new tab with noopener noreferrer', () => {
+ renderWithContext( );
+ const link = screen.getByLabelText('Open link') as HTMLAnchorElement;
+ expect(link.target).toBe('_blank');
+ expect(link.rel).toBe('noopener noreferrer');
+ });
+
+ it('updates href when context linkHref changes', () => {
+ const { rerender } = renderWithContext( );
+ expect((screen.getByLabelText('Open link') as HTMLAnchorElement).href).toBe(
+ 'https://example.com/',
+ );
+
+ rerender(
+
+
+ ,
+ );
+ expect((screen.getByLabelText('Open link') as HTMLAnchorElement).href).toBe(
+ 'https://other.com/',
+ );
+ });
+
+ it('renders custom children instead of default icon', () => {
+ renderWithContext(
+
+ Open
+ ,
+ );
+ expect(screen.getByText('Open')).toBeDefined();
+ });
+
+ it('applies className', () => {
+ const { container } = renderWithContext(
+ ,
+ );
+ expect(container.querySelector('[data-item="open-link"]')?.className).toBe(
+ 'my-class',
+ );
+ });
+
+ it('has correct data attributes', () => {
+ const { container } = renderWithContext( );
+ const link = container.querySelector('[data-re-link-bm-item]');
+ expect(link).toBeDefined();
+ expect(link?.getAttribute('data-item')).toBe('open-link');
+ });
+
+ it('spreads rest props onto anchor', () => {
+ renderWithContext( );
+ expect(screen.getByTestId('custom')).toBeDefined();
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/open-link.tsx b/packages/editor/src/ui/link-bubble-menu/open-link.tsx
new file mode 100644
index 0000000000..a50049a827
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/open-link.tsx
@@ -0,0 +1,29 @@
+import type * as React from 'react';
+import { ExternalLinkIcon } from '../icons';
+import { useLinkBubbleMenuContext } from './context';
+
+export interface LinkBubbleMenuOpenLinkProps
+ extends Omit, 'href' | 'target' | 'rel'> {}
+
+export function LinkBubbleMenuOpenLink({
+ className,
+ children,
+ ...rest
+}: LinkBubbleMenuOpenLinkProps) {
+ const { linkHref } = useLinkBubbleMenuContext();
+
+ return (
+
+ {children ?? }
+
+ );
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/root.spec.tsx b/packages/editor/src/ui/link-bubble-menu/root.spec.tsx
new file mode 100644
index 0000000000..ebc84567a3
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/root.spec.tsx
@@ -0,0 +1,13 @@
+import { render } from '@testing-library/react';
+import { LinkBubbleMenuRoot } from './root';
+
+describe('LinkBubbleMenuRoot', () => {
+ it('renders null when no editor context is available', () => {
+ const { container } = render(
+
+ child
+ ,
+ );
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/root.tsx b/packages/editor/src/ui/link-bubble-menu/root.tsx
new file mode 100644
index 0000000000..aea1180022
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/root.tsx
@@ -0,0 +1,64 @@
+import { useCurrentEditor, useEditorState } from '@tiptap/react';
+import { BubbleMenu } from '@tiptap/react/menus';
+import * as React from 'react';
+import { LinkBubbleMenuContext } from './context';
+
+export interface LinkBubbleMenuRootProps
+ extends Omit, 'children'> {
+ /** Called when the bubble menu hides */
+ onHide?: () => void;
+ /** Placement relative to cursor (default: 'top') */
+ placement?: 'top' | 'bottom';
+ /** Offset from cursor in px (default: 8) */
+ offset?: number;
+ children: React.ReactNode;
+}
+
+export function LinkBubbleMenuRoot({
+ onHide,
+ placement = 'top',
+ offset = 8,
+ className,
+ children,
+ ...rest
+}: LinkBubbleMenuRootProps) {
+ const { editor } = useCurrentEditor();
+ const [isEditing, setIsEditing] = React.useState(false);
+
+ const linkHref = useEditorState({
+ editor,
+ selector: ({ editor: e }) =>
+ (e?.getAttributes('link').href as string) ?? '',
+ });
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+
+ e.isActive('link') && e.view.state.selection.content().size === 0
+ }
+ options={{
+ placement,
+ offset,
+ onHide: () => {
+ setIsEditing(false);
+ onHide?.();
+ },
+ }}
+ className={className}
+ {...rest}
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/toolbar.spec.tsx b/packages/editor/src/ui/link-bubble-menu/toolbar.spec.tsx
new file mode 100644
index 0000000000..9a6f7fce69
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/toolbar.spec.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { LinkBubbleMenuContext } from './context';
+import { LinkBubbleMenuToolbar } from './toolbar';
+
+const mockEditor = {} as any;
+
+function renderWithContext(
+ ui: ReactNode,
+ { isEditing = false }: { isEditing?: boolean } = {},
+) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('LinkBubbleMenuToolbar', () => {
+ it('renders children when not editing', () => {
+ renderWithContext(
+
+
+ ,
+ { isEditing: false },
+ );
+ expect(screen.getByRole('button', { name: 'Edit' })).toBeDefined();
+ });
+
+ it('renders null when editing', () => {
+ const { container } = renderWithContext(
+
+
+ ,
+ { isEditing: true },
+ );
+ expect(container.querySelector('[data-re-link-bm-toolbar]')).toBeNull();
+ });
+
+ it('spreads rest props onto div', () => {
+ const { container } = renderWithContext(
+
+ child
+ ,
+ );
+ const toolbar = container.querySelector('[data-re-link-bm-toolbar]');
+ expect(toolbar?.getAttribute('data-testid')).toBe('custom');
+ expect(toolbar?.getAttribute('aria-label')).toBe('toolbar');
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/toolbar.tsx b/packages/editor/src/ui/link-bubble-menu/toolbar.tsx
new file mode 100644
index 0000000000..a25847e34d
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/toolbar.tsx
@@ -0,0 +1,22 @@
+import type * as React from 'react';
+import { useLinkBubbleMenuContext } from './context';
+
+export interface LinkBubbleMenuToolbarProps
+ extends React.ComponentProps<'div'> {}
+
+export function LinkBubbleMenuToolbar({
+ children,
+ ...rest
+}: LinkBubbleMenuToolbarProps) {
+ const { isEditing } = useLinkBubbleMenuContext();
+
+ if (isEditing) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/editor/src/ui/link-bubble-menu/unlink.spec.tsx b/packages/editor/src/ui/link-bubble-menu/unlink.spec.tsx
new file mode 100644
index 0000000000..58951ddff4
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/unlink.spec.tsx
@@ -0,0 +1,87 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { LinkBubbleMenuContext } from './context';
+import { LinkBubbleMenuUnlink } from './unlink';
+
+const mockRun = vi.fn();
+const mockUnsetLink = vi.fn(() => ({ run: mockRun }));
+const mockFocus = vi.fn(() => ({ unsetLink: mockUnsetLink }));
+const mockChain = vi.fn(() => ({ focus: mockFocus }));
+const mockEditor = { chain: mockChain } as any;
+
+function renderWithContext(ui: ReactNode) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('LinkBubbleMenuUnlink', () => {
+ beforeEach(() => {
+ mockChain.mockClear();
+ mockFocus.mockClear();
+ mockUnsetLink.mockClear();
+ mockRun.mockClear();
+ });
+
+ it('renders a button with remove link aria-label', () => {
+ renderWithContext( );
+ expect(screen.getByLabelText('Remove link')).toBeDefined();
+ });
+
+ it('calls editor.chain().focus().unsetLink().run() on click', () => {
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Remove link'));
+ expect(mockChain).toHaveBeenCalled();
+ expect(mockFocus).toHaveBeenCalled();
+ expect(mockUnsetLink).toHaveBeenCalled();
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it('renders custom children instead of default icon', () => {
+ renderWithContext(
+
+ Remove
+ ,
+ );
+ expect(screen.getByText('Remove')).toBeDefined();
+ });
+
+ it('applies className', () => {
+ const { container } = renderWithContext(
+ ,
+ );
+ expect(container.querySelector('[data-item="unlink"]')?.className).toBe(
+ 'my-class',
+ );
+ });
+
+ it('has correct data attributes', () => {
+ const { container } = renderWithContext( );
+ const button = container.querySelector('[data-re-link-bm-item]');
+ expect(button).toBeDefined();
+ expect(button?.getAttribute('data-item')).toBe('unlink');
+ });
+
+ it('composes user onClick with unsetLink', () => {
+ const userOnClick = vi.fn();
+ renderWithContext( );
+ fireEvent.click(screen.getByLabelText('Remove link'));
+ expect(userOnClick).toHaveBeenCalledTimes(1);
+ expect(mockChain).toHaveBeenCalled();
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it('spreads rest props onto button', () => {
+ renderWithContext( );
+ expect(screen.getByTestId('custom')).toBeDefined();
+ });
+});
diff --git a/packages/editor/src/ui/link-bubble-menu/unlink.tsx b/packages/editor/src/ui/link-bubble-menu/unlink.tsx
new file mode 100644
index 0000000000..45f91ef4b9
--- /dev/null
+++ b/packages/editor/src/ui/link-bubble-menu/unlink.tsx
@@ -0,0 +1,37 @@
+import type * as React from 'react';
+import { UnlinkIcon } from '../icons';
+import { useLinkBubbleMenuContext } from './context';
+
+export interface LinkBubbleMenuUnlinkProps
+ extends Omit, 'type'> {}
+
+export function LinkBubbleMenuUnlink({
+ className,
+ children,
+ onClick,
+ onMouseDown,
+ ...rest
+}: LinkBubbleMenuUnlinkProps) {
+ const { editor } = useLinkBubbleMenuContext();
+
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/slash-command/command-list.tsx b/packages/editor/src/ui/slash-command/command-list.tsx
new file mode 100644
index 0000000000..70d5e87c14
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/command-list.tsx
@@ -0,0 +1,121 @@
+import { useLayoutEffect, useRef } from 'react';
+import type { CommandListProps, SlashCommandItem } from './types';
+import { updateScrollView } from './utils';
+
+const CATEGORY_ORDER = ['Text', 'Media', 'Layout', 'Utility'];
+
+function groupByCategory(
+ items: SlashCommandItem[],
+): { category: string; items: SlashCommandItem[] }[] {
+ const seen = new Map();
+
+ for (const item of items) {
+ const existing = seen.get(item.category);
+ if (existing) {
+ existing.push(item);
+ } else {
+ seen.set(item.category, [item]);
+ }
+ }
+
+ const ordered: { category: string; items: SlashCommandItem[] }[] = [];
+ for (const cat of CATEGORY_ORDER) {
+ const group = seen.get(cat);
+ if (group) {
+ ordered.push({ category: cat, items: group });
+ seen.delete(cat);
+ }
+ }
+ for (const [category, group] of seen) {
+ ordered.push({ category, items: group });
+ }
+
+ return ordered;
+}
+
+interface CommandItemProps {
+ item: SlashCommandItem;
+ selected: boolean;
+ onSelect: () => void;
+}
+
+function CommandItem({ item, selected, onSelect }: CommandItemProps) {
+ return (
+
+ );
+}
+
+export function CommandList({
+ items,
+ query,
+ selectedIndex,
+ onSelect,
+}: CommandListProps) {
+ const containerRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const selected = container.querySelector('[data-selected]');
+ if (selected) {
+ updateScrollView(container, selected);
+ }
+ }, [selectedIndex]);
+
+ if (items.length === 0) {
+ return (
+
+ No results
+
+ );
+ }
+
+ const isFiltering = query.trim().length > 0;
+
+ if (isFiltering) {
+ return (
+
+ {items.map((item, index) => (
+ onSelect(index)}
+ selected={index === selectedIndex}
+ />
+ ))}
+
+ );
+ }
+
+ const groups = groupByCategory(items);
+ let flatIndex = 0;
+
+ return (
+
+ {groups.map((group) => (
+
+ {group.category}
+ {group.items.map((item) => {
+ const currentIndex = flatIndex++;
+ return (
+ onSelect(currentIndex)}
+ selected={currentIndex === selectedIndex}
+ />
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/packages/editor/src/ui/slash-command/commands.tsx b/packages/editor/src/ui/slash-command/commands.tsx
new file mode 100644
index 0000000000..1da7f5493d
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/commands.tsx
@@ -0,0 +1,245 @@
+import {
+ Columns2,
+ Columns3,
+ Columns4,
+ Heading1,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ MousePointer,
+ Rows2 as Rows2Icon,
+ SplitSquareVertical as SplitSquareVerticalIcon,
+ SquareCode,
+ Text,
+ TextQuote,
+} from '../icons';
+import type { SlashCommandItem } from './types';
+
+export const TEXT: SlashCommandItem = {
+ title: 'Text',
+ description: 'Plain text block',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['p', 'paragraph'],
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode('paragraph', 'paragraph')
+ .run();
+ },
+};
+
+export const H1: SlashCommandItem = {
+ title: 'Title',
+ description: 'Large heading',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['title', 'big', 'large', 'h1'],
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode('heading', { level: 1 })
+ .run();
+ },
+};
+
+export const H2: SlashCommandItem = {
+ title: 'Subtitle',
+ description: 'Medium heading',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['subtitle', 'medium', 'h2'],
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode('heading', { level: 2 })
+ .run();
+ },
+};
+
+export const H3: SlashCommandItem = {
+ title: 'Heading',
+ description: 'Small heading',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['subtitle', 'small', 'h3'],
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode('heading', { level: 3 })
+ .run();
+ },
+};
+
+export const BULLET_LIST: SlashCommandItem = {
+ title: 'Bullet list',
+ description: 'Unordered list',
+ icon:
,
+ category: 'Text',
+ searchTerms: ['unordered', 'point'],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
+ },
+};
+
+export const NUMBERED_LIST: SlashCommandItem = {
+ title: 'Numbered list',
+ description: 'Ordered list',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['ordered'],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
+ },
+};
+
+export const QUOTE: SlashCommandItem = {
+ title: 'Quote',
+ description: 'Block quote',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['blockquote'],
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode('paragraph', 'paragraph')
+ .toggleBlockquote()
+ .run();
+ },
+};
+
+export const CODE: SlashCommandItem = {
+ title: 'Code block',
+ description: 'Code snippet',
+ icon: ,
+ category: 'Text',
+ searchTerms: ['codeblock'],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
+ },
+};
+
+export const BUTTON: SlashCommandItem = {
+ title: 'Button',
+ description: 'Clickable button',
+ icon: ,
+ category: 'Layout',
+ searchTerms: ['button'],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setButton().run();
+ },
+};
+
+export const DIVIDER: SlashCommandItem = {
+ title: 'Divider',
+ description: 'Horizontal separator',
+ icon: ,
+ category: 'Layout',
+ searchTerms: ['hr', 'divider', 'separator'],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
+ },
+};
+
+export const SECTION: SlashCommandItem = {
+ title: 'Section',
+ description: 'Content section',
+ icon: ,
+ category: 'Layout',
+ searchTerms: ['section', 'row', 'container'],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).insertSection().run();
+ },
+};
+
+export const TWO_COLUMNS: SlashCommandItem = {
+ title: '2 columns',
+ description: 'Two column layout',
+ icon: ,
+ category: 'Layout',
+ searchTerms: [
+ 'columns',
+ 'column',
+ 'layout',
+ 'grid',
+ 'split',
+ 'side-by-side',
+ 'multi-column',
+ 'row',
+ 'two',
+ '2',
+ ],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).insertColumns(2).run();
+ },
+};
+
+export const THREE_COLUMNS: SlashCommandItem = {
+ title: '3 columns',
+ description: 'Three column layout',
+ icon: ,
+ category: 'Layout',
+ searchTerms: [
+ 'columns',
+ 'column',
+ 'layout',
+ 'grid',
+ 'split',
+ 'multi-column',
+ 'row',
+ 'three',
+ '3',
+ ],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).insertColumns(3).run();
+ },
+};
+
+export const FOUR_COLUMNS: SlashCommandItem = {
+ title: '4 columns',
+ description: 'Four column layout',
+ icon: ,
+ category: 'Layout',
+ searchTerms: [
+ 'columns',
+ 'column',
+ 'layout',
+ 'grid',
+ 'split',
+ 'multi-column',
+ 'row',
+ 'four',
+ '4',
+ ],
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).insertColumns(4).run();
+ },
+};
+
+export const defaultSlashCommands: SlashCommandItem[] = [
+ TEXT,
+ H1,
+ H2,
+ H3,
+ BULLET_LIST,
+ NUMBERED_LIST,
+ QUOTE,
+ CODE,
+ BUTTON,
+ DIVIDER,
+ SECTION,
+ TWO_COLUMNS,
+ THREE_COLUMNS,
+ FOUR_COLUMNS,
+];
diff --git a/packages/editor/src/ui/slash-command/index.ts b/packages/editor/src/ui/slash-command/index.ts
new file mode 100644
index 0000000000..4a77ba2e49
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/index.ts
@@ -0,0 +1,36 @@
+import { CommandList } from './command-list';
+import { SlashCommandRoot } from './root';
+
+export const SlashCommand = {
+ Root: SlashCommandRoot,
+ CommandList,
+} as const;
+
+export { CommandList } from './command-list';
+export {
+ BULLET_LIST,
+ BUTTON,
+ CODE,
+ DIVIDER,
+ defaultSlashCommands,
+ FOUR_COLUMNS,
+ H1,
+ H2,
+ H3,
+ NUMBERED_LIST,
+ QUOTE,
+ SECTION,
+ TEXT,
+ THREE_COLUMNS,
+ TWO_COLUMNS,
+} from './commands';
+export { filterAndRankItems, scoreItem } from './search';
+export type {
+ CommandListProps,
+ SearchableItem,
+ SlashCommandItem,
+ SlashCommandProps,
+ SlashCommandRenderProps,
+ SlashCommandRootProps,
+} from './types';
+export { isAtMaxColumnsDepth, isInsideNode } from './utils';
diff --git a/packages/editor/src/ui/slash-command/root.tsx b/packages/editor/src/ui/slash-command/root.tsx
new file mode 100644
index 0000000000..6952fdbcc0
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/root.tsx
@@ -0,0 +1,210 @@
+import {
+ autoUpdate,
+ flip,
+ offset,
+ shift,
+ useFloating,
+} from '@floating-ui/react-dom';
+import type { Editor } from '@tiptap/core';
+import { PluginKey } from '@tiptap/pm/state';
+import { useCurrentEditor } from '@tiptap/react';
+import Suggestion from '@tiptap/suggestion';
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { CommandList } from './command-list';
+import { defaultSlashCommands } from './commands';
+import { filterAndRankItems } from './search';
+import type { SlashCommandItem, SlashCommandRootProps } from './types';
+import { isAtMaxColumnsDepth } from './utils';
+
+const pluginKey = new PluginKey('slash-command');
+
+interface SuggestionState {
+ active: boolean;
+ query: string;
+ items: SlashCommandItem[];
+ clientRect: (() => DOMRect | null) | null;
+}
+
+const INITIAL_STATE: SuggestionState = {
+ active: false,
+ query: '',
+ items: [],
+ clientRect: null,
+};
+
+function defaultFilterItems(
+ items: SlashCommandItem[],
+ query: string,
+ editor: Editor,
+): SlashCommandItem[] {
+ const filtered = isAtMaxColumnsDepth(editor)
+ ? items.filter(
+ (item) => item.category !== 'Layout' || !item.title.includes('column'),
+ )
+ : items;
+
+ return filterAndRankItems(filtered, query);
+}
+
+export function SlashCommandRoot({
+ items: itemsProp,
+ filterItems: filterItemsProp,
+ char = '/',
+ allow: allowProp,
+ children,
+}: SlashCommandRootProps) {
+ const { editor } = useCurrentEditor();
+ const [state, setState] = useState(INITIAL_STATE);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const itemsRef = useRef(itemsProp ?? defaultSlashCommands);
+ const filterRef = useRef(filterItemsProp ?? defaultFilterItems);
+ const allowRef = useRef(
+ allowProp ??
+ (({ editor: e }: { editor: Editor }) => !e.isActive('codeBlock')),
+ );
+
+ itemsRef.current = itemsProp ?? defaultSlashCommands;
+ filterRef.current = filterItemsProp ?? defaultFilterItems;
+ allowRef.current =
+ allowProp ??
+ (({ editor: e }: { editor: Editor }) => !e.isActive('codeBlock'));
+
+ const commandRef = useRef<((item: SlashCommandItem) => void) | null>(null);
+ const suggestionItemsRef = useRef([]);
+ const selectedIndexRef = useRef(0);
+
+ suggestionItemsRef.current = state.items;
+ selectedIndexRef.current = selectedIndex;
+
+ const { refs, floatingStyles } = useFloating({
+ open: state.active,
+ placement: 'bottom-start',
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
+ whileElementsMounted: autoUpdate,
+ });
+
+ useEffect(() => {
+ if (!state.clientRect) return;
+ refs.setReference({
+ getBoundingClientRect: state.clientRect,
+ });
+ }, [state.clientRect, refs]);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [state.items]);
+
+ const onSelect = useCallback((index: number) => {
+ const item = suggestionItemsRef.current[index];
+ if (item && commandRef.current) {
+ commandRef.current(item);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!editor) return;
+
+ const plugin = Suggestion({
+ pluginKey,
+ editor,
+ char,
+ allow: ({ editor: e }) => allowRef.current({ editor: e }),
+ command: ({ editor: e, range, props }) => {
+ props.command({ editor: e, range });
+ },
+ items: ({ query, editor: e }) =>
+ filterRef.current(itemsRef.current, query, e),
+ render: () => ({
+ onStart: (props) => {
+ commandRef.current = props.command;
+ setState({
+ active: true,
+ query: props.query,
+ items: props.items,
+ clientRect: props.clientRect ?? null,
+ });
+ },
+ onUpdate: (props) => {
+ commandRef.current = props.command;
+ setState({
+ active: true,
+ query: props.query,
+ items: props.items,
+ clientRect: props.clientRect ?? null,
+ });
+ },
+ onKeyDown: ({ event }) => {
+ if (event.key === 'Escape') {
+ setState(INITIAL_STATE);
+ return true;
+ }
+
+ const items = suggestionItemsRef.current;
+ if (items.length === 0) return false;
+
+ if (event.key === 'ArrowUp') {
+ setSelectedIndex((i) => (i + items.length - 1) % items.length);
+ return true;
+ }
+ if (event.key === 'ArrowDown') {
+ setSelectedIndex((i) => (i + 1) % items.length);
+ return true;
+ }
+ if (event.key === 'Enter') {
+ const item = items[selectedIndexRef.current];
+ if (item && commandRef.current) {
+ commandRef.current(item);
+ }
+ return true;
+ }
+ return false;
+ },
+ onExit: () => {
+ setState(INITIAL_STATE);
+ requestAnimationFrame(() => {
+ commandRef.current = null;
+ });
+ },
+ }),
+ });
+
+ editor.registerPlugin(plugin, (newPlugin, plugins) => [
+ newPlugin,
+ ...plugins,
+ ]);
+ return () => {
+ editor.unregisterPlugin(pluginKey);
+ };
+ }, [editor, char]);
+
+ if (!editor || !state.active) return null;
+
+ const renderProps = {
+ items: state.items,
+ query: state.query,
+ selectedIndex,
+ onSelect,
+ };
+
+ let content: ReactNode;
+ if (children) {
+ content = children(renderProps);
+ } else {
+ content = ;
+ }
+
+ return createPortal(
+
+ {content}
+ ,
+ document.body,
+ );
+}
diff --git a/packages/editor/src/ui/slash-command/search.spec.ts b/packages/editor/src/ui/slash-command/search.spec.ts
new file mode 100644
index 0000000000..3d72fb0c8b
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/search.spec.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest';
+import { defaultSlashCommands } from './commands';
+import { filterAndRankItems, scoreItem } from './search';
+
+describe('scoreItem', () => {
+ const text = defaultSlashCommands.find((c) => c.title === 'Text')!;
+ const title = defaultSlashCommands.find((c) => c.title === 'Title')!;
+ const bulletList = defaultSlashCommands.find(
+ (c) => c.title === 'Bullet list',
+ )!;
+
+ it('returns 100 for empty query', () => {
+ expect(scoreItem(text, '')).toBe(100);
+ });
+
+ it('returns 100 for exact title match', () => {
+ expect(scoreItem(text, 'Text')).toBe(100);
+ });
+
+ it('is case insensitive for exact match', () => {
+ expect(scoreItem(text, 'text')).toBe(100);
+ });
+
+ it('returns 90 for title starts with', () => {
+ expect(scoreItem(title, 'Tit')).toBe(90);
+ });
+
+ it('returns 80 for title word starts with', () => {
+ expect(scoreItem(bulletList, 'Lis')).toBe(80);
+ });
+
+ it('returns 70 for exact search term match', () => {
+ expect(scoreItem(text, 'paragraph')).toBe(70);
+ });
+
+ it('returns 60 for search term starts with', () => {
+ expect(scoreItem(text, 'para')).toBe(60);
+ });
+
+ it('returns 40 for title contains', () => {
+ expect(scoreItem(bulletList, 'llet')).toBe(40);
+ });
+
+ it('returns 30 for search term contains', () => {
+ expect(
+ scoreItem(
+ { title: 'Foo', description: 'bar', searchTerms: ['abcdef'] },
+ 'cde',
+ ),
+ ).toBe(30);
+ });
+
+ it('returns 20 for description contains', () => {
+ expect(
+ scoreItem(
+ { title: 'Foo', description: 'some long text', searchTerms: [] },
+ 'long',
+ ),
+ ).toBe(20);
+ });
+
+ it('returns 0 for no match', () => {
+ expect(scoreItem(text, 'zzz')).toBe(0);
+ });
+});
+
+describe('filterAndRankItems', () => {
+ it('returns all items for empty query', () => {
+ const result = filterAndRankItems(defaultSlashCommands, '');
+ expect(result).toEqual(defaultSlashCommands);
+ });
+
+ it('returns all items for whitespace query', () => {
+ const result = filterAndRankItems(defaultSlashCommands, ' ');
+ expect(result).toEqual(defaultSlashCommands);
+ });
+
+ it('filters out non-matching items', () => {
+ const result = filterAndRankItems(defaultSlashCommands, 'zzzzz');
+ expect(result).toHaveLength(0);
+ });
+
+ it('is case insensitive', () => {
+ const result = filterAndRankItems(defaultSlashCommands, 'TEXT');
+ expect(result.length).toBeGreaterThan(0);
+ expect(result[0].title).toBe('Text');
+ });
+
+ it('ranks exact match above starts-with', () => {
+ const result = filterAndRankItems(defaultSlashCommands, 'Text');
+ expect(result[0].title).toBe('Text');
+ });
+
+ it('ranks title starts-with above search term match', () => {
+ const result = filterAndRankItems(defaultSlashCommands, 'Tit');
+ expect(result[0].title).toBe('Title');
+ });
+
+ it('finds items by search term', () => {
+ const result = filterAndRankItems(defaultSlashCommands, 'h1');
+ expect(result.some((item) => item.title === 'Title')).toBe(true);
+ });
+
+ it('returns items sorted by score descending', () => {
+ const result = filterAndRankItems(defaultSlashCommands, 'col');
+ const titles = result.map((i) => i.title);
+ expect(titles).toContain('2 columns');
+ expect(titles).toContain('3 columns');
+ expect(titles).toContain('4 columns');
+ });
+});
diff --git a/packages/editor/src/ui/slash-command/search.ts b/packages/editor/src/ui/slash-command/search.ts
new file mode 100644
index 0000000000..724d6311d5
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/search.ts
@@ -0,0 +1,41 @@
+import type { SearchableItem } from './types';
+
+export function scoreItem(item: SearchableItem, query: string): number {
+ if (!query) return 100;
+
+ const q = query.toLowerCase();
+ const title = item.title.toLowerCase();
+ const description = item.description.toLowerCase();
+ const terms = item.searchTerms?.map((t) => t.toLowerCase()) ?? [];
+
+ if (title === q) return 100;
+ if (title.startsWith(q)) return 90;
+
+ const titleWords = title.split(/\s+/);
+ if (titleWords.some((w) => w.startsWith(q))) return 80;
+
+ if (terms.some((t) => t === q)) return 70;
+ if (terms.some((t) => t.startsWith(q))) return 60;
+
+ if (title.includes(q)) return 40;
+ if (terms.some((t) => t.includes(q))) return 30;
+ if (description.includes(q)) return 20;
+
+ return 0;
+}
+
+export function filterAndRankItems(
+ items: T[],
+ query: string,
+): T[] {
+ const trimmed = query.trim();
+ if (!trimmed) return items;
+
+ const scored = items
+ .map((item) => ({ item, score: scoreItem(item, trimmed) }))
+ .filter(({ score }) => score > 0);
+
+ scored.sort((a, b) => b.score - a.score);
+
+ return scored.map(({ item }) => item);
+}
diff --git a/packages/editor/src/ui/slash-command/slash-command.css b/packages/editor/src/ui/slash-command/slash-command.css
new file mode 100644
index 0000000000..4d4abf8d72
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/slash-command.css
@@ -0,0 +1,44 @@
+/* Minimal functional styles for SlashCommand components.
+ * Layout and positioning only - no visual design.
+ * Import optionally: import '@react-email/editor/styles/slash-command.css';
+ */
+
+[data-re-slash-command] {
+ max-height: 330px;
+ overflow-y: auto;
+ width: 256px;
+ padding: 0.25rem;
+}
+
+[data-re-slash-command-item] {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.375rem 0.5rem;
+ border: none;
+ border-radius: 0.375rem;
+ background: none;
+ cursor: pointer;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ text-align: left;
+}
+
+[data-re-slash-command-item] svg {
+ flex-shrink: 0;
+}
+
+[data-re-slash-command-category] {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 0.5rem 0.5rem 0.25rem;
+}
+
+[data-re-slash-command-empty] {
+ padding: 0.75rem 0.5rem;
+ font-size: 0.875rem;
+ text-align: center;
+}
diff --git a/packages/editor/src/ui/slash-command/types.ts b/packages/editor/src/ui/slash-command/types.ts
new file mode 100644
index 0000000000..3343cfd030
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/types.ts
@@ -0,0 +1,47 @@
+import type { Editor, Range } from '@tiptap/core';
+import type { ReactNode } from 'react';
+
+export type SlashCommandCategory = string;
+
+export interface SearchableItem {
+ title: string;
+ description: string;
+ searchTerms?: string[];
+}
+
+export interface SlashCommandItem extends SearchableItem {
+ icon: ReactNode;
+ category: SlashCommandCategory;
+ command: (props: SlashCommandProps) => void;
+}
+
+export interface SlashCommandProps {
+ editor: Editor;
+ range: Range;
+}
+
+export interface SlashCommandRenderProps {
+ items: SlashCommandItem[];
+ query: string;
+ selectedIndex: number;
+ onSelect: (index: number) => void;
+}
+
+export interface SlashCommandRootProps {
+ items?: SlashCommandItem[];
+ filterItems?: (
+ items: SlashCommandItem[],
+ query: string,
+ editor: Editor,
+ ) => SlashCommandItem[];
+ char?: string;
+ allow?: (props: { editor: Editor }) => boolean;
+ children?: (props: SlashCommandRenderProps) => ReactNode;
+}
+
+export interface CommandListProps {
+ items: SlashCommandItem[];
+ query: string;
+ selectedIndex: number;
+ onSelect: (index: number) => void;
+}
diff --git a/packages/editor/src/ui/slash-command/utils.ts b/packages/editor/src/ui/slash-command/utils.ts
new file mode 100644
index 0000000000..8f658a5a96
--- /dev/null
+++ b/packages/editor/src/ui/slash-command/utils.ts
@@ -0,0 +1,29 @@
+import type { Editor } from '@tiptap/core';
+import { getColumnsDepth, MAX_COLUMNS_DEPTH } from '../../extensions/columns';
+
+export function isInsideNode(editor: Editor, type: string): boolean {
+ const { $from } = editor.state.selection;
+ for (let d = $from.depth; d > 0; d--) {
+ if ($from.node(d).type.name === type) return true;
+ }
+ return false;
+}
+
+export function isAtMaxColumnsDepth(editor: Editor): boolean {
+ const { from } = editor.state.selection;
+ return getColumnsDepth(editor.state.doc, from) >= MAX_COLUMNS_DEPTH;
+}
+
+export function updateScrollView(
+ container: HTMLElement,
+ item: HTMLElement,
+): void {
+ const containerRect = container.getBoundingClientRect();
+ const itemRect = item.getBoundingClientRect();
+
+ if (itemRect.top < containerRect.top) {
+ container.scrollTop -= containerRect.top - itemRect.top;
+ } else if (itemRect.bottom > containerRect.bottom) {
+ container.scrollTop += itemRect.bottom - containerRect.bottom;
+ }
+}
diff --git a/packages/editor/src/ui/themes/default.css b/packages/editor/src/ui/themes/default.css
new file mode 100644
index 0000000000..75c66e0329
--- /dev/null
+++ b/packages/editor/src/ui/themes/default.css
@@ -0,0 +1,506 @@
+/* Default theme for @react-email/editor bubble menu primitives.
+ *
+ * Opt-in: import '@react-email/editor/themes/default.css';
+ *
+ * Override any variable on a parent element:
+ * .my-editor { --re-bg: #1a1a1a; --re-border: #333; }
+ */
+
+/* Layer 0: layout (inlined at build time via postcss-import) */
+@import "../bubble-menu/bubble-menu.css";
+@import "../link-bubble-menu/link-bubble-menu.css";
+@import "../button-bubble-menu/button-bubble-menu.css";
+@import "../image-bubble-menu/image-bubble-menu.css";
+@import "../slash-command/slash-command.css";
+
+/* ----------------------------------------------------------------
+ * CSS custom properties — light defaults
+ * ---------------------------------------------------------------- */
+
+:root {
+ --re-bg: #fff;
+ --re-border: #e5e5e5;
+ --re-text: #1c1c1c;
+ --re-text-muted: #6b6b6b;
+ --re-hover: rgba(0, 0, 0, 0.04);
+ --re-active: rgba(0, 0, 0, 0.06);
+ --re-pressed: rgba(0, 0, 0, 0.06);
+ --re-separator: #e5e5e5;
+ --re-radius: 0.75rem;
+ --re-radius-sm: 0.5rem;
+ --re-shadow:
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ --re-danger: #dc2626;
+ --re-danger-hover: rgba(220, 38, 38, 0.1);
+}
+
+/* ----------------------------------------------------------------
+ * Dark mode — prefers-color-scheme
+ * ---------------------------------------------------------------- */
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --re-bg: #1c1c1c;
+ --re-border: #2e2e2e;
+ --re-text: #ececec;
+ --re-text-muted: #a0a0a0;
+ --re-hover: rgba(255, 255, 255, 0.06);
+ --re-active: rgba(255, 255, 255, 0.09);
+ --re-pressed: rgba(255, 255, 255, 0.09);
+ --re-separator: #2e2e2e;
+ --re-shadow:
+ 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
+ --re-danger: #f87171;
+ --re-danger-hover: rgba(248, 113, 113, 0.15);
+ }
+}
+
+/* ----------------------------------------------------------------
+ * Dark mode — .dark class override
+ * ---------------------------------------------------------------- */
+
+.dark {
+ --re-bg: #1c1c1c;
+ --re-border: #2e2e2e;
+ --re-text: #ececec;
+ --re-text-muted: #a0a0a0;
+ --re-hover: rgba(255, 255, 255, 0.06);
+ --re-active: rgba(255, 255, 255, 0.09);
+ --re-pressed: rgba(255, 255, 255, 0.09);
+ --re-separator: #2e2e2e;
+ --re-shadow:
+ 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
+ --re-danger: #f87171;
+ --re-danger-hover: rgba(248, 113, 113, 0.15);
+}
+
+/* ----------------------------------------------------------------
+ * Root containers
+ * ---------------------------------------------------------------- */
+
+[data-re-bubble-menu],
+[data-re-link-bm],
+[data-re-btn-bm],
+[data-re-img-bm] {
+ background: var(--re-bg);
+ border: 1px solid var(--re-border);
+ border-radius: var(--re-radius);
+ box-shadow: var(--re-shadow);
+ z-index: 50;
+ padding: 0.125rem;
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 0.8125rem;
+ line-height: 1;
+}
+
+/* ----------------------------------------------------------------
+ * Toolbars (link, button, image bubble menus)
+ * ---------------------------------------------------------------- */
+
+[data-re-img-bm-toolbar] > * + * {
+ border-left: 1px solid var(--re-border);
+}
+
+/* ----------------------------------------------------------------
+ * Item buttons (all bubble menus)
+ * ---------------------------------------------------------------- */
+
+[data-re-bubble-menu-item],
+[data-re-link-bm-item],
+[data-re-btn-bm-item],
+[data-re-img-bm-item] {
+ color: var(--re-text-muted);
+ border-radius: var(--re-radius-sm);
+ transition:
+ background-color 0.15s,
+ color 0.15s;
+}
+
+[data-re-bubble-menu-item]:hover,
+[data-re-link-bm-item]:hover,
+[data-re-btn-bm-item]:hover,
+[data-re-img-bm-item]:hover {
+ background: var(--re-hover);
+ color: var(--re-text);
+}
+
+[data-re-bubble-menu-item]:active,
+[data-re-link-bm-item]:active,
+[data-re-btn-bm-item]:active,
+[data-re-img-bm-item]:active {
+ background: var(--re-active);
+}
+
+/* Active / pressed state */
+[data-re-bubble-menu-item][data-active],
+[data-re-bubble-menu-item][aria-pressed="true"] {
+ background: var(--re-pressed);
+ color: var(--re-text);
+}
+
+/* ----------------------------------------------------------------
+ * Separator (text bubble menu)
+ * ---------------------------------------------------------------- */
+
+[data-re-bubble-menu-separator] {
+ background: var(--re-separator);
+}
+
+/* ----------------------------------------------------------------
+ * Node Selector
+ * ---------------------------------------------------------------- */
+
+[data-re-node-selector-trigger] {
+ color: var(--re-text);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+ font-weight: 500;
+}
+
+[data-re-node-selector-trigger]:hover {
+ background: var(--re-hover);
+}
+
+[data-re-node-selector-trigger]:active {
+ background: var(--re-active);
+}
+
+[data-re-node-selector-content] {
+ background: var(--re-bg);
+ border: 1px solid var(--re-border);
+ border-radius: var(--re-radius);
+ box-shadow: var(--re-shadow);
+ padding: 0.25rem;
+ margin-top: 0.25rem;
+ z-index: 50;
+}
+
+[data-re-node-selector-item] {
+ color: var(--re-text-muted);
+ border-radius: var(--re-radius-sm);
+ transition:
+ background-color 0.15s,
+ color 0.15s;
+}
+
+[data-re-node-selector-item]:hover {
+ background: var(--re-hover);
+ color: var(--re-text);
+}
+
+[data-re-node-selector-item][data-active] {
+ color: var(--re-text);
+}
+
+/* ----------------------------------------------------------------
+ * Link Selector (text bubble menu)
+ * ---------------------------------------------------------------- */
+
+[data-re-link-selector-trigger] {
+ color: var(--re-text-muted);
+ border-radius: var(--re-radius-sm);
+ transition:
+ background-color 0.15s,
+ color 0.15s;
+}
+
+[data-re-link-selector-trigger]:hover {
+ background: var(--re-hover);
+ color: var(--re-text);
+}
+
+[data-re-link-selector-trigger][aria-pressed="true"] {
+ background: var(--re-pressed);
+}
+
+[data-re-link-selector-form] {
+ background: var(--re-bg);
+ border: 1px solid var(--re-border);
+ border-radius: var(--re-radius);
+ box-shadow: var(--re-shadow);
+ padding: 0.25rem;
+}
+
+[data-re-link-selector-input] {
+ color: var(--re-text);
+}
+
+[data-re-link-selector-input]::placeholder {
+ color: var(--re-text-muted);
+}
+
+[data-re-link-selector-apply] {
+ color: var(--re-text);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-link-selector-apply]:hover {
+ background: var(--re-hover);
+}
+
+[data-re-link-selector-unlink] {
+ color: var(--re-danger);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-link-selector-unlink]:hover {
+ background: var(--re-danger-hover);
+}
+
+/* ----------------------------------------------------------------
+ * Link Bubble Menu Form
+ * ---------------------------------------------------------------- */
+
+[data-re-link-bm-form] {
+ background: var(--re-bg);
+ padding: 0.25rem;
+}
+
+[data-re-link-bm-input] {
+ color: var(--re-text);
+}
+
+[data-re-link-bm-input]::placeholder {
+ color: var(--re-text-muted);
+}
+
+[data-re-link-bm-apply] {
+ color: var(--re-text);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-link-bm-apply]:hover {
+ background: var(--re-hover);
+}
+
+[data-re-link-bm-unlink] {
+ color: var(--re-danger);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-link-bm-unlink]:hover {
+ background: var(--re-danger-hover);
+}
+
+/* ----------------------------------------------------------------
+ * Button Bubble Menu Form
+ * ---------------------------------------------------------------- */
+
+[data-re-btn-bm-form] {
+ background: var(--re-bg);
+ padding: 0.25rem;
+}
+
+[data-re-btn-bm-input] {
+ color: var(--re-text);
+}
+
+[data-re-btn-bm-input]::placeholder {
+ color: var(--re-text-muted);
+}
+
+[data-re-btn-bm-apply] {
+ color: var(--re-text);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-btn-bm-apply]:hover {
+ background: var(--re-hover);
+}
+
+[data-re-btn-bm-unlink] {
+ color: var(--re-danger);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-btn-bm-unlink]:hover {
+ background: var(--re-danger-hover);
+}
+
+/* ----------------------------------------------------------------
+ * Slash Command
+ * ---------------------------------------------------------------- */
+
+[data-re-slash-command] {
+ background: var(--re-bg);
+ border: 1px solid var(--re-border);
+ border-radius: var(--re-radius);
+ box-shadow: var(--re-shadow);
+ font-family: system-ui, -apple-system, sans-serif;
+}
+
+[data-re-slash-command-item] {
+ color: var(--re-text);
+ border-radius: var(--re-radius-sm);
+ transition: background-color 0.15s;
+}
+
+[data-re-slash-command-item]:hover {
+ background: var(--re-hover);
+}
+
+[data-re-slash-command-item][data-selected] {
+ background: var(--re-hover);
+}
+
+[data-re-slash-command-item]:active {
+ background: var(--re-active);
+}
+
+[data-re-slash-command-item] svg {
+ color: var(--re-text-muted);
+}
+
+[data-re-slash-command-category] {
+ color: var(--re-text-muted);
+}
+
+[data-re-slash-command-empty] {
+ color: var(--re-text-muted);
+}
+
+/* ----------------------------------------------------------------
+ * Editor content — alignment attribute
+ * ---------------------------------------------------------------- */
+
+.tiptap [alignment="left"] {
+ text-align: left;
+}
+
+.tiptap [alignment="center"] {
+ text-align: center;
+}
+
+.tiptap [alignment="right"] {
+ text-align: right;
+}
+
+.tiptap [alignment="justify"] {
+ text-align: justify;
+}
+
+/* ----------------------------------------------------------------
+ * Editor content — columns
+ * ---------------------------------------------------------------- */
+
+.tiptap .node-columns {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.tiptap .node-column {
+ flex: 1;
+ min-width: 0;
+}
+
+/* ----------------------------------------------------------------
+ * Editor content — base typography
+ * ---------------------------------------------------------------- */
+
+.tiptap {
+ outline: none;
+ color: var(--re-text);
+}
+
+.tiptap p {
+ margin: 0.25em 0;
+}
+
+.tiptap h1,
+.tiptap h2,
+.tiptap h3 {
+ margin: 0.5em 0 0.25em;
+ font-weight: 700;
+}
+
+.tiptap h1 {
+ font-size: 2em;
+}
+
+.tiptap h2 {
+ font-size: 1.5em;
+}
+
+.tiptap h3 {
+ font-size: 1.17em;
+}
+
+.tiptap a:not(.node-button) {
+ color: #2563eb;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ cursor: text;
+}
+
+.tiptap .node-button {
+ display: inline-block;
+ padding: 0.625em 1.25em;
+ background-color: #000;
+ color: #fff;
+ border-radius: 0.375em;
+ font-weight: 500;
+ font-size: 0.875em;
+ text-decoration: none;
+ text-align: center;
+ cursor: text;
+}
+
+.tiptap blockquote {
+ border-left: 3px solid var(--re-border);
+ margin: 0.5em 0;
+ padding-left: 1em;
+ color: var(--re-text-muted);
+}
+
+.tiptap hr {
+ border: none;
+ border-top: 1px solid var(--re-border);
+ margin: 1em 0;
+}
+
+.tiptap code {
+ background: var(--re-hover);
+ border-radius: 0.25rem;
+ padding: 0.125rem 0.375rem;
+ font-size: 0.875em;
+}
+
+.tiptap pre {
+ background: var(--re-hover);
+ border-radius: var(--re-radius-sm);
+ padding: 0.75rem 1rem;
+ overflow-x: auto;
+}
+
+.tiptap pre code {
+ background: none;
+ padding: 0;
+ border-radius: 0;
+}
+
+.tiptap ul {
+ list-style-type: disc;
+ padding-left: 1.5em;
+ margin: 0.25em 0;
+}
+
+.tiptap ol {
+ list-style-type: decimal;
+ padding-left: 1.5em;
+ margin: 0.25em 0;
+}
+
+.tiptap .node-placeholder::before {
+ color: var(--re-text-muted);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+}
diff --git a/packages/editor/src/utils/attribute-helpers.ts b/packages/editor/src/utils/attribute-helpers.ts
new file mode 100644
index 0000000000..c8364aca42
--- /dev/null
+++ b/packages/editor/src/utils/attribute-helpers.ts
@@ -0,0 +1,89 @@
+/**
+ * Creates TipTap attribute definitions for a list of HTML attributes.
+ * Each attribute will have the same pattern:
+ * - default: null
+ * - parseHTML: extracts the attribute from the element
+ * - renderHTML: conditionally renders the attribute if it has a value
+ *
+ * @param attributeNames - Array of HTML attribute names to create definitions for
+ * @returns Object with TipTap attribute definitions
+ *
+ * @example
+ * const attrs = createStandardAttributes(['class', 'id', 'title']);
+ * // Returns:
+ * // {
+ * // class: {
+ * // default: null,
+ * // parseHTML: (element) => element.getAttribute('class'),
+ * // renderHTML: (attributes) => attributes.class ? { class: attributes.class } : {}
+ * // },
+ * // ...
+ * // }
+ */
+export function createStandardAttributes(attributeNames: readonly string[]) {
+ return Object.fromEntries(
+ attributeNames.map((attr) => [
+ attr,
+ {
+ default: null,
+ parseHTML: (element: HTMLElement) => element.getAttribute(attr),
+ renderHTML: (attributes: Record) => {
+ if (!attributes[attr]) {
+ return {};
+ }
+
+ return {
+ [attr]: attributes[attr],
+ };
+ },
+ },
+ ]),
+ );
+}
+
+/**
+ * Common HTML attributes used across multiple extensions.
+ * These preserve attributes during HTML import and editing for better
+ * fidelity when importing existing email templates.
+ */
+export const COMMON_HTML_ATTRIBUTES = [
+ 'id',
+ 'class',
+ 'title',
+ 'lang',
+ 'dir',
+ 'data-id',
+] as const;
+
+/**
+ * Layout-specific HTML attributes used for positioning and sizing.
+ */
+export const LAYOUT_ATTRIBUTES = ['align', 'width', 'height'] as const;
+
+/**
+ * Table-specific HTML attributes used for table layout and styling.
+ */
+export const TABLE_ATTRIBUTES = [
+ 'border',
+ 'cellpadding',
+ 'cellspacing',
+] as const;
+
+/**
+ * Table cell-specific HTML attributes.
+ */
+export const TABLE_CELL_ATTRIBUTES = [
+ 'valign',
+ 'bgcolor',
+ 'colspan',
+ 'rowspan',
+] as const;
+
+/**
+ * Table header cell-specific HTML attributes.
+ * These are additional attributes that only apply to elements.
+ */
+export const TABLE_HEADER_ATTRIBUTES = [
+ ...TABLE_CELL_ATTRIBUTES,
+ 'scope',
+] as const;
diff --git a/packages/editor/src/utils/default-styles.ts b/packages/editor/src/utils/default-styles.ts
new file mode 100644
index 0000000000..43cae12661
--- /dev/null
+++ b/packages/editor/src/utils/default-styles.ts
@@ -0,0 +1,100 @@
+import type { CssJs } from './types';
+
+export const DEFAULT_STYLES: CssJs = {
+ reset: {
+ margin: '0',
+ padding: '0',
+ },
+ body: {
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
+ fontSize: '16px',
+ minHeight: '100%',
+ lineHeight: '155%',
+ },
+ container: {},
+ h1: {
+ fontSize: '2.25em',
+ lineHeight: '1.44em',
+ paddingTop: '0.389em',
+ fontWeight: 600,
+ },
+ h2: {
+ fontSize: '1.8em',
+ lineHeight: '1.44em',
+ paddingTop: '0.389em',
+ fontWeight: 600,
+ },
+ h3: {
+ fontSize: '1.4em',
+ lineHeight: '1.08em',
+ paddingTop: '0.389em',
+ fontWeight: 600,
+ },
+ paragraph: {
+ fontSize: '1em',
+ paddingTop: '0.5em',
+ paddingBottom: '0.5em',
+ },
+ list: {
+ paddingLeft: '1.1em',
+ paddingBottom: '1em',
+ },
+ nestedList: {
+ paddingLeft: '1.1em',
+ paddingBottom: '0',
+ },
+ listItem: {
+ marginLeft: '1em',
+ marginBottom: '0.3em',
+ marginTop: '0.3em',
+ },
+ listParagraph: { padding: '0', margin: '0' },
+ blockquote: {
+ borderLeft: '3px solid #acb3be',
+ color: '#7e8a9a',
+ marginLeft: 0,
+ paddingLeft: '0.8em',
+ fontSize: '1.1em',
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
+ },
+ link: { textDecoration: 'underline' },
+ footer: {
+ fontSize: '0.8em',
+ },
+ hr: {
+ paddingBottom: '1em',
+ borderWidth: '2px',
+ },
+ image: {
+ maxWidth: '100%',
+ },
+ button: {
+ lineHeight: '100%',
+ display: 'inline-block',
+ },
+ inlineCode: {
+ paddingTop: '0.25em',
+ paddingBottom: '0.25em',
+ paddingLeft: '0.4em',
+ paddingRight: '0.4em',
+ background: '#e5e7eb',
+ color: '#1e293b',
+ borderRadius: '4px',
+ },
+ codeBlock: {
+ fontFamily: 'monospace',
+ fontWeight: '500',
+ fontSize: '.92em',
+ },
+ codeTag: {
+ lineHeight: '130%',
+ fontFamily: 'monospace',
+ fontSize: '.92em',
+ },
+ section: {
+ padding: '10px 20px 10px 20px',
+ boxSizing: 'border-box' as const,
+ },
+} as CssJs;
diff --git a/packages/editor/src/utils/get-text-alignment.ts b/packages/editor/src/utils/get-text-alignment.ts
new file mode 100644
index 0000000000..34e76fa923
--- /dev/null
+++ b/packages/editor/src/utils/get-text-alignment.ts
@@ -0,0 +1,12 @@
+export function getTextAlignment(alignment: string | undefined) {
+ switch (alignment) {
+ case 'left':
+ return { textAlign: 'left' } as const;
+ case 'center':
+ return { textAlign: 'center' } as const;
+ case 'right':
+ return { textAlign: 'right' } as const;
+ default:
+ return {};
+ }
+}
diff --git a/packages/editor/src/utils/index.ts b/packages/editor/src/utils/index.ts
new file mode 100644
index 0000000000..eabb2e64dc
--- /dev/null
+++ b/packages/editor/src/utils/index.ts
@@ -0,0 +1 @@
+export { setTextAlignment } from './set-text-alignment';
diff --git a/packages/editor/src/utils/paste-sanitizer.ts b/packages/editor/src/utils/paste-sanitizer.ts
new file mode 100644
index 0000000000..6e7ef90127
--- /dev/null
+++ b/packages/editor/src/utils/paste-sanitizer.ts
@@ -0,0 +1,76 @@
+/**
+ * Sanitizes pasted HTML.
+ * - From editor (has node-* classes): pass through as-is
+ * - From external: strip all styles/classes, keep only semantic HTML
+ */
+
+/**
+ * Detects content from the Resend editor by checking for node-* class names.
+ */
+const EDITOR_CLASS_PATTERN = /class="[^"]*node-/;
+
+/**
+ * Attributes to preserve on specific elements for EXTERNAL content.
+ * Only functional attributes - NO style or class.
+ */
+const PRESERVED_ATTRIBUTES: Record = {
+ a: ['href', 'target', 'rel'],
+ img: ['src', 'alt', 'width', 'height'],
+ td: ['colspan', 'rowspan'],
+ th: ['colspan', 'rowspan', 'scope'],
+ table: ['border', 'cellpadding', 'cellspacing'],
+ '*': ['id'],
+};
+
+function isFromEditor(html: string): boolean {
+ return EDITOR_CLASS_PATTERN.test(html);
+}
+
+export function sanitizePastedHtml(html: string): string {
+ if (isFromEditor(html)) {
+ return html;
+ }
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+
+ sanitizeNode(doc.body);
+
+ return doc.body.innerHTML;
+}
+
+function sanitizeNode(node: Node): void {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ const el = node as HTMLElement;
+ sanitizeElement(el);
+ }
+
+ for (const child of Array.from(node.childNodes)) {
+ sanitizeNode(child);
+ }
+}
+
+function sanitizeElement(el: HTMLElement): void {
+ const tagName = el.tagName.toLowerCase();
+
+ const allowedForTag = PRESERVED_ATTRIBUTES[tagName] || [];
+ const allowedGlobal = PRESERVED_ATTRIBUTES['*'] || [];
+ const allowed = new Set([...allowedForTag, ...allowedGlobal]);
+
+ const attributesToRemove: string[] = [];
+
+ for (const attr of Array.from(el.attributes)) {
+ if (attr.name.startsWith('data-')) {
+ attributesToRemove.push(attr.name);
+ continue;
+ }
+
+ if (!allowed.has(attr.name)) {
+ attributesToRemove.push(attr.name);
+ }
+ }
+
+ for (const attr of attributesToRemove) {
+ el.removeAttribute(attr);
+ }
+}
diff --git a/packages/editor/src/utils/prism-utils.ts b/packages/editor/src/utils/prism-utils.ts
new file mode 100644
index 0000000000..6600d604f2
--- /dev/null
+++ b/packages/editor/src/utils/prism-utils.ts
@@ -0,0 +1,30 @@
+const publicURL = '/styles/prism';
+
+export function loadPrismTheme(theme: string) {
+ // Create new link element for the new theme
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = `${publicURL}/prism-${theme}.css`;
+ link.setAttribute('data-prism-theme', ''); // Mark this link as the Prism theme
+
+ // Append the new link element to the head
+ document.head.appendChild(link);
+}
+
+export function removePrismTheme() {
+ const existingTheme = document.querySelectorAll(
+ 'link[rel="stylesheet"][data-prism-theme]',
+ );
+ if (existingTheme.length > 0) {
+ existingTheme.forEach((cssLinkTag) => {
+ cssLinkTag.remove();
+ });
+ }
+}
+
+export function hasPrismThemeLoaded(theme: string) {
+ const existingTheme = document.querySelector(
+ `link[rel="stylesheet"][data-prism-theme][href="${publicURL}/prism-${theme}.css"]`,
+ );
+ return !!existingTheme;
+}
diff --git a/packages/editor/src/utils/set-text-alignment.spec.ts b/packages/editor/src/utils/set-text-alignment.spec.ts
new file mode 100644
index 0000000000..aec37a6ec5
--- /dev/null
+++ b/packages/editor/src/utils/set-text-alignment.spec.ts
@@ -0,0 +1,114 @@
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import { AlignmentAttribute } from '../extensions/alignment-attribute';
+import { setTextAlignment } from './set-text-alignment';
+
+function createEditor(content?: Record) {
+ return new Editor({
+ extensions: [
+ StarterKit,
+ AlignmentAttribute.configure({
+ types: ['heading', 'paragraph'],
+ }),
+ ],
+ content: content ?? undefined,
+ });
+}
+
+const PARAGRAPH_DOC = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Hello world' }],
+ },
+ ],
+};
+
+const HEADING_DOC = {
+ type: 'doc',
+ content: [
+ {
+ type: 'heading',
+ attrs: { level: 1 },
+ content: [{ type: 'text', text: 'Title' }],
+ },
+ ],
+};
+
+const MULTI_BLOCK_DOC = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'First' }],
+ },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Second' }],
+ },
+ ],
+};
+
+describe('setTextAlignment', () => {
+ let editor: Editor;
+
+ afterEach(() => {
+ editor?.destroy();
+ });
+
+ it('sets alignment on paragraph with cursor inside', () => {
+ editor = createEditor(PARAGRAPH_DOC);
+ editor.commands.setTextSelection(3);
+
+ setTextAlignment(editor, 'center');
+
+ const paragraph = editor.state.doc.firstChild!;
+ expect(paragraph.attrs.alignment).toBe('center');
+ });
+
+ it('sets alignment on heading with cursor inside', () => {
+ editor = createEditor(HEADING_DOC);
+ editor.commands.setTextSelection(2);
+
+ setTextAlignment(editor, 'right');
+
+ const heading = editor.state.doc.firstChild!;
+ expect(heading.attrs.alignment).toBe('right');
+ });
+
+ it('sets alignment on all textblocks in a range selection', () => {
+ editor = createEditor(MULTI_BLOCK_DOC);
+ editor.commands.setTextSelection({ from: 2, to: 10 });
+
+ setTextAlignment(editor, 'center');
+
+ editor.state.doc.descendants((node) => {
+ if (node.isTextblock) {
+ expect(node.attrs.alignment).toBe('center');
+ }
+ });
+ });
+
+ it('preserves existing node attributes when setting alignment', () => {
+ editor = createEditor(HEADING_DOC);
+ editor.commands.setTextSelection(2);
+
+ setTextAlignment(editor, 'right');
+
+ const heading = editor.state.doc.firstChild!;
+ expect(heading.attrs.level).toBe(1);
+ expect(heading.attrs.alignment).toBe('right');
+ });
+
+ it('overrides a previous alignment value', () => {
+ editor = createEditor(PARAGRAPH_DOC);
+ editor.commands.setTextSelection(3);
+
+ setTextAlignment(editor, 'right');
+ setTextAlignment(editor, 'center');
+
+ const paragraph = editor.state.doc.firstChild!;
+ expect(paragraph.attrs.alignment).toBe('center');
+ });
+});
diff --git a/packages/editor/src/utils/set-text-alignment.ts b/packages/editor/src/utils/set-text-alignment.ts
new file mode 100644
index 0000000000..18d265b043
--- /dev/null
+++ b/packages/editor/src/utils/set-text-alignment.ts
@@ -0,0 +1,13 @@
+import type { Editor } from '@tiptap/core';
+
+export function setTextAlignment(editor: Editor, alignment: string) {
+ const { from, to } = editor.state.selection;
+ const tr = editor.state.tr;
+ editor.state.doc.nodesBetween(from, to, (node, pos) => {
+ if (node.isTextblock) {
+ const prop = 'align' in node.attrs ? 'align' : 'alignment';
+ tr.setNodeMarkup(pos, null, { ...node.attrs, [prop]: alignment });
+ }
+ });
+ editor.view.dispatch(tr);
+}
diff --git a/packages/editor/src/utils/styles.spec.ts b/packages/editor/src/utils/styles.spec.ts
new file mode 100644
index 0000000000..c6dc5ff404
--- /dev/null
+++ b/packages/editor/src/utils/styles.spec.ts
@@ -0,0 +1,872 @@
+import {
+ ensureBorderStyleFallback,
+ expandShorthandProperties,
+ inlineCssToJs,
+ jsToInlineCss,
+ resolveConflictingStyles,
+} from './styles';
+
+vi.mock('@/actions/ai', () => ({
+ uploadImageViaAI: vi.fn(),
+}));
+
+describe('resolveConflictingStyles', () => {
+ describe('basic functionality', () => {
+ it('should merge reset styles with inline styles', () => {
+ const resetStyles = { margin: '0', padding: '0' };
+ const inlineStyles = { color: 'red' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ color: 'red',
+ });
+ });
+
+ it('should allow inline styles to override expanded reset styles', () => {
+ const resetStyles = { margin: '0' };
+ const inlineStyles = { marginTop: '10px' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ });
+ });
+ });
+
+ describe('margin conflicts', () => {
+ it('should resolve margin auto centering conflict', () => {
+ const resetStyles = { margin: '0' };
+ const inlineStyles = { marginLeft: 'auto', marginRight: 'auto' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: 'auto',
+ marginBottom: '0',
+ marginLeft: 'auto',
+ });
+ });
+
+ it('should allow partial margin overrides', () => {
+ const resetStyles = { margin: '0' };
+ const inlineStyles = { marginTop: '20px', marginBottom: '20px' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '20px',
+ marginRight: '0',
+ marginBottom: '20px',
+ marginLeft: '0',
+ });
+ });
+
+ it('should handle margin with multiple values in reset', () => {
+ const resetStyles = { margin: '10px 20px' };
+ const inlineStyles = { marginLeft: 'auto' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '20px',
+ marginBottom: '10px',
+ marginLeft: 'auto',
+ });
+ });
+ });
+
+ describe('padding conflicts', () => {
+ it('should resolve padding conflicts', () => {
+ const resetStyles = { padding: '0' };
+ const inlineStyles = { paddingTop: '15px', paddingBottom: '15px' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ paddingTop: '15px',
+ paddingRight: '0',
+ paddingBottom: '15px',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should handle padding with multiple values in reset', () => {
+ const resetStyles = { padding: '5px 10px' };
+ const inlineStyles = { paddingLeft: '20px' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ paddingTop: '5px',
+ paddingRight: '10px',
+ paddingBottom: '5px',
+ paddingLeft: '20px',
+ });
+ });
+ });
+
+ describe('mixed properties', () => {
+ it('should handle both margin and padding conflicts', () => {
+ const resetStyles = { margin: '0', padding: '0' };
+ const inlineStyles = {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ paddingTop: '10px',
+ };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: 'auto',
+ marginBottom: '0',
+ marginLeft: 'auto',
+ paddingTop: '10px',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should preserve non-spacing properties from both sources', () => {
+ const resetStyles = {
+ margin: '0',
+ padding: '0',
+ fontSize: '16px',
+ };
+ const inlineStyles = {
+ marginTop: '10px',
+ color: 'blue',
+ backgroundColor: 'white',
+ };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ fontSize: '16px',
+ color: 'blue',
+ backgroundColor: 'white',
+ });
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty inline styles', () => {
+ const resetStyles = { margin: '0', padding: '0' };
+ const inlineStyles = {};
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should handle reset styles without shorthand properties', () => {
+ const resetStyles = { fontSize: '16px', lineHeight: '1.5' };
+ const inlineStyles = { color: 'red' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ fontSize: '16px',
+ lineHeight: '1.5',
+ color: 'red',
+ });
+ });
+
+ it('should handle inline styles overriding non-spacing properties', () => {
+ const resetStyles = { margin: '0', fontSize: '16px' };
+ const inlineStyles = { fontSize: '20px' };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ fontSize: '20px',
+ });
+ });
+ });
+
+ describe('real-world email scenarios', () => {
+ it('should handle centered button with margin auto', () => {
+ const resetStyles = { margin: '0', padding: '0' };
+ const inlineStyles = {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ paddingTop: '12px',
+ paddingBottom: '12px',
+ paddingLeft: '24px',
+ paddingRight: '24px',
+ };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: 'auto',
+ marginBottom: '0',
+ marginLeft: 'auto',
+ paddingTop: '12px',
+ paddingRight: '24px',
+ paddingBottom: '12px',
+ paddingLeft: '24px',
+ });
+ });
+
+ it('should handle section with custom spacing', () => {
+ const resetStyles = { margin: '0', padding: '0' };
+ const inlineStyles = {
+ paddingTop: '20px',
+ paddingBottom: '20px',
+ marginTop: '10px',
+ };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '20px',
+ paddingRight: '0',
+ paddingBottom: '20px',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should handle complex reset with multiple properties', () => {
+ const resetStyles = {
+ margin: '0',
+ padding: '0',
+ fontSize: '16px',
+ lineHeight: '1.5',
+ fontWeight: 400,
+ };
+ const inlineStyles = {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ fontSize: '18px',
+ };
+
+ const result = resolveConflictingStyles(resetStyles, inlineStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: 'auto',
+ marginBottom: '0',
+ marginLeft: 'auto',
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ fontSize: '18px',
+ lineHeight: '1.5',
+ fontWeight: 400,
+ });
+ });
+ });
+});
+
+describe('jsToInlineCss', () => {
+ it('should convert a simple style object to inline CSS', () => {
+ expect(jsToInlineCss({ color: 'red' })).toBe('color:red;');
+ });
+
+ it('should convert camelCase keys to kebab-case', () => {
+ expect(jsToInlineCss({ backgroundColor: 'blue' })).toBe(
+ 'background-color:blue;',
+ );
+ expect(jsToInlineCss({ fontSize: '16px' })).toBe('font-size:16px;');
+ expect(jsToInlineCss({ borderTopLeftRadius: '4px' })).toBe(
+ 'border-top-left-radius:4px;',
+ );
+ });
+
+ it('should join multiple properties with semicolons', () => {
+ expect(jsToInlineCss({ color: 'red', fontSize: '16px' })).toBe(
+ 'color:red;font-size:16px;',
+ );
+ });
+
+ it('should return an empty string for an empty object', () => {
+ expect(jsToInlineCss({})).toBe('');
+ });
+
+ it('should filter out undefined values', () => {
+ expect(jsToInlineCss({ color: undefined })).toBe('');
+ });
+
+ it('should filter out null values', () => {
+ expect(jsToInlineCss({ color: null })).toBe('');
+ });
+
+ it('should filter out empty string values', () => {
+ expect(jsToInlineCss({ color: '' })).toBe('');
+ });
+
+ // Meant to avoid https://github.com/resend/resend/pull/8030#discussion_r2789386279, but we're unsure if this is actually bug
+ // it('should keep 0 as a valid CSS value', () => {
+ // expect(jsToInlineCss({ margin: 0 })).toBe('margin:0;');
+ // expect(jsToInlineCss({ padding: 0 })).toBe('padding:0;');
+ // expect(jsToInlineCss({ border: 0 })).toBe('border:0;');
+ // expect(jsToInlineCss({ opacity: 0 })).toBe('opacity:0;');
+ // expect(jsToInlineCss({ top: 0 })).toBe('top:0;');
+ // expect(jsToInlineCss({ left: 0 })).toBe('left:0;');
+ // expect(jsToInlineCss({ borderRadius: 0 })).toBe('border-radius:0;');
+ // expect(jsToInlineCss({ fontSize: 0 })).toBe('font-size:0;');
+ // });
+
+ it('should keep non-zero numeric values', () => {
+ expect(jsToInlineCss({ lineHeight: 1.5 })).toBe('line-height:1.5;');
+ expect(jsToInlineCss({ zIndex: 10 })).toBe('z-index:10;');
+ });
+
+ it('should handle a mix of valid and filtered-out values', () => {
+ expect(
+ jsToInlineCss({
+ color: 'red',
+ margin: 0,
+ padding: undefined,
+ fontSize: '14px',
+ border: null,
+ display: '',
+ }),
+ ).toBe('color:red;font-size:14px;');
+ });
+
+ it('should handle keys that are already lowercase', () => {
+ expect(jsToInlineCss({ display: 'flex', color: 'green' })).toBe(
+ 'display:flex;color:green;',
+ );
+ });
+});
+
+describe('inlineCssToJs', () => {
+ it('should return an empty object for an empty string', () => {
+ expect(inlineCssToJs('')).toEqual({});
+ });
+
+ it('should return an empty object for undefined input', () => {
+ expect(inlineCssToJs(undefined as unknown as string)).toEqual({});
+ });
+
+ it('should return an empty object for an object input', () => {
+ expect(inlineCssToJs({} as unknown as string)).toEqual({});
+ });
+
+ it('should parse a single CSS property', () => {
+ expect(inlineCssToJs('color: red')).toEqual({ color: 'red' });
+ });
+
+ it('should parse multiple CSS properties', () => {
+ expect(inlineCssToJs('color: red; font-size: 16px')).toEqual({
+ color: 'red',
+ fontSize: '16px',
+ });
+ });
+
+ it('should handle a trailing semicolon', () => {
+ expect(inlineCssToJs('color: red;')).toEqual({ color: 'red' });
+ });
+
+ it('should convert kebab-case to camelCase', () => {
+ expect(inlineCssToJs('background-color: blue')).toEqual({
+ backgroundColor: 'blue',
+ });
+ });
+
+ it('should convert multi-hyphen properties to camelCase', () => {
+ expect(inlineCssToJs('border-top-left-radius: 4px')).toEqual({
+ borderTopLeftRadius: '4px',
+ });
+ });
+
+ it('should trim whitespace around keys and values', () => {
+ expect(inlineCssToJs(' color : red ; font-size : 14px ')).toEqual({
+ color: 'red',
+ fontSize: '14px',
+ });
+ });
+
+ it('should skip properties with no value', () => {
+ expect(inlineCssToJs('color:')).toEqual({});
+ });
+
+ it('should skip properties with only whitespace as value', () => {
+ expect(inlineCssToJs('color: ')).toEqual({});
+ });
+
+ describe('removeUnit option', () => {
+ it('should remove px units when removeUnit is true', () => {
+ expect(inlineCssToJs('width: 100px', { removeUnit: true })).toEqual({
+ width: '100',
+ });
+ });
+
+ it('should remove % units when removeUnit is true', () => {
+ expect(inlineCssToJs('width: 50%', { removeUnit: true })).toEqual({
+ width: '50',
+ });
+ });
+
+ it('should remove multiple units in one value when removeUnit is true', () => {
+ expect(inlineCssToJs('margin: 10px 20px', { removeUnit: true })).toEqual({
+ margin: '10 20',
+ });
+ });
+
+ it('should not remove units when removeUnit is false', () => {
+ expect(inlineCssToJs('width: 100px', { removeUnit: false })).toEqual({
+ width: '100px',
+ });
+ });
+
+ it('should not remove units by default', () => {
+ expect(inlineCssToJs('width: 100px')).toEqual({
+ width: '100px',
+ });
+ });
+ });
+});
+
+describe('expandShorthandProperties', () => {
+ describe('margin shorthand expansion', () => {
+ it('should expand single value margin to all sides', () => {
+ const result = expandShorthandProperties({ margin: '0' });
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ });
+ });
+
+ it('should expand two value margin (vertical horizontal)', () => {
+ const result = expandShorthandProperties({ margin: '10px 20px' });
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '20px',
+ marginBottom: '10px',
+ marginLeft: '20px',
+ });
+ });
+
+ it('should expand three value margin (top horizontal bottom)', () => {
+ const result = expandShorthandProperties({ margin: '10px 20px 30px' });
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '20px',
+ marginBottom: '30px',
+ marginLeft: '20px',
+ });
+ });
+
+ it('should expand four value margin (top right bottom left)', () => {
+ const result = expandShorthandProperties({
+ margin: '10px 20px 30px 40px',
+ });
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '20px',
+ marginBottom: '30px',
+ marginLeft: '40px',
+ });
+ });
+
+ it('should handle margin with auto values', () => {
+ const result = expandShorthandProperties({ margin: '0 auto' });
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: 'auto',
+ marginBottom: '0',
+ marginLeft: 'auto',
+ });
+ });
+
+ it('should handle numeric margin values', () => {
+ const result = expandShorthandProperties({ margin: '0' });
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ });
+ });
+ });
+
+ describe('padding shorthand expansion', () => {
+ it('should expand single value padding to all sides', () => {
+ const result = expandShorthandProperties({ padding: '0' });
+ expect(result).toEqual({
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should expand two value padding (vertical horizontal)', () => {
+ const result = expandShorthandProperties({ padding: '10px 20px' });
+ expect(result).toEqual({
+ paddingTop: '10px',
+ paddingRight: '20px',
+ paddingBottom: '10px',
+ paddingLeft: '20px',
+ });
+ });
+
+ it('should expand three value padding (top horizontal bottom)', () => {
+ const result = expandShorthandProperties({ padding: '10px 20px 30px' });
+ expect(result).toEqual({
+ paddingTop: '10px',
+ paddingRight: '20px',
+ paddingBottom: '30px',
+ paddingLeft: '20px',
+ });
+ });
+
+ it('should expand four value padding (top right bottom left)', () => {
+ const result = expandShorthandProperties({
+ padding: '10px 20px 30px 40px',
+ });
+ expect(result).toEqual({
+ paddingTop: '10px',
+ paddingRight: '20px',
+ paddingBottom: '30px',
+ paddingLeft: '40px',
+ });
+ });
+
+ it('should handle numeric padding values', () => {
+ const result = expandShorthandProperties({ padding: '0' });
+ expect(result).toEqual({
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ });
+ });
+ });
+
+ describe('border radius longhand handling', () => {
+ it('should preserve a single border radius corner', () => {
+ const result = expandShorthandProperties({
+ borderTopLeftRadius: '8px',
+ });
+ expect(result).toEqual({
+ borderTopLeftRadius: '8px',
+ });
+ });
+
+ it('should preserve two different border radius corners', () => {
+ const result = expandShorthandProperties({
+ borderTopLeftRadius: '8px',
+ borderTopRightRadius: '4px',
+ });
+ expect(result).toEqual({
+ borderTopLeftRadius: '8px',
+ borderTopRightRadius: '4px',
+ });
+ });
+
+ it('should preserve three border radius corners without collapsing', () => {
+ const result = expandShorthandProperties({
+ borderTopLeftRadius: '8px',
+ borderTopRightRadius: '8px',
+ borderBottomLeftRadius: '8px',
+ });
+ expect(result).toEqual({
+ borderTopLeftRadius: '8px',
+ borderTopRightRadius: '8px',
+ borderBottomLeftRadius: '8px',
+ });
+ });
+
+ it('should preserve all four corners when they differ', () => {
+ const result = expandShorthandProperties({
+ borderTopLeftRadius: '4px',
+ borderTopRightRadius: '8px',
+ borderBottomLeftRadius: '12px',
+ borderBottomRightRadius: '16px',
+ });
+ expect(result).toEqual({
+ borderTopLeftRadius: '4px',
+ borderTopRightRadius: '8px',
+ borderBottomLeftRadius: '12px',
+ borderBottomRightRadius: '16px',
+ });
+ });
+
+ it('should add borderRadius shorthand when all four corners are identical', () => {
+ const result = expandShorthandProperties({
+ borderTopLeftRadius: '8px',
+ borderTopRightRadius: '8px',
+ borderBottomLeftRadius: '8px',
+ borderBottomRightRadius: '8px',
+ });
+ expect(result).toEqual({
+ borderTopLeftRadius: '8px',
+ borderTopRightRadius: '8px',
+ borderBottomLeftRadius: '8px',
+ borderBottomRightRadius: '8px',
+ borderRadius: '8px',
+ });
+ });
+
+ it('should preserve border radius corners alongside other properties', () => {
+ const result = expandShorthandProperties({
+ borderTopLeftRadius: '8px',
+ borderBottomRightRadius: '4px',
+ margin: '0',
+ color: 'red',
+ });
+ expect(result).toEqual({
+ borderTopLeftRadius: '8px',
+ borderBottomRightRadius: '4px',
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ color: 'red',
+ });
+ });
+ });
+
+ describe('mixed properties', () => {
+ it('should expand both margin and padding', () => {
+ const result = expandShorthandProperties({
+ margin: '0',
+ padding: '10px 20px',
+ });
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '10px',
+ paddingRight: '20px',
+ paddingBottom: '10px',
+ paddingLeft: '20px',
+ });
+ });
+
+ it('should preserve longhand properties alongside shorthand', () => {
+ const result = expandShorthandProperties({
+ margin: '0',
+ paddingTop: '10px',
+ color: 'red',
+ });
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '10px',
+ color: 'red',
+ });
+ });
+
+ it('should preserve non-spacing properties', () => {
+ const result = expandShorthandProperties({
+ margin: '0',
+ fontSize: '16px',
+ color: 'blue',
+ backgroundColor: 'white',
+ });
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ fontSize: '16px',
+ color: 'blue',
+ backgroundColor: 'white',
+ });
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty object', () => {
+ const result = expandShorthandProperties({});
+ expect(result).toEqual({});
+ });
+
+ it('should handle null input', () => {
+ const result = expandShorthandProperties(null as any);
+ expect(result).toEqual({});
+ });
+
+ it('should handle undefined input', () => {
+ const result = expandShorthandProperties(undefined as any);
+ expect(result).toEqual({});
+ });
+
+ it('should skip undefined values', () => {
+ const result = expandShorthandProperties({
+ margin: undefined as any,
+ padding: '10px',
+ });
+ expect(result).toEqual({
+ paddingTop: '10px',
+ paddingRight: '10px',
+ paddingBottom: '10px',
+ paddingLeft: '10px',
+ });
+ });
+
+ it('should skip null values', () => {
+ const result = expandShorthandProperties({
+ margin: null as any,
+ padding: '10px',
+ });
+ expect(result).toEqual({
+ paddingTop: '10px',
+ paddingRight: '10px',
+ paddingBottom: '10px',
+ paddingLeft: '10px',
+ });
+ });
+
+ it('should handle extra whitespace in shorthand values', () => {
+ const result = expandShorthandProperties({ margin: ' 10px 20px ' });
+ expect(result).toEqual({
+ marginTop: '10px',
+ marginRight: '20px',
+ marginBottom: '10px',
+ marginLeft: '20px',
+ });
+ });
+ });
+
+ describe('real-world scenarios', () => {
+ it('should handle the margin auto centering case', () => {
+ const resetStyles = { margin: '0', padding: '0' };
+ const inlineStyles = { marginLeft: 'auto', marginRight: 'auto' };
+
+ const expandedReset = expandShorthandProperties(resetStyles);
+ const merged = { ...expandedReset, ...inlineStyles };
+
+ expect(merged).toEqual({
+ marginTop: '0',
+ marginRight: 'auto',
+ marginBottom: '0',
+ marginLeft: 'auto',
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should allow specific padding overrides', () => {
+ const resetStyles = { padding: '0' };
+ const inlineStyles = { paddingTop: '20px', paddingBottom: '20px' };
+
+ const expandedReset = expandShorthandProperties(resetStyles);
+ const merged = { ...expandedReset, ...inlineStyles };
+
+ expect(merged).toEqual({
+ paddingTop: '20px',
+ paddingRight: '0',
+ paddingBottom: '20px',
+ paddingLeft: '0',
+ });
+ });
+
+ it('should handle complex reset styles with multiple properties', () => {
+ const resetStyles = {
+ margin: '0',
+ padding: '0',
+ fontSize: '16px',
+ lineHeight: '1.5',
+ };
+
+ const result = expandShorthandProperties(resetStyles);
+
+ expect(result).toEqual({
+ marginTop: '0',
+ marginRight: '0',
+ marginBottom: '0',
+ marginLeft: '0',
+ paddingTop: '0',
+ paddingRight: '0',
+ paddingBottom: '0',
+ paddingLeft: '0',
+ fontSize: '16px',
+ lineHeight: '1.5',
+ });
+ });
+ });
+});
+
+describe('ensureBorderStyleFallback', () => {
+ it('adds solid when border width is set without any style', () => {
+ const result = ensureBorderStyleFallback({
+ borderTopWidth: '1px',
+ });
+
+ expect(result).toEqual({
+ borderTopWidth: '1px',
+ borderTopStyle: 'solid',
+ });
+ });
+
+ it('does not override shorthand borderStyle for side-specific widths', () => {
+ const result = ensureBorderStyleFallback({
+ borderStyle: 'dashed',
+ borderTopWidth: '1px',
+ });
+
+ expect(result).toEqual({
+ borderStyle: 'dashed',
+ borderTopWidth: '1px',
+ });
+ });
+
+ it('keeps explicit side style unchanged', () => {
+ const result = ensureBorderStyleFallback({
+ borderTopWidth: '1px',
+ borderTopStyle: 'dotted',
+ });
+
+ expect(result).toEqual({
+ borderTopWidth: '1px',
+ borderTopStyle: 'dotted',
+ });
+ });
+});
diff --git a/packages/editor/src/utils/styles.ts b/packages/editor/src/utils/styles.ts
new file mode 100644
index 0000000000..563c13d380
--- /dev/null
+++ b/packages/editor/src/utils/styles.ts
@@ -0,0 +1,286 @@
+import type { CssJs } from './types';
+
+const WHITE_SPACE_REGEX = /\s+/;
+
+const BORDER_WIDTH_TO_STYLE: Record = {
+ borderWidth: 'borderStyle',
+ borderTopWidth: 'borderTopStyle',
+ borderRightWidth: 'borderRightStyle',
+ borderBottomWidth: 'borderBottomStyle',
+ borderLeftWidth: 'borderLeftStyle',
+};
+
+export const jsToInlineCss = (styleObject: { [key: string]: any }) => {
+ const parts: string[] = [];
+
+ for (const key in styleObject) {
+ const value = styleObject[key];
+ if (value !== 0 && value !== undefined && value !== null && value !== '') {
+ const KEBAB_CASE_REGEX = /[A-Z]/g;
+ const formattedKey = key.replace(
+ KEBAB_CASE_REGEX,
+ (match) => `-${match.toLowerCase()}`,
+ );
+ parts.push(`${formattedKey}:${value}`);
+ }
+ }
+
+ return parts.join(';') + (parts.length ? ';' : '');
+};
+
+export const inlineCssToJs = (
+ inlineStyle: string,
+ options: { removeUnit?: boolean } = {},
+) => {
+ const styleObject: { [key: string]: string } = {};
+
+ if (!inlineStyle || inlineStyle === '' || typeof inlineStyle === 'object') {
+ return styleObject;
+ }
+
+ inlineStyle.split(';').forEach((style: string) => {
+ if (style.trim()) {
+ const [key, value] = style.split(':');
+ const valueTrimmed = value?.trim();
+
+ if (!valueTrimmed) {
+ return;
+ }
+
+ const formattedKey = key
+ .trim()
+ .replace(/-\w/g, (match) => match[1].toUpperCase());
+
+ const UNIT_REGEX = /px|%/g;
+ const sanitizedValue = options?.removeUnit
+ ? valueTrimmed.replace(UNIT_REGEX, '')
+ : valueTrimmed;
+
+ styleObject[formattedKey] = sanitizedValue;
+ }
+ });
+
+ return styleObject;
+};
+
+/**
+ * Expands CSS shorthand properties (margin, padding) into their longhand equivalents.
+ * This prevents shorthand properties from overriding specific longhand properties in email clients.
+ *
+ * @param styles - Style object that may contain shorthand properties
+ * @returns New style object with shorthand properties expanded to longhand
+ *
+ * @example
+ * expandShorthandProperties({ margin: '0', paddingTop: '10px' })
+ * // Returns: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', paddingTop: '10px' }
+ */
+export function expandShorthandProperties(
+ styles: Record,
+): Record {
+ if (!styles || typeof styles !== 'object') {
+ return {};
+ }
+
+ const expanded: Record = {};
+
+ for (const key in styles) {
+ const value = styles[key];
+ if (value === undefined || value === null || value === '') {
+ continue;
+ }
+
+ switch (key) {
+ case 'margin': {
+ const values = parseShorthandValue(value);
+ expanded.marginTop = values.top;
+ expanded.marginRight = values.right;
+ expanded.marginBottom = values.bottom;
+ expanded.marginLeft = values.left;
+ break;
+ }
+ case 'padding': {
+ const values = parseShorthandValue(value);
+ expanded.paddingTop = values.top;
+ expanded.paddingRight = values.right;
+ expanded.paddingBottom = values.bottom;
+ expanded.paddingLeft = values.left;
+ break;
+ }
+ case 'border': {
+ const values = convertBorderValue(value);
+ expanded.borderStyle = values.style;
+ expanded.borderWidth = values.width;
+ expanded.borderColor = values.color;
+ break;
+ }
+ case 'borderTopLeftRadius':
+ case 'borderTopRightRadius':
+ case 'borderBottomLeftRadius':
+ case 'borderBottomRightRadius': {
+ // Always preserve the longhand property
+ expanded[key] = value;
+
+ // When all four corners are present and identical, also add the shorthand
+ if (
+ styles.borderTopLeftRadius &&
+ styles.borderTopRightRadius &&
+ styles.borderBottomLeftRadius &&
+ styles.borderBottomRightRadius
+ ) {
+ const values = [
+ styles.borderTopLeftRadius,
+ styles.borderTopRightRadius,
+ styles.borderBottomLeftRadius,
+ styles.borderBottomRightRadius,
+ ];
+
+ if (new Set(values).size === 1) {
+ expanded.borderRadius = values[0];
+ }
+ }
+
+ break;
+ }
+
+ default: {
+ // Keep all other properties as-is
+ expanded[key] = value;
+ }
+ }
+ }
+
+ return expanded;
+}
+
+/**
+ * Parses CSS shorthand value (1-4 values) into individual side values.
+ * Follows CSS specification for shorthand property value parsing.
+ *
+ * @param value - Shorthand value string (e.g., '0', '10px 20px', '5px 10px 15px 20px')
+ * @returns Object with top, right, bottom, left values
+ */
+function parseShorthandValue(value: string | number): {
+ top: string;
+ right: string;
+ bottom: string;
+ left: string;
+} {
+ const stringValue = String(value).trim();
+ const parts = stringValue.split(WHITE_SPACE_REGEX);
+ const len = parts.length;
+
+ if (len === 1) {
+ return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
+ }
+ if (len === 2) {
+ return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
+ }
+ if (len === 3) {
+ return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
+ }
+ if (len === 4) {
+ return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
+ }
+
+ return {
+ top: stringValue,
+ right: stringValue,
+ bottom: stringValue,
+ left: stringValue,
+ };
+}
+
+function convertBorderValue(value: string | number): {
+ style: string;
+ width: string;
+ color: string;
+} {
+ const stringValue = String(value).trim();
+ const parts = stringValue.split(WHITE_SPACE_REGEX);
+
+ switch (parts.length) {
+ case 1:
+ // border: 1px → all sides
+ return {
+ style: 'solid',
+ width: parts[0],
+ color: 'black',
+ };
+ case 2:
+ // border: 1px solid → top/bottom, left/right
+ return {
+ style: parts[1],
+ width: parts[0],
+ color: 'black',
+ };
+ case 3:
+ // border: 1px solid #000 → top, left/right, bottom
+ return {
+ style: parts[1],
+ width: parts[0],
+ color: parts[2],
+ };
+ case 4:
+ // border: 1px solid #000 #fff → top, right, bottom, left
+ return {
+ style: parts[1],
+ width: parts[0],
+ color: parts[2],
+ };
+ default:
+ // Invalid format, return the original value for all sides
+ return {
+ style: 'solid',
+ width: stringValue,
+ color: 'black',
+ };
+ }
+}
+
+/**
+ * When a border-width is present but border-style is missing, browsers default
+ * to `none` and the border is invisible. This adds `solid` as a sensible
+ * fallback so borders show up immediately after setting a width.
+ */
+export function ensureBorderStyleFallback(
+ styles: Record,
+): Record {
+ for (const [widthKey, styleKey] of Object.entries(BORDER_WIDTH_TO_STYLE)) {
+ const widthValue = styles[widthKey];
+ if (!widthValue || widthValue === '0' || widthValue === '0px') {
+ continue;
+ }
+ if (!styles[styleKey]) {
+ // Keep shorthand borderStyle authoritative for each side when present.
+ if (styleKey !== 'borderStyle' && styles.borderStyle) {
+ continue;
+ }
+ styles[styleKey] = 'solid';
+ }
+ }
+ return styles;
+}
+
+/**
+ * Resolves conflicts between reset styles and inline styles by expanding
+ * shorthand properties (margin, padding) to longhand before merging.
+ * This prevents shorthand properties from overriding specific longhand properties.
+ *
+ * @param resetStyles - Base reset styles that may contain shorthand properties
+ * @param inlineStyles - Inline styles that should override reset styles
+ * @returns Merged styles with inline styles taking precedence
+ */
+export function resolveConflictingStyles(
+ resetStyles: CssJs['reset'],
+ inlineStyles: Record,
+) {
+ const expandedResetStyles = expandShorthandProperties(
+ resetStyles as Record,
+ );
+ const expandedInlineStyles = expandShorthandProperties(inlineStyles);
+
+ return {
+ ...expandedResetStyles,
+ ...expandedInlineStyles,
+ };
+}
diff --git a/packages/editor/src/utils/types.ts b/packages/editor/src/utils/types.ts
new file mode 100644
index 0000000000..ec97ce9de6
--- /dev/null
+++ b/packages/editor/src/utils/types.ts
@@ -0,0 +1,120 @@
+import type { Editor } from '@tiptap/core';
+import type { Attrs } from '@tiptap/pm/model';
+import type * as React from 'react';
+
+export type NodeClickedEvent = {
+ nodeType: string;
+ nodeAttrs: Attrs;
+ nodePos: { pos: number; inside: number };
+};
+
+type InputType = 'color' | 'number' | 'select' | 'text' | 'textarea';
+type InputUnit = 'px' | '%';
+type Options = Record;
+
+export type EditorThemes = 'basic' | 'minimal';
+export type KnownThemeComponents =
+ | 'reset'
+ | 'body'
+ | 'container'
+ | 'h1'
+ | 'h2'
+ | 'h3'
+ | 'paragraph'
+ | 'nestedList'
+ | 'list'
+ | 'listItem'
+ | 'listParagraph'
+ | 'blockquote'
+ | 'codeBlock'
+ | 'inlineCode'
+ | 'codeTag'
+ | 'link'
+ | 'footer'
+ | 'hr'
+ | 'image'
+ | 'button'
+ | 'section';
+
+export type KnownCssProperties =
+ | 'align'
+ | 'backgroundColor'
+ | 'color'
+ | 'fontSize'
+ | 'fontWeight'
+ | 'lineHeight'
+ | 'textDecoration'
+ | 'borderRadius'
+ | 'borderTopLeftRadius'
+ | 'borderTopRightRadius'
+ | 'borderBottomLeftRadius'
+ | 'borderBottomRightRadius'
+ | 'borderWidth'
+ | 'borderStyle'
+ | 'borderColor'
+ | 'padding'
+ | 'paddingTop'
+ | 'paddingRight'
+ | 'paddingBottom'
+ | 'paddingLeft'
+ | 'width'
+ | 'height';
+
+export type ResetTheme = Record;
+
+export type CssJs = {
+ [K in KnownThemeComponents]: React.CSSProperties & {
+ // TODO: remove align as soon as possible
+ align?: 'center' | 'left' | 'right';
+ };
+};
+export type SupportedCssProperties = {
+ [K in KnownCssProperties]: {
+ category: 'layout' | 'appearance' | 'typography';
+ label: string;
+ type: InputType;
+ defaultValue: string | number;
+ unit?: InputUnit;
+ options?: Options;
+ excludeNodes?: string[];
+ placeholder?: string;
+ customUpdate?: (
+ props: Record,
+ update: (func: (tree: PanelGroup[]) => PanelGroup[]) => void,
+ ) => void;
+ };
+};
+
+export interface PanelInputProperty {
+ label: string;
+ type: InputType;
+ value: string | number;
+ prop: KnownCssProperties;
+ classReference?: KnownThemeComponents;
+ unit?: InputUnit;
+ options?: Options;
+ placeholder?: string;
+ category: SupportedCssProperties[KnownCssProperties]['category'];
+}
+
+export interface PanelGroup {
+ title: string;
+ headerSlot?: React.ReactNode;
+ classReference?: KnownThemeComponents;
+ inputs: Omit[];
+}
+
+export interface ContextProperties {
+ theme: EditorThemes;
+ styles: PanelGroup[] & {
+ toCss: () => Record;
+ };
+ css: string;
+}
+
+export interface ContextValue extends ContextProperties {
+ subscribe: (
+ editor: Editor,
+ propagateChanges?: (context: ContextProperties) => void,
+ ) => void;
+}
diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json
new file mode 100644
index 0000000000..8c66cd2fe2
--- /dev/null
+++ b/packages/editor/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "tsconfig/react-library.json",
+ "compilerOptions": {
+ "moduleResolution": "nodenext",
+ "module": "nodenext",
+ "target": "es2018",
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["."],
+ "exclude": ["dist", "build", "node_modules", "examples"]
+}
diff --git a/packages/editor/tsdown.config.ts b/packages/editor/tsdown.config.ts
new file mode 100644
index 0000000000..cb457c4189
--- /dev/null
+++ b/packages/editor/tsdown.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: {
+ 'core/index': 'src/core/index.ts',
+ 'extensions/index': 'src/extensions/index.ts',
+ 'plugins/index': 'src/plugins/index.ts',
+ 'ui/index': 'src/ui/index.ts',
+ 'utils/index': 'src/utils/index.ts',
+ },
+ format: ['esm', 'cjs'],
+ dts: true,
+ external: ['react', 'react-dom'],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 572919856f..35ccf80cfb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -244,7 +244,7 @@ importers:
dependencies:
mintlify:
specifier: 4.2.423
- version: 4.2.423(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
+ version: 4.2.423(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
zod:
specifier: 'catalog:'
version: 4.1.12
@@ -629,6 +629,194 @@ importers:
specifier: 'catalog:'
version: 5.9.3
+ packages/editor:
+ dependencies:
+ '@floating-ui/react-dom':
+ specifier: ^2.1.8
+ version: 2.1.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@radix-ui/react-popover':
+ specifier: ^1.0.0
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@react-email/components':
+ specifier: workspace:*
+ version: link:../components
+ '@tiptap/core':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/extension-blockquote':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-bold':
+ specifier: ^3.20.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-bullet-list':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-code':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-code-block':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-hard-break':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-heading':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-horizontal-rule':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-italic':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-link':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-list-item':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-mention':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@tiptap/suggestion@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-ordered-list':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-paragraph':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-placeholder':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-strike':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-superscript':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-text':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-underline':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extensions':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/html':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(happy-dom@20.8.3)
+ '@tiptap/pm':
+ specifier: ^3.17.1
+ version: 3.20.1
+ '@tiptap/react':
+ specifier: ^3.17.1
+ version: 3.20.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@tiptap/starter-kit':
+ specifier: ^3.17.1
+ version: 3.20.1
+ '@tiptap/suggestion':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ hast-util-from-html:
+ specifier: ^2.0.3
+ version: 2.0.3
+ prismjs:
+ specifier: ^1.30.0
+ version: 1.30.0
+ react:
+ specifier: 19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: 19.0.0
+ version: 19.0.0(react@19.0.0)
+ devDependencies:
+ '@testing-library/react':
+ specifier: 16.0.0
+ version: 16.0.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@types/node':
+ specifier: 'catalog:'
+ version: 22.19.13
+ '@types/prismjs':
+ specifier: 1.26.5
+ version: 1.26.5
+ postcss:
+ specifier: 8.5.6
+ version: 8.5.6
+ postcss-import:
+ specifier: 16.1.1
+ version: 16.1.1(postcss@8.5.6)
+ tsconfig:
+ specifier: workspace:*
+ version: link:../tsconfig
+ tsx:
+ specifier: 'catalog:'
+ version: 4.21.0
+ typescript:
+ specifier: 5.8.3
+ version: 5.8.3
+
+ packages/editor/examples:
+ dependencies:
+ '@radix-ui/react-popover':
+ specifier: 'catalog:'
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@react-email/components':
+ specifier: workspace:*
+ version: link:../../components
+ '@react-email/editor':
+ specifier: workspace:*
+ version: link:..
+ '@tiptap/extension-heading':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-link':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-underline':
+ specifier: ^3.17.1
+ version: 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/react':
+ specifier: ^3.17.1
+ version: 3.20.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@tiptap/starter-kit':
+ specifier: ^3.17.1
+ version: 3.20.1
+ lucide-react:
+ specifier: ^0.470.0
+ version: 0.470.0(react@19.0.0)
+ react:
+ specifier: 19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: 19.0.0
+ version: 19.0.0(react@19.0.0)
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: 4.1.18
+ version: 4.1.18(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.37.0)(tsx@4.21.0)(yaml@2.6.1))
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.13
+ '@types/react-dom':
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.13)
+ '@vitejs/plugin-react':
+ specifier: 'catalog:'
+ version: 5.1.4(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.37.0)(tsx@4.21.0)(yaml@2.6.1))
+ tailwindcss:
+ specifier: 'catalog:'
+ version: 4.1.18
+ tsconfig:
+ specifier: workspace:*
+ version: link:../../tsconfig
+ typescript:
+ specifier: 5.8.3
+ version: 5.8.3
+ vite:
+ specifier: 'catalog:'
+ version: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.37.0)(tsx@4.21.0)(yaml@2.6.1)
+
packages/font:
dependencies:
react:
@@ -1833,15 +2021,30 @@ packages:
'@floating-ui/core@1.6.8':
resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
'@floating-ui/dom@1.6.12':
resolution: {integrity: sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==}
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
'@floating-ui/react-dom@2.1.2':
resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: 19.0.0
+ react-dom: 19.0.0
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
@@ -3216,6 +3419,9 @@ packages:
react-native:
optional: true
+ '@remirror/core-constants@3.0.0':
+ resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
+
'@responsive-email/react-email@0.0.4':
resolution: {integrity: sha512-oRpI+tmiHR4Ff86+cuX2fdlIN/5PdwLQL8wa+2ztypLdX/3J7MQQq9IKLt3X5tuwshtGXkotcydXDpXs8yfPQA==}
peerDependencies:
@@ -3773,6 +3979,215 @@ packages:
'@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
+ '@tailwindcss/vite@4.1.18':
+ resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7
+
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/react@16.0.0':
+ resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0
+ '@types/react-dom': ^18.0.0
+ react: 19.0.0
+ react-dom: 19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@tiptap/core@3.20.1':
+ resolution: {integrity: sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==}
+ peerDependencies:
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-blockquote@3.20.1':
+ resolution: {integrity: sha512-WzNXk/63PQI2fav4Ta6P0GmYRyu8Gap1pV3VUqaVK829iJ6Zt1T21xayATHEHWMK27VT1GLPJkx9Ycr2jfDyQw==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-bold@3.20.1':
+ resolution: {integrity: sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-bold@3.20.2':
+ resolution: {integrity: sha512-NLqh6ewHcDDPveTCL2f6BQcsDI5lubNjiyzvuYr0ZO9AV5Fqw8TkYwoKNijiYlgGRtm+pZLhMnf45gbLJQoymg==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.2
+
+ '@tiptap/extension-bubble-menu@3.20.1':
+ resolution: {integrity: sha512-XaPvO6aCoWdFnCBus0s88lnj17NR/OopV79i8Qhgz3WMR0vrsL5zsd45l0lZuu9pSvm5VW47SoxakkJiZC1suw==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-bullet-list@3.20.1':
+ resolution: {integrity: sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.20.1
+
+ '@tiptap/extension-code-block@3.20.1':
+ resolution: {integrity: sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-code@3.20.1':
+ resolution: {integrity: sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-document@3.20.1':
+ resolution: {integrity: sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-dropcursor@3.20.1':
+ resolution: {integrity: sha512-K18L9FX4znn+ViPSIbTLOGcIaXMx/gLNwAPE8wPLwswbHhQqdiY1zzdBw6drgOc1Hicvebo2dIoUlSXOZsOEcw==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.20.1
+
+ '@tiptap/extension-floating-menu@3.20.1':
+ resolution: {integrity: sha512-BeDC6nfOesIMn5pFuUnkEjOxGv80sOJ8uk1mdt9/3Fkvra8cB9NIYYCVtd6PU8oQFmJ8vFqPrRkUWrG5tbqnOg==}
+ peerDependencies:
+ '@floating-ui/dom': ^1.0.0
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-gapcursor@3.20.1':
+ resolution: {integrity: sha512-kZOtttV6Ai8VUAgEng3h4WKFbtdSNJ6ps7r0cRPY+FctWhVmgNb/JJwwyC+vSilR7nRENAhrA/Cv/RxVlvLw+g==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.20.1
+
+ '@tiptap/extension-hard-break@3.20.1':
+ resolution: {integrity: sha512-9sKpmg/IIdlLXimYWUZ3PplIRcehv4Oc7V1miTqlnAthMzjMqigDkjjgte4JZV67RdnDJTQkRw8TklCAU28Emg==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-heading@3.20.1':
+ resolution: {integrity: sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-horizontal-rule@3.20.1':
+ resolution: {integrity: sha512-rjFKFXNntdl0jay8oIGFvvykHlpyQTLmrH3Ag2fj3i8yh6MVvqhtaDomYQbw5sxECd5hBkL+T4n2d2DRuVw/QQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-italic@3.20.1':
+ resolution: {integrity: sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-link@3.20.1':
+ resolution: {integrity: sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-list-item@3.20.1':
+ resolution: {integrity: sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.20.1
+
+ '@tiptap/extension-list-keymap@3.20.1':
+ resolution: {integrity: sha512-Dr0xsQKx0XPOgDg7xqoWwfv7FFwZ3WeF3eOjqh3rDXlNHMj1v+UW5cj1HLphrsAZHTrVTn2C+VWPJkMZrSbpvQ==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.20.1
+
+ '@tiptap/extension-list@3.20.1':
+ resolution: {integrity: sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-mention@3.20.1':
+ resolution: {integrity: sha512-KOGokj7oH1QpcM8P02V+o6wHsVE0g7XEtdIy2vtq2vlFE3npNNNFkMa8F8VWX6qyC+VeVrNU6SIzS5MFY2TORA==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+ '@tiptap/suggestion': ^3.20.1
+
+ '@tiptap/extension-ordered-list@3.20.1':
+ resolution: {integrity: sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.20.1
+
+ '@tiptap/extension-paragraph@3.20.1':
+ resolution: {integrity: sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-placeholder@3.20.1':
+ resolution: {integrity: sha512-k+jfbCugYGuIFBdojukgEopGazIMOgHrw46FnyN2X/6ICOIjQP2rh2ObslrsUOsJYoEevxCsNF9hZl1HvWX66g==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.20.1
+
+ '@tiptap/extension-strike@3.20.1':
+ resolution: {integrity: sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-superscript@3.20.1':
+ resolution: {integrity: sha512-NJkIJKo7cly0AEXjpXicMY092K8yDpOxvhxDLgYAEBl0rv3Si6oNLTeF5TSK2k3tP7BpXSqvsBORY1wgXUty1g==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/extension-text@3.20.1':
+ resolution: {integrity: sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extension-underline@3.20.1':
+ resolution: {integrity: sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+
+ '@tiptap/extensions@3.20.1':
+ resolution: {integrity: sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
+ '@tiptap/html@3.20.1':
+ resolution: {integrity: sha512-vElmnCWIqIj8DIS1lIxKKlN8pQEZVNiRfh4RZ2TL4tdZDsfS29US2lbgWEeI8lZdgr5C5JuoC/X1Nv+N8tJn6A==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+ happy-dom: ^20.0.2
+
+ '@tiptap/pm@3.20.1':
+ resolution: {integrity: sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==}
+
+ '@tiptap/react@3.20.1':
+ resolution: {integrity: sha512-UH1NpVpCaZBGB3Yr5N6aTS+rsCMDl9wHfrt/w+6+Gz4KHFZ2OILA82hELxZzhNc1Lmjz8vgCArKcsYql9gbzJA==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: 19.0.0
+ react-dom: 19.0.0
+
+ '@tiptap/starter-kit@3.20.1':
+ resolution: {integrity: sha512-opqWxL/4OTEiqmVC0wsU4o3JhAf6LycJ2G/gRIZVAIFLljI9uHfpPMTFGxZ5w9IVVJaP5PJysfwW/635kKqkrw==}
+
+ '@tiptap/suggestion@3.20.1':
+ resolution: {integrity: sha512-ng7olbzgZhWvPJVJygNQK5153CjquR2eJXpkLq7bRjHlahvt4TH4tGFYvGdYZcXuzbe2g9RoqT7NaPGL9CUq9w==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.1
+ '@tiptap/pm': ^3.20.1
+
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
@@ -3785,6 +4200,9 @@ packages:
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -3854,9 +4272,18 @@ packages:
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
+ '@types/linkify-it@5.0.0':
+ resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
+
+ '@types/markdown-it@14.1.2':
+ resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+ '@types/mdurl@2.0.0':
+ resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
+
'@types/mdx@2.0.13':
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
@@ -3887,6 +4314,9 @@ packages:
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
+ '@types/prismjs@1.26.5':
+ resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
+
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
@@ -3924,6 +4354,9 @@ packages:
'@types/urijs@1.19.25':
resolution: {integrity: sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==}
+ '@types/use-sync-external-store@0.0.6':
+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
'@types/webpack@5.28.5':
resolution: {integrity: sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==}
@@ -4183,6 +4616,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
ansi-styles@6.2.1:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
@@ -4211,6 +4648,9 @@ packages:
resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==}
engines: {node: '>=10'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
arkregex@0.0.3:
resolution: {integrity: sha512-bU21QJOJEFJK+BPNgv+5bVXkvRxyAvgnon75D92newgHxkBJTgiFwQxusyViYyJkETsddPlHyspshDQcCzmkNg==}
@@ -4637,6 +5077,9 @@ packages:
typescript:
optional: true
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
@@ -4854,6 +5297,9 @@ packages:
resolution: {integrity: sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==}
engines: {node: '>=6'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -5022,6 +5468,10 @@ packages:
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
engines: {node: '>=8'}
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -5118,6 +5568,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-equals@5.4.0:
+ resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
+ engines: {node: '>=6.0.0'}
+
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
@@ -6107,6 +6561,12 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ linkify-it@5.0.0:
+ resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+
+ linkifyjs@4.3.2:
+ resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
+
loader-runner@4.3.1:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'}
@@ -6157,11 +6617,20 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
+ lucide-react@0.470.0:
+ resolution: {integrity: sha512-tqYODeoB3qU5gxH33IbL7IcF05EYYzsZQJqdM9HGGHwmoJMPvVLPHO6Plu6HNAfntucZQ41taEw6aUpwOtaoXg==}
+ peerDependencies:
+ react: 19.0.0
+
lucide-react@0.544.0:
resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==}
peerDependencies:
react: 19.0.0
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
maath@0.10.8:
resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==}
peerDependencies:
@@ -6175,6 +6644,10 @@ packages:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
+ markdown-it@14.1.1:
+ resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
+ hasBin: true
+
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -6250,6 +6723,9 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
+ mdurl@2.0.0:
+ resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -6673,6 +7149,9 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
+ orderedmap@2.1.1:
+ resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
+
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
@@ -6837,6 +7316,12 @@ packages:
peerDependencies:
postcss: ^8.0.0
+ postcss-import@16.1.1:
+ resolution: {integrity: sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
postcss-js@4.0.1:
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
engines: {node: ^12 || ^14 || >= 16}
@@ -6893,6 +7378,10 @@ packages:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0}
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies:
@@ -6925,6 +7414,64 @@ packages:
property-information@7.0.0:
resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==}
+ prosemirror-changeset@2.4.0:
+ resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
+
+ prosemirror-collab@1.3.1:
+ resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
+
+ prosemirror-commands@1.7.1:
+ resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
+
+ prosemirror-dropcursor@1.8.2:
+ resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
+
+ prosemirror-gapcursor@1.4.0:
+ resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==}
+
+ prosemirror-history@1.5.0:
+ resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
+
+ prosemirror-inputrules@1.5.1:
+ resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
+
+ prosemirror-keymap@1.2.3:
+ resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
+
+ prosemirror-markdown@1.13.4:
+ resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
+
+ prosemirror-menu@1.3.0:
+ resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
+
+ prosemirror-model@1.25.4:
+ resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
+
+ prosemirror-schema-basic@1.2.4:
+ resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
+
+ prosemirror-schema-list@1.5.1:
+ resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
+
+ prosemirror-state@1.4.4:
+ resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
+
+ prosemirror-tables@1.8.5:
+ resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
+
+ prosemirror-trailing-node@3.0.0:
+ resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
+ peerDependencies:
+ prosemirror-model: ^1.22.1
+ prosemirror-state: ^1.4.2
+ prosemirror-view: ^1.33.8
+
+ prosemirror-transform@1.11.0:
+ resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
+
+ prosemirror-view@1.41.6:
+ resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -6943,6 +7490,10 @@ packages:
pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
+ punycode.js@2.3.1:
+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+ engines: {node: '>=6'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -7007,6 +7558,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-reconciler@0.32.0:
resolution: {integrity: sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==}
engines: {node: '>=0.10.0'}
@@ -7277,6 +7831,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ rope-sequence@1.3.4:
+ resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
+
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
@@ -7948,11 +8505,19 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
+ typescript@5.8.3:
+ resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
+ uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+
ufo@1.5.4:
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
@@ -8228,6 +8793,9 @@ packages:
jsdom:
optional: true
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -8777,7 +9345,7 @@ snapshots:
'@babel/helpers@7.26.10':
dependencies:
- '@babel/template': 7.27.1
+ '@babel/template': 7.28.6
'@babel/types': 7.29.0
'@babel/helpers@7.28.6':
@@ -9204,17 +9772,34 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.8
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
'@floating-ui/dom@1.6.12':
dependencies:
'@floating-ui/core': 1.6.8
'@floating-ui/utils': 0.2.8
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.6.12
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
+ '@floating-ui/react-dom@2.1.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
+ '@floating-ui/utils@0.2.11': {}
+
'@floating-ui/utils@0.2.8': {}
'@img/colour@1.0.0': {}
@@ -9648,6 +10233,36 @@ snapshots:
- acorn
- supports-color
+ '@mdx-js/mdx@3.1.0(acorn@8.16.0)':
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdx': 2.0.13
+ collapse-white-space: 2.1.0
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ estree-util-scope: 1.0.0
+ estree-walker: 3.0.3
+ hast-util-to-jsx-runtime: 2.3.3
+ markdown-extensions: 2.0.0
+ recma-build-jsx: 1.0.0
+ recma-jsx: 1.0.0(acorn@8.16.0)
+ recma-stringify: 1.0.0
+ rehype-recma: 1.0.0
+ remark-mdx: 3.1.0
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.1
+ source-map: 0.7.4
+ unified: 11.0.5
+ unist-util-position-from-estree: 2.0.0
+ unist-util-stringify-position: 4.0.0
+ unist-util-visit: 5.0.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - acorn
+ - supports-color
+
'@mdx-js/react@3.1.0(@types/react@19.2.13)(react@19.0.0)':
dependencies:
'@types/mdx': 2.0.13
@@ -9656,16 +10271,16 @@ snapshots:
'@mediapipe/tasks-vision@0.10.17': {}
- '@mintlify/cli@4.0.1026(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)':
+ '@mintlify/cli@4.0.1026(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)':
dependencies:
'@inquirer/prompts': 7.9.0(@types/node@25.0.6)
'@mintlify/common': 1.0.793(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/link-rot': 3.0.960(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/link-rot': 3.0.960(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
'@mintlify/models': 0.0.284
- '@mintlify/prebuild': 1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/previewing': 4.0.989(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
+ '@mintlify/prebuild': 1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/previewing': 4.0.989(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
'@mintlify/scraping': 4.0.655(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
adm-zip: 0.5.16
chalk: 5.2.0
color: 4.2.3
@@ -9819,13 +10434,13 @@ snapshots:
- ts-node
- typescript
- '@mintlify/link-rot@3.0.960(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)':
+ '@mintlify/link-rot@3.0.960(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)':
dependencies:
'@mintlify/common': 1.0.793(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/prebuild': 1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/previewing': 4.0.989(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
+ '@mintlify/prebuild': 1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/previewing': 4.0.989(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
'@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
fs-extra: 11.1.0
unist-util-visit: 4.1.2
transitivePeerDependencies:
@@ -9871,6 +10486,33 @@ snapshots:
- supports-color
- typescript
+ '@mintlify/mdx@3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)':
+ dependencies:
+ '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@shikijs/transformers': 3.15.0
+ '@shikijs/twoslash': 3.15.0(typescript@5.9.3)
+ arktype: 2.1.27
+ hast-util-to-string: 3.0.1
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-gfm: 3.1.0
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-to-hast: 13.2.1
+ next-mdx-remote-client: 1.0.7(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ rehype-katex: 7.0.1
+ remark-gfm: 4.0.1
+ remark-math: 6.0.0
+ remark-smartypants: 3.0.2
+ shiki: 3.15.0
+ unified: 11.0.5
+ unist-util-visit: 5.0.0
+ transitivePeerDependencies:
+ - '@types/react'
+ - acorn
+ - supports-color
+ - typescript
+
'@mintlify/models@0.0.255':
dependencies:
axios: 1.13.5
@@ -9894,12 +10536,12 @@ snapshots:
leven: 4.0.0
yaml: 2.6.1
- '@mintlify/prebuild@1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)':
+ '@mintlify/prebuild@1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)':
dependencies:
'@mintlify/common': 1.0.793(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
'@mintlify/openapi-parser': 0.0.8
'@mintlify/scraping': 4.0.655(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
chalk: 5.3.0
favicons: 7.2.0
front-matter: 4.0.2
@@ -9925,11 +10567,11 @@ snapshots:
- typescript
- utf-8-validate
- '@mintlify/previewing@4.0.989(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)':
+ '@mintlify/previewing@4.0.989(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)':
dependencies:
'@mintlify/common': 1.0.793(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/prebuild': 1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
- '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/prebuild': 1.0.931(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/validation': 0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
better-opn: 3.0.2
chalk: 5.2.0
chokidar: 3.5.3
@@ -10074,6 +10716,29 @@ snapshots:
- supports-color
- typescript
+ '@mintlify/validation@0.1.629(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)':
+ dependencies:
+ '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
+ '@mintlify/models': 0.0.284
+ arktype: 2.1.27
+ js-yaml: 4.1.1
+ lcm: 0.0.3
+ lodash: 4.17.23
+ object-hash: 3.0.0
+ openapi-types: 12.1.3
+ uuid: 11.1.0
+ zod: 3.25.76
+ zod-to-json-schema: 3.20.4(zod@3.25.76)
+ transitivePeerDependencies:
+ - '@radix-ui/react-popover'
+ - '@types/react'
+ - acorn
+ - debug
+ - react
+ - react-dom
+ - supports-color
+ - typescript
+
'@monogrid/gainmap-js@3.1.0(three@0.170.0)':
dependencies:
promise-worker-transferable: 1.0.4
@@ -10898,6 +11563,8 @@ snapshots:
- '@types/react'
- immer
+ '@remirror/core-constants@3.0.0': {}
+
'@responsive-email/react-email@0.0.4(react@19.0.0)':
dependencies:
'@react-email/section': 0.0.14(react@19.0.0)
@@ -11397,6 +12064,241 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.18
+ '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.37.0)(tsx@4.21.0)(yaml@2.6.1))':
+ dependencies:
+ '@tailwindcss/node': 4.1.18
+ '@tailwindcss/oxide': 4.1.18
+ tailwindcss: 4.1.18
+ vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.37.0)(tsx@4.21.0)(yaml@2.6.1)
+
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.26.10
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/react@16.0.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@babel/runtime': 7.26.10
+ '@testing-library/dom': 10.4.1
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ optionalDependencies:
+ '@types/react': 19.2.13
+ '@types/react-dom': 19.2.3(@types/react@19.2.13)
+
+ '@tiptap/core@3.20.1(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/extension-blockquote@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-bold@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-bold@3.20.2(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-bubble-menu@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@floating-ui/dom': 1.6.12
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+ optional: true
+
+ '@tiptap/extension-bullet-list@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-code-block@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/extension-code@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-document@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-dropcursor@3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-floating-menu@3.20.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+ optional: true
+
+ '@tiptap/extension-gapcursor@3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-hard-break@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-heading@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-horizontal-rule@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/extension-italic@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-link@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+ linkifyjs: 4.3.2
+
+ '@tiptap/extension-list-item@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-list-keymap@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/extension-mention@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@tiptap/suggestion@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+ '@tiptap/suggestion': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-ordered-list@3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-paragraph@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-placeholder@3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-strike@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-superscript@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/extension-text@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extension-underline@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+
+ '@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/html@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(happy-dom@20.8.3)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+ happy-dom: 20.8.3
+
+ '@tiptap/pm@3.20.1':
+ dependencies:
+ prosemirror-changeset: 2.4.0
+ prosemirror-collab: 1.3.1
+ prosemirror-commands: 1.7.1
+ prosemirror-dropcursor: 1.8.2
+ prosemirror-gapcursor: 1.4.0
+ prosemirror-history: 1.5.0
+ prosemirror-inputrules: 1.5.1
+ prosemirror-keymap: 1.2.3
+ prosemirror-markdown: 1.13.4
+ prosemirror-menu: 1.3.0
+ prosemirror-model: 1.25.4
+ prosemirror-schema-basic: 1.2.4
+ prosemirror-schema-list: 1.5.1
+ prosemirror-state: 1.4.4
+ prosemirror-tables: 1.8.5
+ prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
+ prosemirror-transform: 1.11.0
+ prosemirror-view: 1.41.6
+
+ '@tiptap/react@3.20.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+ '@types/react': 19.2.13
+ '@types/react-dom': 19.2.3(@types/react@19.2.13)
+ '@types/use-sync-external-store': 0.0.6
+ fast-equals: 5.4.0
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ use-sync-external-store: 1.6.0(react@19.0.0)
+ optionalDependencies:
+ '@tiptap/extension-bubble-menu': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-floating-menu': 3.20.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ transitivePeerDependencies:
+ - '@floating-ui/dom'
+
+ '@tiptap/starter-kit@3.20.1':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/extension-blockquote': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-bold': 3.20.2(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-bullet-list': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-code': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-code-block': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-document': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-dropcursor': 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-gapcursor': 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-hard-break': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-heading': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-horizontal-rule': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-italic': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-link': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-list': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/extension-list-item': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-list-keymap': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-ordered-list': 3.20.1(@tiptap/extension-list@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1))
+ '@tiptap/extension-paragraph': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-strike': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-text': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extension-underline': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
+ '@tiptap/extensions': 3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
+ '@tiptap/suggestion@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)':
+ dependencies:
+ '@tiptap/core': 3.20.1(@tiptap/pm@3.20.1)
+ '@tiptap/pm': 3.20.1
+
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@tweenjs/tween.js@23.1.3': {}
@@ -11410,6 +12312,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
+ '@types/aria-query@5.0.4': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -11492,10 +12396,19 @@ snapshots:
'@types/katex@0.16.7': {}
+ '@types/linkify-it@5.0.0': {}
+
+ '@types/markdown-it@14.1.2':
+ dependencies:
+ '@types/linkify-it': 5.0.0
+ '@types/mdurl': 2.0.0
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
+ '@types/mdurl@2.0.0': {}
+
'@types/mdx@2.0.13': {}
'@types/mime-types@2.1.4': {}
@@ -11522,6 +12435,8 @@ snapshots:
'@types/phoenix@1.6.6': {}
+ '@types/prismjs@1.26.5': {}
+
'@types/prismjs@1.26.6': {}
'@types/prompts@2.4.9':
@@ -11563,6 +12478,8 @@ snapshots:
'@types/urijs@1.19.25': {}
+ '@types/use-sync-external-store@0.0.6': {}
+
'@types/webpack@5.28.5(esbuild@0.27.3)':
dependencies:
'@types/node': 25.0.6
@@ -11829,6 +12746,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.1: {}
ansis@4.2.0: {}
@@ -11852,6 +12771,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
arkregex@0.0.3:
dependencies:
'@ark/util': 0.55.0
@@ -12275,6 +13198,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ crelt@1.0.6: {}
+
cross-env@10.1.0:
dependencies:
'@epic-web/invariant': 1.0.0
@@ -12463,6 +13388,8 @@ snapshots:
dependencies:
dns-packet: 5.6.1
+ dom-accessibility-api@0.5.16: {}
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -12727,6 +13654,8 @@ snapshots:
escape-string-regexp@2.0.0: {}
+ escape-string-regexp@4.0.0: {}
+
escape-string-regexp@5.0.0: {}
escodegen@2.1.0:
@@ -12861,6 +13790,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-equals@5.4.0: {}
+
fast-fifo@1.3.2: {}
fast-glob@3.3.2:
@@ -13938,6 +14869,12 @@ snapshots:
lines-and-columns@1.2.4: {}
+ linkify-it@5.0.0:
+ dependencies:
+ uc.micro: 2.1.0
+
+ linkifyjs@4.3.2: {}
+
loader-runner@4.3.1: {}
locate-path@5.0.0:
@@ -13978,10 +14915,16 @@ snapshots:
lru-cache@7.18.3: {}
+ lucide-react@0.470.0(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+
lucide-react@0.544.0(react@19.0.0):
dependencies:
react: 19.0.0
+ lz-string@1.5.0: {}
+
maath@0.10.8(@types/three@0.170.0)(three@0.170.0):
dependencies:
'@types/three': 0.170.0
@@ -13993,6 +14936,15 @@ snapshots:
markdown-extensions@2.0.0: {}
+ markdown-it@14.1.1:
+ dependencies:
+ argparse: 2.0.1
+ entities: 4.5.0
+ linkify-it: 5.0.0
+ mdurl: 2.0.0
+ punycode.js: 2.3.1
+ uc.micro: 2.1.0
+
markdown-table@3.0.4: {}
marked@15.0.12: {}
@@ -14216,6 +15168,8 @@ snapshots:
mdn-data@2.12.2: {}
+ mdurl@2.0.0: {}
+
media-typer@0.3.0: {}
merge-descriptors@1.0.3: {}
@@ -14561,9 +15515,9 @@ snapshots:
minipass: 3.3.6
yallist: 4.0.0
- mintlify@4.2.423(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3):
+ mintlify@4.2.423(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3):
dependencies:
- '@mintlify/cli': 4.0.1026(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
+ '@mintlify/cli': 4.0.1026(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)
transitivePeerDependencies:
- '@radix-ui/react-popover'
- '@types/node'
@@ -14639,6 +15593,22 @@ snapshots:
- acorn
- supports-color
+ next-mdx-remote-client@1.0.7(@types/react@19.2.13)(acorn@8.16.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@mdx-js/mdx': 3.1.0(acorn@8.16.0)
+ '@mdx-js/react': 3.1.0(@types/react@19.2.13)(react@19.0.0)
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ remark-mdx-remove-esm: 1.1.0
+ serialize-error: 12.0.0
+ vfile: 6.0.3
+ vfile-matter: 5.0.0
+ transitivePeerDependencies:
+ - '@types/react'
+ - acorn
+ - supports-color
+
next-safe-action@8.0.11(next@16.1.7(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
next: 16.1.7(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -14822,6 +15792,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.0
+ orderedmap@2.1.1: {}
+
outdent@0.5.0: {}
own-keys@1.0.1:
@@ -14988,6 +15960,13 @@ snapshots:
read-cache: 1.0.0
resolve: 1.22.9
+ postcss-import@16.1.1(postcss@8.5.6):
+ dependencies:
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.9
+
postcss-js@4.0.1(postcss@8.5.6):
dependencies:
camelcase-css: 2.0.1
@@ -15032,6 +16011,12 @@ snapshots:
pretty-bytes@6.1.1: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
prism-react-renderer@2.4.1(react@19.0.0):
dependencies:
'@types/prismjs': 1.26.6
@@ -15064,6 +16049,109 @@ snapshots:
property-information@7.0.0: {}
+ prosemirror-changeset@2.4.0:
+ dependencies:
+ prosemirror-transform: 1.11.0
+
+ prosemirror-collab@1.3.1:
+ dependencies:
+ prosemirror-state: 1.4.4
+
+ prosemirror-commands@1.7.1:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+
+ prosemirror-dropcursor@1.8.2:
+ dependencies:
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+ prosemirror-view: 1.41.6
+
+ prosemirror-gapcursor@1.4.0:
+ dependencies:
+ prosemirror-keymap: 1.2.3
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-view: 1.41.6
+
+ prosemirror-history@1.5.0:
+ dependencies:
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+ prosemirror-view: 1.41.6
+ rope-sequence: 1.3.4
+
+ prosemirror-inputrules@1.5.1:
+ dependencies:
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+
+ prosemirror-keymap@1.2.3:
+ dependencies:
+ prosemirror-state: 1.4.4
+ w3c-keyname: 2.2.8
+
+ prosemirror-markdown@1.13.4:
+ dependencies:
+ '@types/markdown-it': 14.1.2
+ markdown-it: 14.1.1
+ prosemirror-model: 1.25.4
+
+ prosemirror-menu@1.3.0:
+ dependencies:
+ crelt: 1.0.6
+ prosemirror-commands: 1.7.1
+ prosemirror-history: 1.5.0
+ prosemirror-state: 1.4.4
+
+ prosemirror-model@1.25.4:
+ dependencies:
+ orderedmap: 2.1.1
+
+ prosemirror-schema-basic@1.2.4:
+ dependencies:
+ prosemirror-model: 1.25.4
+
+ prosemirror-schema-list@1.5.1:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+
+ prosemirror-state@1.4.4:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-transform: 1.11.0
+ prosemirror-view: 1.41.6
+
+ prosemirror-tables@1.8.5:
+ dependencies:
+ prosemirror-keymap: 1.2.3
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+ prosemirror-view: 1.41.6
+
+ prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6):
+ dependencies:
+ '@remirror/core-constants': 3.0.0
+ escape-string-regexp: 4.0.0
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-view: 1.41.6
+
+ prosemirror-transform@1.11.0:
+ dependencies:
+ prosemirror-model: 1.25.4
+
+ prosemirror-view@1.41.6:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.11.0
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -15095,6 +16183,8 @@ snapshots:
end-of-stream: 1.4.4
once: 1.4.0
+ punycode.js@2.3.1: {}
+
punycode@2.3.1: {}
puppeteer-core@22.14.0:
@@ -15173,6 +16263,8 @@ snapshots:
react-is@16.13.1: {}
+ react-is@17.0.2: {}
+
react-reconciler@0.32.0(react@19.0.0):
dependencies:
react: 19.0.0
@@ -15269,6 +16361,16 @@ snapshots:
transitivePeerDependencies:
- acorn
+ recma-jsx@1.0.0(acorn@8.16.0):
+ dependencies:
+ acorn-jsx: 5.3.2(acorn@8.16.0)
+ estree-util-to-js: 2.0.0
+ recma-parse: 1.0.0
+ recma-stringify: 1.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - acorn
+
recma-parse@1.0.0:
dependencies:
'@types/estree': 1.0.8
@@ -15606,6 +16708,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
+ rope-sequence@1.3.4: {}
+
rrweb-cssom@0.8.0: {}
run-async@3.0.0: {}
@@ -16441,8 +17545,12 @@ snapshots:
typedarray@0.0.6: {}
+ typescript@5.8.3: {}
+
typescript@5.9.3: {}
+ uc.micro@2.1.0: {}
+
ufo@1.5.4: {}
uint8array-extras@1.5.0: {}
@@ -16726,6 +17834,8 @@ snapshots:
- tsx
- yaml
+ w3c-keyname@2.2.8: {}
+
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0ea93bc880..9f81a31240 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,6 +3,7 @@ packages:
- apps/*
- benchmarks/*
- packages/*
+ - packages/editor/examples
- packages/react-email/dev
- playground