From ca83c78263e688e679ccc6e7f84925bfe206f2f9 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 16 Feb 2026 15:39:29 -0300 Subject: [PATCH 01/30] add basic extensions and dependencies --- packages/editor/CHANGELOG.md | 2 + packages/editor/license.md | 7 + packages/editor/package.json | 55 ++ packages/editor/readme.md | 0 packages/editor/src/core/email-node.spec.ts | 45 ++ packages/editor/src/core/email-node.ts | 91 +++ packages/editor/src/extensions/body.spec.tsx | 26 + packages/editor/src/extensions/body.tsx | 77 +++ .../editor/src/extensions/button.spec.tsx | 28 + packages/editor/src/extensions/button.tsx | 127 +++++ packages/editor/src/extensions/div.spec.tsx | 26 + packages/editor/src/extensions/div.tsx | 77 +++ .../editor/src/extensions/divider.spec.tsx | 25 + packages/editor/src/extensions/divider.tsx | 50 ++ packages/editor/src/extensions/index.ts | 4 + packages/editor/src/index.ts | 1 + packages/editor/src/utils/styles.spec.ts | 538 ++++++++++++++++++ packages/editor/src/utils/styles.ts | 228 ++++++++ packages/editor/tsconfig.json | 5 + 19 files changed, 1412 insertions(+) create mode 100644 packages/editor/CHANGELOG.md create mode 100644 packages/editor/license.md create mode 100644 packages/editor/package.json create mode 100644 packages/editor/readme.md create mode 100644 packages/editor/src/core/email-node.spec.ts create mode 100644 packages/editor/src/core/email-node.ts create mode 100644 packages/editor/src/extensions/body.spec.tsx create mode 100644 packages/editor/src/extensions/body.tsx create mode 100644 packages/editor/src/extensions/button.spec.tsx create mode 100644 packages/editor/src/extensions/button.tsx create mode 100644 packages/editor/src/extensions/div.spec.tsx create mode 100644 packages/editor/src/extensions/div.tsx create mode 100644 packages/editor/src/extensions/divider.spec.tsx create mode 100644 packages/editor/src/extensions/divider.tsx create mode 100644 packages/editor/src/extensions/index.ts create mode 100644 packages/editor/src/index.ts create mode 100644 packages/editor/src/utils/styles.spec.ts create mode 100644 packages/editor/src/utils/styles.ts create mode 100644 packages/editor/tsconfig.json 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/license.md b/packages/editor/license.md new file mode 100644 index 0000000000..a2c628c9e0 --- /dev/null +++ b/packages/editor/license.md @@ -0,0 +1,7 @@ +Copyright 2026 Plus Five Five, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/editor/package.json b/packages/editor/package.json new file mode 100644 index 0000000000..b53729c432 --- /dev/null +++ b/packages/editor/package.json @@ -0,0 +1,55 @@ +{ + "name": "@react-email/editor", + "version": "0.0.0-experimental.1", + "description": "", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "license": "MIT", + "scripts": { + "build": "tsdown src/index.ts --format esm,cjs --dts --external react", + "build:watch": "tsdown src/index.ts --format esm,cjs --dts --external react --watch", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "repository": { + "type": "git", + "url": "https://github.com/resend/react-email.git", + "directory": "packages/editor" + }, + "keywords": [ + "react", + "email" + ], + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, + "devDependencies": { + "@react-email/render": "workspace:*", + "tsconfig": "workspace:*", + "typescript": "5.8.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/editor/readme.md b/packages/editor/readme.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/editor/src/core/email-node.spec.ts b/packages/editor/src/core/email-node.spec.ts new file mode 100644 index 0000000000..9411dcb597 --- /dev/null +++ b/packages/editor/src/core/email-node.spec.ts @@ -0,0 +1,45 @@ +import { Heading } from '@tiptap/extension-heading'; +import { EmailNode } from './email-node'; + +describe('EmailNode', () => { + it('maintains all user-defined properties from Heading', () => { + const Component = vi.fn(() => 'some important value'); + const CustomHeader = EmailNode.from(Heading, Component); + expect(CustomHeader).toBeInstanceOf(EmailNode); + expect(Heading.config).not.toHaveProperty('renderToReactEmail'); + + expect(CustomHeader.options).toStrictEqual(Heading.options); + expect(CustomHeader.storage).toStrictEqual(Heading.storage); + expect(CustomHeader.child).toStrictEqual(Heading.child); + expect(CustomHeader.type).toStrictEqual(Heading.type); + expect(CustomHeader.name).toStrictEqual(Heading.name); + expect(CustomHeader.parent).toStrictEqual(Heading.parent); + expect(CustomHeader.config).toHaveProperty('renderToReactEmail'); + + expect( + CustomHeader.config.renderToReactEmail( + {} as unknown as Parameters< + typeof CustomHeader.config.renderToReactEmail + >[0], + ), + ).toBe('some important value'); + const configWithoutRender = { ...CustomHeader.config } as Record< + string, + unknown + >; + delete configWithoutRender.renderToReactEmail; + expect(configWithoutRender).toStrictEqual(Heading.config); + }); + + it('remains an EmailNode instance and preserves renderToReactEmail after configure()', () => { + const Component = vi.fn(() => 'rendered'); + const CustomHeader = EmailNode.from(Heading, Component); + + const configured = CustomHeader.configure({ levels: [1, 2] }); + + expect(configured).toBeInstanceOf(EmailNode); + expect(configured.config).toHaveProperty('renderToReactEmail'); + expect(configured.config.renderToReactEmail).toBe(Component); + expect(configured.name).toBe(CustomHeader.name); + }); +}); diff --git a/packages/editor/src/core/email-node.ts b/packages/editor/src/core/email-node.ts new file mode 100644 index 0000000000..9dce60a420 --- /dev/null +++ b/packages/editor/src/core/email-node.ts @@ -0,0 +1,91 @@ +import { + type Editor, + type JSONContent, + Node, + type NodeConfig, + type NodeType, +} from '@tiptap/core'; +import type { CssJs } from '@/types/editor/styles'; + +export type RendererComponent = (props: { + node: JSONContent; + styles: CssJs; + children?: React.ReactNode; +}) => React.ReactNode; + +export interface EmailNodeConfig + extends NodeConfig { + renderToReactEmail: RendererComponent; +} + +type ConfigParameter = Partial< + Omit, 'renderToReactEmail'> +> & + Pick, 'renderToReactEmail'>; + +export class EmailNode< + Options = Record, + Storage = Record, +> extends Node { + declare config: EmailNodeConfig; + + // biome-ignore lint/complexity/noUselessConstructor: This is only meant to change the types for config, hence why we keep it + constructor(config: ConfigParameter) { + super(config); + } + + /** + * Create a new Node instance + * @param config - Node configuration object or a function that returns a configuration object + */ + static create, S = Record>( + config: ConfigParameter | (() => ConfigParameter), + ) { + // If the config is a function, execute it to get the configuration object + const resolvedConfig = typeof config === 'function' ? config() : config; + return new EmailNode(resolvedConfig); + } + + static from( + node: Node, + renderToReactEmail: RendererComponent, + ): EmailNode { + const customNode = EmailNode.create({} as ConfigParameter); + // This only makes a shallow copy, so if there's nested objects here mutating things will be dangerous + Object.assign(customNode, { ...node }); + customNode.config = { ...node.config, renderToReactEmail }; + return customNode; + } + + configure(options?: Partial) { + return super.configure(options) as EmailNode; + } + + extend< + ExtendedOptions = Options, + ExtendedStorage = Storage, + ExtendedConfig extends NodeConfig< + ExtendedOptions, + ExtendedStorage + > = EmailNodeConfig, + >( + extendedConfig?: + | (() => Partial) + | (Partial & + ThisType<{ + name: string; + options: ExtendedOptions; + storage: ExtendedStorage; + editor: Editor; + type: NodeType; + }>), + ): EmailNode { + // If the extended config is a function, execute it to get the configuration object + const resolvedConfig = + typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig; + return super.extend(resolvedConfig) as EmailNode< + ExtendedOptions, + ExtendedStorage + >; + } +} diff --git a/packages/editor/src/extensions/body.spec.tsx b/packages/editor/src/extensions/body.spec.tsx new file mode 100644 index 0000000000..8732577cc0 --- /dev/null +++ b/packages/editor/src/extensions/body.spec.tsx @@ -0,0 +1,26 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Body } from './body'; + +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..eeffc9f7fb --- /dev/null +++ b/packages/editor/src/extensions/body.tsx @@ -0,0 +1,77 @@ +import { mergeAttributes } from '@tiptap/core'; +import { EmailNode } from '../core/serializer/email-node'; +import { inlineCssToJs } from '../utils/styles'; +import { + COMMON_HTML_ATTRIBUTES, + createStandardAttributes, + LAYOUT_ATTRIBUTES, +} from './attribute-helpers'; + +export interface BodyOptions { + HTMLAttributes: Record; +} + +export const Body = EmailNode.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, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.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..5744636b0d --- /dev/null +++ b/packages/editor/src/extensions/button.spec.tsx @@ -0,0 +1,28 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { EditorButton } from './editor-button'; + +describe('EditorButton Node', () => { + it('renders React Email properly', async () => { + const Component = EditorButton.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..c83b2211e3 --- /dev/null +++ b/packages/editor/src/extensions/button.tsx @@ -0,0 +1,127 @@ +import { Button, Column, Row } from '@react-email/components'; +import { mergeAttributes } from '@tiptap/core'; +import { EmailNode } from '../core/serializer/email-node'; +import { inlineCssToJs } from '../utils/styles'; + +interface EditorButtonOptions { + HTMLAttributes: Record; + [key: string]: unknown; +} + +declare module '@tiptap/core' { + interface Commands { + button: { + setButton: () => ReturnType; + updateButton: (attributes: Record) => ReturnType; + }; + } +} + +export const EditorButton = EmailNode.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, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( + + + + + + ); + }, +}); diff --git a/packages/editor/src/extensions/div.spec.tsx b/packages/editor/src/extensions/div.spec.tsx new file mode 100644 index 0000000000..827c4b3e8d --- /dev/null +++ b/packages/editor/src/extensions/div.spec.tsx @@ -0,0 +1,26 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Div } from './div'; + +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..27bf9bd9de --- /dev/null +++ b/packages/editor/src/extensions/div.tsx @@ -0,0 +1,77 @@ +import { mergeAttributes } from '@tiptap/core'; +import { EmailNode } from '../core/serializer/email-node'; +import { inlineCssToJs } from '../utils/styles'; +import { + COMMON_HTML_ATTRIBUTES, + createStandardAttributes, + LAYOUT_ATTRIBUTES, +} from './attribute-helpers'; + +export interface DivOptions { + HTMLAttributes: Record; +} + +export const Div = EmailNode.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, styles }) { + 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..c4c85b8172 --- /dev/null +++ b/packages/editor/src/extensions/divider.spec.tsx @@ -0,0 +1,25 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Divider } from './divider'; + +describe('Divider Node', () => { + it('renders React Email properly', async () => { + const Component = Divider.config.renderToReactEmail; + expect(Component).toBeDefined(); + 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..dfd019c276 --- /dev/null +++ b/packages/editor/src/extensions/divider.tsx @@ -0,0 +1,50 @@ +import { Hr } from '@react-email/components'; +import { InputRule } from '@tiptap/core'; +import HorizontalRule from '@tiptap/extension-horizontal-rule'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { EmailNode } from '../core/serializer/email-node'; +import { inlineCssToJs } from '../utils/styles'; +import { ReactEmailDivider } from './react-email-divider'; + +export const Divider = EmailNode.from( + 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(ReactEmailDivider); + }, + }), + ({ node, styles }) => { + return ( +
+ ); + }, +); diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts new file mode 100644 index 0000000000..9d1cd70f4e --- /dev/null +++ b/packages/editor/src/extensions/index.ts @@ -0,0 +1,4 @@ +export * from './body'; +export * from './button'; +export * from './div'; +export * from './divider'; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts new file mode 100644 index 0000000000..4509f5ccb0 --- /dev/null +++ b/packages/editor/src/index.ts @@ -0,0 +1 @@ +export * from './extensions'; diff --git a/packages/editor/src/utils/styles.spec.ts b/packages/editor/src/utils/styles.spec.ts new file mode 100644 index 0000000000..e22c1507e1 --- /dev/null +++ b/packages/editor/src/utils/styles.spec.ts @@ -0,0 +1,538 @@ +import { describe, expect, it } from 'vitest'; +import { + expandShorthandProperties, + inlineCssToJs, + jsToInlineCss, +} from './styles'; + +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', + }); + }); + }); +}); diff --git a/packages/editor/src/utils/styles.ts b/packages/editor/src/utils/styles.ts new file mode 100644 index 0000000000..b48fb5e14a --- /dev/null +++ b/packages/editor/src/utils/styles.ts @@ -0,0 +1,228 @@ +const WHITE_SPACE_REGEX = /\s+/; + +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', + }; + } +} diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json new file mode 100644 index 0000000000..cd6c94d6e8 --- /dev/null +++ b/packages/editor/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} From 24a6f33838ea958b7553165a22a755c59770fa5f Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 09:12:35 -0300 Subject: [PATCH 02/30] fix imports, types, rename a few things --- packages/editor/package.json | 10 +- packages/editor/src/core/email-node.ts | 2 +- .../__snapshots__/body.spec.tsx.snap | 9 + .../__snapshots__/button.spec.tsx.snap | 36 ++ .../__snapshots__/div.spec.tsx.snap | 9 + packages/editor/src/extensions/body.tsx | 6 +- .../editor/src/extensions/button.spec.tsx | 4 +- packages/editor/src/extensions/button.tsx | 38 +- packages/editor/src/extensions/div.tsx | 6 +- .../editor/src/extensions/divider.spec.tsx | 25 - packages/editor/src/extensions/divider.tsx | 50 -- packages/editor/src/extensions/index.ts | 1 - packages/editor/src/plugins/theming/themes.ts | 561 ++++++++++++++++++ packages/editor/src/types.ts | 120 ++++ .../editor/src/utils/attribute-helpers.ts | 89 +++ pnpm-lock.yaml | 386 ++++++++++++ 16 files changed, 1247 insertions(+), 105 deletions(-) create mode 100644 packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap create mode 100644 packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap create mode 100644 packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap delete mode 100644 packages/editor/src/extensions/divider.spec.tsx delete mode 100644 packages/editor/src/extensions/divider.tsx create mode 100644 packages/editor/src/plugins/theming/themes.ts create mode 100644 packages/editor/src/types.ts create mode 100644 packages/editor/src/utils/attribute-helpers.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index b53729c432..f4216af9a5 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -42,7 +42,15 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react": "^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "@react-email/components": "workspace:*" + }, + "dependencies": { + "@tiptap/core": "3.10.7", + "@tiptap/extension-heading": "3.10.7", + "@tiptap/extension-horizontal-rule": "3.10.7", + "@tiptap/pm": "3.10.7", + "@tiptap/react": "3.10.7" }, "devDependencies": { "@react-email/render": "workspace:*", diff --git a/packages/editor/src/core/email-node.ts b/packages/editor/src/core/email-node.ts index 9dce60a420..6b09ae21e4 100644 --- a/packages/editor/src/core/email-node.ts +++ b/packages/editor/src/core/email-node.ts @@ -5,7 +5,7 @@ import { type NodeConfig, type NodeType, } from '@tiptap/core'; -import type { CssJs } from '@/types/editor/styles'; +import type { CssJs } from '../types'; export type RendererComponent = (props: { node: JSONContent; 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__/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/body.tsx b/packages/editor/src/extensions/body.tsx index eeffc9f7fb..3b12d37251 100644 --- a/packages/editor/src/extensions/body.tsx +++ b/packages/editor/src/extensions/body.tsx @@ -1,11 +1,11 @@ import { mergeAttributes } from '@tiptap/core'; -import { EmailNode } from '../core/serializer/email-node'; -import { inlineCssToJs } from '../utils/styles'; +import { EmailNode } from '../core/email-node'; import { COMMON_HTML_ATTRIBUTES, createStandardAttributes, LAYOUT_ATTRIBUTES, -} from './attribute-helpers'; +} from '../utils/attribute-helpers'; +import { inlineCssToJs } from '../utils/styles'; export interface BodyOptions { HTMLAttributes: Record; diff --git a/packages/editor/src/extensions/button.spec.tsx b/packages/editor/src/extensions/button.spec.tsx index 5744636b0d..e365261881 100644 --- a/packages/editor/src/extensions/button.spec.tsx +++ b/packages/editor/src/extensions/button.spec.tsx @@ -1,10 +1,10 @@ import { render } from '@react-email/components'; import { RESET_THEMES } from '../plugins/theming/themes'; -import { EditorButton } from './editor-button'; +import { Button } from './button'; describe('EditorButton Node', () => { it('renders React Email properly', async () => { - const Component = EditorButton.config.renderToReactEmail; + const Component = Button.config.renderToReactEmail; expect(Component).toBeDefined(); expect( await render( diff --git a/packages/editor/src/extensions/button.tsx b/packages/editor/src/extensions/button.tsx index c83b2211e3..58a51b48e4 100644 --- a/packages/editor/src/extensions/button.tsx +++ b/packages/editor/src/extensions/button.tsx @@ -1,6 +1,6 @@ -import { Button, Column, Row } from '@react-email/components'; +import { Button as ReactEmailButton, Column, Row } from '@react-email/components'; import { mergeAttributes } from '@tiptap/core'; -import { EmailNode } from '../core/serializer/email-node'; +import { EmailNode } from '../core/email-node'; import { inlineCssToJs } from '../utils/styles'; interface EditorButtonOptions { @@ -17,7 +17,7 @@ declare module '@tiptap/core' { } } -export const EditorButton = EmailNode.create({ +export const Button = EmailNode.create({ name: 'button', group: 'block', content: 'inline*', @@ -84,23 +84,23 @@ export const EditorButton = EmailNode.create({ return { updateButton: (attributes) => - ({ commands }) => { - return commands.updateAttributes('button', attributes); - }, + ({ commands }) => { + return commands.updateAttributes('button', attributes); + }, setButton: () => - ({ commands }) => { - return commands.insertContent({ - type: 'button', - content: [ - { - type: 'text', - text: 'Button', - }, - ], - }); - }, + ({ commands }) => { + return commands.insertContent({ + type: 'button', + content: [ + { + type: 'text', + text: 'Button', + }, + ], + }); + }, }; }, @@ -109,7 +109,7 @@ export const EditorButton = EmailNode.create({ return ( - + ); diff --git a/packages/editor/src/extensions/div.tsx b/packages/editor/src/extensions/div.tsx index 27bf9bd9de..02d2491d75 100644 --- a/packages/editor/src/extensions/div.tsx +++ b/packages/editor/src/extensions/div.tsx @@ -1,11 +1,11 @@ import { mergeAttributes } from '@tiptap/core'; -import { EmailNode } from '../core/serializer/email-node'; -import { inlineCssToJs } from '../utils/styles'; +import { EmailNode } from '../core/email-node'; import { COMMON_HTML_ATTRIBUTES, createStandardAttributes, LAYOUT_ATTRIBUTES, -} from './attribute-helpers'; +} from '../utils/attribute-helpers'; +import { inlineCssToJs } from '../utils/styles'; export interface DivOptions { HTMLAttributes: Record; diff --git a/packages/editor/src/extensions/divider.spec.tsx b/packages/editor/src/extensions/divider.spec.tsx deleted file mode 100644 index c4c85b8172..0000000000 --- a/packages/editor/src/extensions/divider.spec.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render } from '@react-email/components'; -import { RESET_THEMES } from '../plugins/theming/themes'; -import { Divider } from './divider'; - -describe('Divider Node', () => { - it('renders React Email properly', async () => { - const Component = Divider.config.renderToReactEmail; - expect(Component).toBeDefined(); - expect( - await render( - , - { pretty: true }, - ), - ).toMatchSnapshot(); - }); -}); diff --git a/packages/editor/src/extensions/divider.tsx b/packages/editor/src/extensions/divider.tsx deleted file mode 100644 index dfd019c276..0000000000 --- a/packages/editor/src/extensions/divider.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Hr } from '@react-email/components'; -import { InputRule } from '@tiptap/core'; -import HorizontalRule from '@tiptap/extension-horizontal-rule'; -import { ReactNodeViewRenderer } from '@tiptap/react'; -import { EmailNode } from '../core/serializer/email-node'; -import { inlineCssToJs } from '../utils/styles'; -import { ReactEmailDivider } from './react-email-divider'; - -export const Divider = EmailNode.from( - 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(ReactEmailDivider); - }, - }), - ({ node, styles }) => { - return ( -
- ); - }, -); diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts index 9d1cd70f4e..33cbeadee3 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -1,4 +1,3 @@ export * from './body'; export * from './button'; export * from './div'; -export * from './divider'; diff --git a/packages/editor/src/plugins/theming/themes.ts b/packages/editor/src/plugins/theming/themes.ts new file mode 100644 index 0000000000..6a6e8f8515 --- /dev/null +++ b/packages/editor/src/plugins/theming/themes.ts @@ -0,0 +1,561 @@ +import type { + EditorThemes, + PanelGroup, + ResetTheme, + SupportedCssProperties, +} from '../../types'; + +const THEME_BASIC: PanelGroup[] = [ + { + title: 'Body', + classReference: 'body', + inputs: [], + }, + { + title: 'Container', + 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', + }, + ], + }, + { + title: 'Typography', + 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', + }, + ], + }, + { + 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', + }, + ], + }, + { + title: 'Image', + classReference: 'image', + inputs: [ + { + label: 'Border radius', + type: 'number', + value: 8, + unit: 'px', + prop: 'borderRadius', + classReference: 'image', + }, + ], + }, + { + 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', + }, + ], + }, + { + 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', + }, + ], + }, + { + 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: '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, + }, +}; + +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 const EDITOR_THEMES: Record = { + basic: THEME_BASIC, + minimal: THEME_MINIMAL, +}; + +/** + * Use to make the preview nicer once the theme might miss some + * important properties to make layout accurate + */ +export const PREVIEW_THEME_OVERWRITE: Partial = { + body: { + color: '#000000', + fontSize: '14px', + 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', + }, + 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', + }, + borderStyle: { + label: 'Border Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderColor: { + label: 'Border 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/types.ts b/packages/editor/src/types.ts new file mode 100644 index 0000000000..ec97ce9de6 --- /dev/null +++ b/packages/editor/src/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index a4dc8c8c63..28582664d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,40 @@ importers: specifier: 5.8.3 version: 5.8.3 + packages/editor: + dependencies: + '@react-email/components': + specifier: workspace:* + version: link:../components + '@tiptap/core': + specifier: 3.10.7 + version: 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/extension-heading': + specifier: 3.10.7 + version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-horizontal-rule': + specifier: 3.10.7 + version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/pm': + specifier: 3.10.7 + version: 3.10.7 + '@tiptap/react': + specifier: 3.10.7 + version: 3.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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: + specifier: ^19.0.0 + version: 19.0.0 + devDependencies: + '@react-email/render': + specifier: workspace:* + version: link:../render + tsconfig: + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: 5.8.3 + version: 5.8.3 + packages/font: dependencies: react: @@ -4024,6 +4058,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: @@ -4542,6 +4579,48 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@tiptap/core@3.10.7': + resolution: {integrity: sha512-4rD3oHkXNOS6Fxm0mr+ECyq35iMFnnAXheIO+UsQbOexwTxn2yZ5Q1rQiFKcCf+p+rrg1yt8TtxQPM8VLWS+1g==} + peerDependencies: + '@tiptap/pm': ^3.10.7 + + '@tiptap/extension-bubble-menu@3.19.0': + resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-floating-menu@3.19.0': + resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-heading@3.10.7': + resolution: {integrity: sha512-Pp0LYTEyimDfiXzy+8Ls2LDuhhmyM7jXr8go3myTHSLMTpt0ch7P5FVSnDxMFtQ5eRiAwXHET63/JOaiIwMa/w==} + peerDependencies: + '@tiptap/core': ^3.10.7 + + '@tiptap/extension-horizontal-rule@3.10.7': + resolution: {integrity: sha512-V9uWb341QUBDDbR3aoSs3Sx0PQQaKwZ/ESVEE03El9rkIrf8g5K82x8/M0nvSOvGobt6oRyI/rgbj196YQuXiQ==} + peerDependencies: + '@tiptap/core': ^3.10.7 + '@tiptap/pm': ^3.10.7 + + '@tiptap/pm@3.10.7': + resolution: {integrity: sha512-/iiurioqSukJk6CrEtfRpdOEafDybyVPToAllgn7i2XcusXSxJSX+K0GUndMUwVR+UqVOCyMYBTRTnE0hdQqgA==} + + '@tiptap/react@3.10.7': + resolution: {integrity: sha512-hhKj62zvs/mSu5HlcmZDRFHVHCjJ6v6/7vB45MTAziP+cZ0+CEbEh2rnGNRNwooumWwm5pWdkVqI1efp7GtnUA==} + peerDependencies: + '@tiptap/core': ^3.10.7 + '@tiptap/pm': ^3.10.7 + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3 + react: ^19.0.0 + react-dom: ^19.0.0 + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -4626,9 +4705,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==} @@ -4702,6 +4790,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==} @@ -5477,6 +5568,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'} @@ -7079,6 +7173,9 @@ 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==} + loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -7158,6 +7255,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==} @@ -7233,6 +7334,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'} @@ -7727,6 +7831,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==} @@ -8025,6 +8132,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.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + + 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'} @@ -8043,6 +8208,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'} @@ -8410,6 +8579,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==} @@ -9195,6 +9367,9 @@ packages: 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==} @@ -9471,6 +9646,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'} @@ -12844,6 +13022,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) @@ -13350,6 +13530,71 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@tiptap/core@3.10.7(@tiptap/pm@3.10.7)': + dependencies: + '@tiptap/pm': 3.10.7 + + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@floating-ui/dom': 1.6.12 + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + optional: true + + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@floating-ui/dom': 1.6.12 + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + optional: true + + '@tiptap/extension-heading@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-horizontal-rule@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + + '@tiptap/pm@3.10.7': + 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.2.5 + 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.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + '@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-deep-equal: 3.1.3 + 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.19.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + transitivePeerDependencies: + - '@floating-ui/dom' + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tweenjs/tween.js@23.1.3': {} @@ -13449,10 +13694,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': {} @@ -13528,6 +13782,8 @@ snapshots: '@types/urijs@1.19.25': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/webpack@5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.19.11)': dependencies: '@types/node': 22.19.7 @@ -14310,6 +14566,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crelt@1.0.6: {} + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -16222,6 +16480,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + loader-runner@4.3.1: {} locate-path@5.0.0: @@ -16288,6 +16550,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: {} @@ -16511,6 +16782,8 @@ snapshots: mdn-data@2.12.2: {} + mdurl@2.0.0: {} + media-typer@0.3.0: {} merge-descriptors@1.0.1: {} @@ -17181,6 +17454,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: @@ -17477,6 +17752,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.2.5: + 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 @@ -17508,6 +17886,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: @@ -18098,6 +18478,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.2 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + rrweb-cssom@0.8.0: {} run-async@3.0.0: {} @@ -19128,6 +19510,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + ufo@1.5.4: {} uint8array-extras@1.5.0: {} @@ -19419,6 +19803,8 @@ snapshots: - tsx - yaml + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 From fb862728fc56275ba217ab3160c51316e37bbc7d Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 09:12:51 -0300 Subject: [PATCH 03/30] lint --- packages/editor/src/extensions/button.tsx | 34 +++++++++++-------- packages/editor/src/plugins/theming/themes.ts | 1 - 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/editor/src/extensions/button.tsx b/packages/editor/src/extensions/button.tsx index 58a51b48e4..9ed5b0787a 100644 --- a/packages/editor/src/extensions/button.tsx +++ b/packages/editor/src/extensions/button.tsx @@ -1,4 +1,8 @@ -import { Button as ReactEmailButton, Column, Row } from '@react-email/components'; +import { + Column, + Button as ReactEmailButton, + Row, +} from '@react-email/components'; import { mergeAttributes } from '@tiptap/core'; import { EmailNode } from '../core/email-node'; import { inlineCssToJs } from '../utils/styles'; @@ -84,23 +88,23 @@ export const Button = EmailNode.create({ return { updateButton: (attributes) => - ({ commands }) => { - return commands.updateAttributes('button', attributes); - }, + ({ commands }) => { + return commands.updateAttributes('button', attributes); + }, setButton: () => - ({ commands }) => { - return commands.insertContent({ - type: 'button', - content: [ - { - type: 'text', - text: 'Button', - }, - ], - }); - }, + ({ commands }) => { + return commands.insertContent({ + type: 'button', + content: [ + { + type: 'text', + text: 'Button', + }, + ], + }); + }, }; }, diff --git a/packages/editor/src/plugins/theming/themes.ts b/packages/editor/src/plugins/theming/themes.ts index 6a6e8f8515..d94559c425 100644 --- a/packages/editor/src/plugins/theming/themes.ts +++ b/packages/editor/src/plugins/theming/themes.ts @@ -558,4 +558,3 @@ export const SUPPORTED_CSS_PROPERTIES: SupportedCssProperties = { category: 'layout', }, }; - From 02e8d44374aa8a40db0b106a5cad71674ce34a8e Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 09:18:31 -0300 Subject: [PATCH 04/30] experimental.0 --- packages/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index f4216af9a5..9450bd01d1 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.1", + "version": "0.0.0-experimental.0", "description": "", "sideEffects": false, "main": "./dist/index.js", From bdebc2409a00533aa2e9b8418ea5580fffee9431 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 09:23:49 -0300 Subject: [PATCH 05/30] export from core too, move types to utils --- packages/editor/src/core/email-node.ts | 2 +- packages/editor/src/core/index.ts | 1 + packages/editor/src/index.ts | 1 + packages/editor/src/plugins/theming/themes.ts | 2 +- packages/editor/src/{ => utils}/types.ts | 0 5 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/core/index.ts rename packages/editor/src/{ => utils}/types.ts (100%) diff --git a/packages/editor/src/core/email-node.ts b/packages/editor/src/core/email-node.ts index 6b09ae21e4..7459ac491e 100644 --- a/packages/editor/src/core/email-node.ts +++ b/packages/editor/src/core/email-node.ts @@ -5,7 +5,7 @@ import { type NodeConfig, type NodeType, } from '@tiptap/core'; -import type { CssJs } from '../types'; +import type { CssJs } from '../utils/types'; export type RendererComponent = (props: { node: JSONContent; diff --git a/packages/editor/src/core/index.ts b/packages/editor/src/core/index.ts new file mode 100644 index 0000000000..3a791b93b1 --- /dev/null +++ b/packages/editor/src/core/index.ts @@ -0,0 +1 @@ +export * from './email-node'; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 4509f5ccb0..a158f47bbe 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1 +1,2 @@ +export * from './core'; export * from './extensions'; diff --git a/packages/editor/src/plugins/theming/themes.ts b/packages/editor/src/plugins/theming/themes.ts index d94559c425..a1d837d4b9 100644 --- a/packages/editor/src/plugins/theming/themes.ts +++ b/packages/editor/src/plugins/theming/themes.ts @@ -3,7 +3,7 @@ import type { PanelGroup, ResetTheme, SupportedCssProperties, -} from '../../types'; +} from '../../utils/types'; const THEME_BASIC: PanelGroup[] = [ { diff --git a/packages/editor/src/types.ts b/packages/editor/src/utils/types.ts similarity index 100% rename from packages/editor/src/types.ts rename to packages/editor/src/utils/types.ts From c13d2b17ea894369e9eda50339433de75640894f Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 10:13:57 -0300 Subject: [PATCH 06/30] add codeblock and heading --- packages/editor/package.json | 1 + .../editor/src/extensions/code-block.spec.tsx | 51 ++++ packages/editor/src/extensions/code-block.tsx | 147 +++++++++++ .../editor/src/extensions/heading.spec.tsx | 30 +++ packages/editor/src/extensions/heading.tsx | 34 +++ .../editor/src/extensions/prism-plugin.ts | 242 ++++++++++++++++++ pnpm-lock.yaml | 14 + 7 files changed, 519 insertions(+) create mode 100644 packages/editor/src/extensions/code-block.spec.tsx create mode 100644 packages/editor/src/extensions/code-block.tsx create mode 100644 packages/editor/src/extensions/heading.spec.tsx create mode 100644 packages/editor/src/extensions/heading.tsx create mode 100644 packages/editor/src/extensions/prism-plugin.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 9450bd01d1..9da027ba6a 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@tiptap/core": "3.10.7", + "@tiptap/extension-code-block": "3.10.7", "@tiptap/extension-heading": "3.10.7", "@tiptap/extension-horizontal-rule": "3.10.7", "@tiptap/pm": "3.10.7", 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..cf41c35465 --- /dev/null +++ b/packages/editor/src/extensions/code-block.spec.tsx @@ -0,0 +1,51 @@ +import { dracula, render } from '@react-email/components'; +import { describe, expect, it } from 'vitest'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { CodeBlockPrism } from './code-block'; + +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..378805c09e --- /dev/null +++ b/packages/editor/src/extensions/code-block.tsx @@ -0,0 +1,147 @@ +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 { EmailNode } from '../core/email-node'; +import { PrismPlugin } from './prism-plugin'; + +export interface CodeBlockPrismOptions extends CodeBlockOptions { + defaultLanguage: string; + defaultTheme: string; +} + +export const CodeBlockPrism = EmailNode.from( + 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, + ], + ]; + }, + + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + PrismPlugin({ + name: this.name, + defaultLanguage: this.options.defaultLanguage, + defaultTheme: this.options.defaultTheme, + }), + ]; + }, + }), + ({ node, styles }) => { + const language = node.attrs?.language + ? `${node.attrs.language}` + : 'javascript'; + + const userTheme = ReactEmailComponents[node.attrs?.theme]; + + // Without theme, render a gray codeblock + 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/heading.spec.tsx b/packages/editor/src/extensions/heading.spec.tsx new file mode 100644 index 0000000000..14ac8bf342 --- /dev/null +++ b/packages/editor/src/extensions/heading.spec.tsx @@ -0,0 +1,30 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Heading } from './heading'; + +describe('Heading Node', () => { + it('renders React Email properly', async () => { + const Component = Heading.config.renderToReactEmail; + expect(Component).toBeDefined(); + 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..c247856b7a --- /dev/null +++ b/packages/editor/src/extensions/heading.tsx @@ -0,0 +1,34 @@ +import { Heading as EmailHeading } from '@react-email/components'; +import { Heading as TipTapHeading } from '@tiptap/extension-heading'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { EmailNode } from '../core/serializer/email-node'; +import { getTextAlignment } from '../utils/get-text-alignment'; +import { inlineCssToJs } from '../utils/styles'; +import { ReactEmailHeading } from './react-email-heading'; + +export const Heading = EmailNode.from( + TipTapHeading.extend({ + addNodeView() { + return ReactNodeViewRenderer(ReactEmailHeading); + }, + }), + ({ children, node, styles }) => { + const level = node.attrs?.level ?? 1; + return ( + + {children} + + ); + }, +); diff --git a/packages/editor/src/extensions/prism-plugin.ts b/packages/editor/src/extensions/prism-plugin.ts new file mode 100644 index 0000000000..957955fa31 --- /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/pnpm-lock.yaml b/pnpm-lock.yaml index 28582664d4..a50a1b39e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,6 +521,9 @@ importers: '@tiptap/core': specifier: 3.10.7 version: 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/extension-code-block': + specifier: 3.10.7 + version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) '@tiptap/extension-heading': specifier: 3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) @@ -4590,6 +4593,12 @@ packages: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-code-block@3.10.7': + resolution: {integrity: sha512-Z6EH/DhSVQtOKL+vS9J2dbvJ81T3xJ2Htgn4BOxpuCGUCInu5Aymf/53tco3aQse/UHB3Gvr+/4AOwxphXYhgw==} + peerDependencies: + '@tiptap/core': ^3.10.7 + '@tiptap/pm': ^3.10.7 + '@tiptap/extension-floating-menu@3.19.0': resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} peerDependencies: @@ -13541,6 +13550,11 @@ snapshots: '@tiptap/pm': 3.10.7 optional: true + '@tiptap/extension-code-block@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': dependencies: '@floating-ui/dom': 1.6.12 From 0b1b2a654ccfecd834faa41d9674c359dbd4958d Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 10:30:05 -0300 Subject: [PATCH 07/30] add section node --- packages/editor/src/extensions/index.ts | 3 + .../editor/src/extensions/section.spec.tsx | 27 +++++++ packages/editor/src/extensions/section.tsx | 81 +++++++++++++++++++ .../editor/src/utils/get-text-alignment.ts | 12 +++ 4 files changed, 123 insertions(+) create mode 100644 packages/editor/src/extensions/section.spec.tsx create mode 100644 packages/editor/src/extensions/section.tsx create mode 100644 packages/editor/src/utils/get-text-alignment.ts diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts index 33cbeadee3..169d1d43a0 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -1,3 +1,6 @@ export * from './body'; export * from './button'; +export * from './code-block'; export * from './div'; +export * from './heading'; +export * from './section'; diff --git a/packages/editor/src/extensions/section.spec.tsx b/packages/editor/src/extensions/section.spec.tsx new file mode 100644 index 0000000000..f9d8c1be21 --- /dev/null +++ b/packages/editor/src/extensions/section.spec.tsx @@ -0,0 +1,27 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Section } from './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..ece5f41f93 --- /dev/null +++ b/packages/editor/src/extensions/section.tsx @@ -0,0 +1,81 @@ +import { Section as ReactEmailSection } from '@react-email/components'; +import { mergeAttributes } from '@tiptap/core'; +import type * as React from 'react'; +import { EmailNode } from '../core/email-node'; +import { getTextAlignment } from '../utils/get-text-alignment'; +import { inlineCssToJs } from '../utils/styles'; + +interface SectionOptions { + HTMLAttributes: Record; + [key: string]: unknown; +} + +declare module '@tiptap/core' { + interface Commands { + section: { + insertSection: () => ReturnType; + }; + } +} + +export const Section = EmailNode.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, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + const textAlign = node.attrs?.align || node.attrs?.alignment; + + return ( + + {children} + + ); + }, +}); 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..3d31f982b8 --- /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' }; + case 'center': + return { textAlign: 'center' }; + case 'right': + return { textAlign: 'right' }; + default: + return {}; + } +} From 7a3174b4ec66bdd234d3a524090c9dde1ffc6f32 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 11:24:34 -0300 Subject: [PATCH 08/30] remove heading since it requires theming --- .../editor/src/extensions/heading.spec.tsx | 30 ---------------- packages/editor/src/extensions/heading.tsx | 34 ------------------- packages/editor/src/extensions/index.ts | 1 - 3 files changed, 65 deletions(-) delete mode 100644 packages/editor/src/extensions/heading.spec.tsx delete mode 100644 packages/editor/src/extensions/heading.tsx diff --git a/packages/editor/src/extensions/heading.spec.tsx b/packages/editor/src/extensions/heading.spec.tsx deleted file mode 100644 index 14ac8bf342..0000000000 --- a/packages/editor/src/extensions/heading.spec.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render } from '@react-email/components'; -import { RESET_THEMES } from '../plugins/theming/themes'; -import { Heading } from './heading'; - -describe('Heading Node', () => { - it('renders React Email properly', async () => { - const Component = Heading.config.renderToReactEmail; - expect(Component).toBeDefined(); - expect( - await render( - , - { pretty: true }, - ), - ).toMatchSnapshot(); - }); -}); diff --git a/packages/editor/src/extensions/heading.tsx b/packages/editor/src/extensions/heading.tsx deleted file mode 100644 index c247856b7a..0000000000 --- a/packages/editor/src/extensions/heading.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Heading as EmailHeading } from '@react-email/components'; -import { Heading as TipTapHeading } from '@tiptap/extension-heading'; -import { ReactNodeViewRenderer } from '@tiptap/react'; -import { EmailNode } from '../core/serializer/email-node'; -import { getTextAlignment } from '../utils/get-text-alignment'; -import { inlineCssToJs } from '../utils/styles'; -import { ReactEmailHeading } from './react-email-heading'; - -export const Heading = EmailNode.from( - TipTapHeading.extend({ - addNodeView() { - return ReactNodeViewRenderer(ReactEmailHeading); - }, - }), - ({ children, node, styles }) => { - const level = node.attrs?.level ?? 1; - return ( - - {children} - - ); - }, -); diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts index 169d1d43a0..b4b853fc15 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -2,5 +2,4 @@ export * from './body'; export * from './button'; export * from './code-block'; export * from './div'; -export * from './heading'; export * from './section'; From fb5ab5d60f5f587f30fe35e104b77e90e67458b2 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 11:26:31 -0300 Subject: [PATCH 09/30] fix build --- packages/editor/package.json | 5 +++- .../editor/src/extensions/prism-plugin.ts | 2 +- packages/editor/src/utils/prism-utils.ts | 30 +++++++++++++++++++ pnpm-lock.yaml | 9 ++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/utils/prism-utils.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 9da027ba6a..e9ec0c58b2 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -51,10 +51,13 @@ "@tiptap/extension-heading": "3.10.7", "@tiptap/extension-horizontal-rule": "3.10.7", "@tiptap/pm": "3.10.7", - "@tiptap/react": "3.10.7" + "@tiptap/react": "3.10.7", + "hast-util-from-html": "2.0.3", + "prismjs": "1.30.0" }, "devDependencies": { "@react-email/render": "workspace:*", + "@types/prismjs": "1.26.5", "tsconfig": "workspace:*", "typescript": "5.8.3" }, diff --git a/packages/editor/src/extensions/prism-plugin.ts b/packages/editor/src/extensions/prism-plugin.ts index 957955fa31..c1b0cc2d4b 100644 --- a/packages/editor/src/extensions/prism-plugin.ts +++ b/packages/editor/src/extensions/prism-plugin.ts @@ -9,7 +9,7 @@ import { hasPrismThemeLoaded, loadPrismTheme, removePrismTheme, -} from '@/utils/prism-utils'; +} from '../utils/prism-utils'; const PRISM_LANGUAGE_LOADED_META = 'prismLanguageLoaded'; 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/pnpm-lock.yaml b/pnpm-lock.yaml index a50a1b39e0..f409e91b29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,12 @@ importers: '@tiptap/react': specifier: 3.10.7 version: 3.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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) + 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 @@ -543,6 +549,9 @@ importers: '@react-email/render': specifier: workspace:* version: link:../render + '@types/prismjs': + specifier: 1.26.5 + version: 1.26.5 tsconfig: specifier: workspace:* version: link:../tsconfig From 0e0e73888e0d782c88f967f2b90da3eef611a8f3 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 11:27:59 -0300 Subject: [PATCH 10/30] update snaps --- .../__snapshots__/code-block.spec.tsx.snap | 10 ++++++++ .../__snapshots__/section.spec.tsx.snap | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap create mode 100644 packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap 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__/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`] = ` +" + + + + + + + + + +" +`; From 4cf8dc93aa3d297ed8de79f282e03d223eb88dc5 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 12:03:56 -0300 Subject: [PATCH 11/30] lint --- packages/editor/src/extensions/code-block.tsx | 34 +++++++++---------- packages/editor/src/extensions/section.tsx | 22 ++++++------ 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index 378805c09e..84da4ac863 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -113,24 +113,24 @@ export const CodeBlockPrism = EmailNode.from( // Without theme, render a gray codeblock const theme = userTheme ? { - ...userTheme, - base: { - ...userTheme.base, - borderRadius: '0.125rem', - padding: '0.75rem 1rem', - }, - } + ...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', - }, - }; + 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 ( ({ return { insertSection: () => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - content: [ - { - type: 'paragraph', - content: [], - }, - ], - }); - }, + ({ commands }) => { + return commands.insertContent({ + type: this.name, + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }); + }, }; }, From d135363be2dc3835cc74364750cddd4d2590d423 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 12:24:19 -0300 Subject: [PATCH 12/30] remove render --- packages/editor/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index e9ec0c58b2..857c599430 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -56,7 +56,6 @@ "prismjs": "1.30.0" }, "devDependencies": { - "@react-email/render": "workspace:*", "@types/prismjs": "1.26.5", "tsconfig": "workspace:*", "typescript": "5.8.3" From 703145b606170aaae6f3930770bb90651f036653 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 12:24:22 -0300 Subject: [PATCH 13/30] add ts-expect-erro --- packages/editor/src/extensions/code-block.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index 84da4ac863..a4dc20e812 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -108,9 +108,10 @@ export const CodeBlockPrism = EmailNode.from( ? `${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 codeblock + // Without theme, render a gray code block const theme = userTheme ? { ...userTheme, From 2f5264b6183de8acdc092163bcfddd1ee68163d0 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 18 Feb 2026 12:25:54 -0300 Subject: [PATCH 14/30] update lock --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f409e91b29..c6b0aaf88a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -546,9 +546,6 @@ importers: specifier: ^19.0.0 version: 19.0.0 devDependencies: - '@react-email/render': - specifier: workspace:* - version: link:../render '@types/prismjs': specifier: 1.26.5 version: 1.26.5 From 21b608187b65c2d01ed24412dafb2eaf630c4673 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Fri, 20 Feb 2026 10:07:23 -0300 Subject: [PATCH 15/30] unpin dependencies --- packages/editor/package.json | 22 +++++++++++----------- pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index 857c599430..d4dfbb79f5 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.0", + "version": "0.0.0-experimental.1", "description": "", "sideEffects": false, "main": "./dist/index.js", @@ -42,18 +42,18 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react": "^18.0 || ^19.0 || ^19.0.0-rc", - "@react-email/components": "workspace:*" + "@react-email/components": "workspace:*", + "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "dependencies": { - "@tiptap/core": "3.10.7", - "@tiptap/extension-code-block": "3.10.7", - "@tiptap/extension-heading": "3.10.7", - "@tiptap/extension-horizontal-rule": "3.10.7", - "@tiptap/pm": "3.10.7", - "@tiptap/react": "3.10.7", - "hast-util-from-html": "2.0.3", - "prismjs": "1.30.0" + "@tiptap/core": "^3.10.7", + "@tiptap/extension-code-block": "^3.10.7", + "@tiptap/extension-heading": "^3.10.7", + "@tiptap/extension-horizontal-rule": "^3.10.7", + "@tiptap/pm": "^3.10.7", + "@tiptap/react": "^3.10.7", + "hast-util-from-html": "^2.0.3", + "prismjs": "^1.30.0" }, "devDependencies": { "@types/prismjs": "1.26.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6b0aaf88a..ca0f040cf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -519,28 +519,28 @@ importers: specifier: workspace:* version: link:../components '@tiptap/core': - specifier: 3.10.7 + specifier: ^3.10.7 version: 3.10.7(@tiptap/pm@3.10.7) '@tiptap/extension-code-block': - specifier: 3.10.7 + specifier: ^3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) '@tiptap/extension-heading': - specifier: 3.10.7 + specifier: ^3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) '@tiptap/extension-horizontal-rule': - specifier: 3.10.7 + specifier: ^3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) '@tiptap/pm': - specifier: 3.10.7 + specifier: ^3.10.7 version: 3.10.7 '@tiptap/react': - specifier: 3.10.7 + specifier: ^3.10.7 version: 3.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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) hast-util-from-html: - specifier: 2.0.3 + specifier: ^2.0.3 version: 2.0.3 prismjs: - specifier: 1.30.0 + specifier: ^1.30.0 version: 1.30.0 react: specifier: ^19.0.0 From 73b6a274a083edc719ef5df17a566694fbe59ed8 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Fri, 20 Feb 2026 11:05:17 -0300 Subject: [PATCH 16/30] add table component --- .../__snapshots__/table.spec.tsx.snap | 42 +++ packages/editor/src/extensions/index.ts | 1 + packages/editor/src/extensions/table.spec.tsx | 68 ++++ packages/editor/src/extensions/table.tsx | 280 ++++++++++++++++ packages/editor/src/utils/styles.spec.ts | 298 +++++++++++++++++- packages/editor/src/utils/styles.ts | 26 ++ 6 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap create mode 100644 packages/editor/src/extensions/table.spec.tsx create mode 100644 packages/editor/src/extensions/table.tsx 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/index.ts b/packages/editor/src/extensions/index.ts index b4b853fc15..4fc9514cbd 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -3,3 +3,4 @@ export * from './button'; export * from './code-block'; export * from './div'; export * from './section'; +export * from './table'; diff --git a/packages/editor/src/extensions/table.spec.tsx b/packages/editor/src/extensions/table.spec.tsx new file mode 100644 index 0000000000..61f4c3117a --- /dev/null +++ b/packages/editor/src/extensions/table.spec.tsx @@ -0,0 +1,68 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Table, TableCell, TableRow } from './table'; + +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..008034febe --- /dev/null +++ b/packages/editor/src/extensions/table.tsx @@ -0,0 +1,280 @@ +import { Column, Section } from '@react-email/components'; +import type { ParentConfig } from '@tiptap/core'; +import { mergeAttributes, Node } from '@tiptap/core'; +import { EmailNode } from '../core/email-node'; +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 = EmailNode.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, styles }) { + 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} +
+ ); + }, +}); + +interface TableRowOptions extends Record { + HTMLAttributes?: Record; +} + +export const TableRow = EmailNode.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, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( + + {children} + + ); + }, +}); + +interface TableCellOptions extends Record { + HTMLAttributes?: Record; +} + +export const TableCell = EmailNode.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, styles }) { + 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/utils/styles.spec.ts b/packages/editor/src/utils/styles.spec.ts index e22c1507e1..ea9552c211 100644 --- a/packages/editor/src/utils/styles.spec.ts +++ b/packages/editor/src/utils/styles.spec.ts @@ -1,10 +1,306 @@ -import { describe, expect, it } from 'vitest'; import { 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;'); diff --git a/packages/editor/src/utils/styles.ts b/packages/editor/src/utils/styles.ts index b48fb5e14a..a54082c6f1 100644 --- a/packages/editor/src/utils/styles.ts +++ b/packages/editor/src/utils/styles.ts @@ -1,3 +1,5 @@ +import type { CssJs } from "./types"; + const WHITE_SPACE_REGEX = /\s+/; export const jsToInlineCss = (styleObject: { [key: string]: any }) => { @@ -226,3 +228,27 @@ function convertBorderValue(value: string | number): { }; } } + +/** + * 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, + }; +} From d0cc78d73ed1c40224b077ec4b1a996a9433f42e Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 23 Feb 2026 09:35:23 -0300 Subject: [PATCH 17/30] add style-attribute --- packages/editor/package.json | 2 + .../src/extensions/style-attribute.spec.ts | 84 +++++++ .../editor/src/extensions/style-attribute.tsx | 99 ++++++++ pnpm-lock.yaml | 232 ++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 packages/editor/src/extensions/style-attribute.spec.ts create mode 100644 packages/editor/src/extensions/style-attribute.tsx diff --git a/packages/editor/package.json b/packages/editor/package.json index d4dfbb79f5..9617cc2d8b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -51,7 +51,9 @@ "@tiptap/extension-heading": "^3.10.7", "@tiptap/extension-horizontal-rule": "^3.10.7", "@tiptap/pm": "^3.10.7", + "@tiptap/html": "^3.10.7", "@tiptap/react": "^3.10.7", + "@tiptap/starter-kit": "^3.10.7", "hast-util-from-html": "^2.0.3", "prismjs": "^1.30.0" }, 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..93f74c86a2 --- /dev/null +++ b/packages/editor/src/extensions/style-attribute.tsx @@ -0,0 +1,99 @@ +import { Extension } from '@tiptap/core'; + +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/pnpm-lock.yaml b/pnpm-lock.yaml index ca0f040cf6..38039e30a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,12 +530,18 @@ importers: '@tiptap/extension-horizontal-rule': specifier: ^3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/html': + specifier: ^3.10.7 + version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(happy-dom@20.0.11) '@tiptap/pm': specifier: ^3.10.7 version: 3.10.7 '@tiptap/react': specifier: ^3.10.7 version: 3.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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.10.7 + version: 3.10.7 hast-util-from-html: specifier: ^2.0.3 version: 2.0.3 @@ -4593,18 +4599,48 @@ packages: peerDependencies: '@tiptap/pm': ^3.10.7 + '@tiptap/extension-blockquote@3.20.0': + resolution: {integrity: sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-bold@3.20.0': + resolution: {integrity: sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/extension-bubble-menu@3.19.0': resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} peerDependencies: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-bullet-list@3.20.0': + resolution: {integrity: sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + '@tiptap/extension-code-block@3.10.7': resolution: {integrity: sha512-Z6EH/DhSVQtOKL+vS9J2dbvJ81T3xJ2Htgn4BOxpuCGUCInu5Aymf/53tco3aQse/UHB3Gvr+/4AOwxphXYhgw==} peerDependencies: '@tiptap/core': ^3.10.7 '@tiptap/pm': ^3.10.7 + '@tiptap/extension-code@3.20.0': + resolution: {integrity: sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-document@3.20.0': + resolution: {integrity: sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-dropcursor@3.20.0': + resolution: {integrity: sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + '@tiptap/extension-floating-menu@3.19.0': resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} peerDependencies: @@ -4612,6 +4648,16 @@ packages: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-gapcursor@3.20.0': + resolution: {integrity: sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-hard-break@3.20.0': + resolution: {integrity: sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/extension-heading@3.10.7': resolution: {integrity: sha512-Pp0LYTEyimDfiXzy+8Ls2LDuhhmyM7jXr8go3myTHSLMTpt0ch7P5FVSnDxMFtQ5eRiAwXHET63/JOaiIwMa/w==} peerDependencies: @@ -4623,6 +4669,71 @@ packages: '@tiptap/core': ^3.10.7 '@tiptap/pm': ^3.10.7 + '@tiptap/extension-italic@3.20.0': + resolution: {integrity: sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-link@3.20.0': + resolution: {integrity: sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-list-item@3.20.0': + resolution: {integrity: sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-list-keymap@3.20.0': + resolution: {integrity: sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-list@3.20.0': + resolution: {integrity: sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-ordered-list@3.20.0': + resolution: {integrity: sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-paragraph@3.20.0': + resolution: {integrity: sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-strike@3.20.0': + resolution: {integrity: sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-text@3.20.0': + resolution: {integrity: sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-underline@3.20.0': + resolution: {integrity: sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extensions@3.20.0': + resolution: {integrity: sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/html@3.10.7': + resolution: {integrity: sha512-IIR1Q2FccjcjE9P77lNOD0h+wLgmAw6Cq3vNXOZNWiv+mhzo0A+C2nPAJiTbKS2Fhisdpbo1UNOvs3tvypOXcQ==} + peerDependencies: + '@tiptap/core': ^3.10.7 + '@tiptap/pm': ^3.10.7 + happy-dom: ^20.0.2 + '@tiptap/pm@3.10.7': resolution: {integrity: sha512-/iiurioqSukJk6CrEtfRpdOEafDybyVPToAllgn7i2XcusXSxJSX+K0GUndMUwVR+UqVOCyMYBTRTnE0hdQqgA==} @@ -4636,6 +4747,9 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 + '@tiptap/starter-kit@3.10.7': + resolution: {integrity: sha512-5oGeTzD8tvyeVICHNHJC/wsymacvXlyUQChlrweWYM8eMvNgzP/f14myiF0PyNlXJrGXkYtsAfq5004KeyLW/w==} + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -7191,6 +7305,9 @@ packages: 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'} @@ -13549,6 +13666,14 @@ snapshots: dependencies: '@tiptap/pm': 3.10.7 + '@tiptap/extension-blockquote@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-bold@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': dependencies: '@floating-ui/dom': 1.6.12 @@ -13556,11 +13681,27 @@ snapshots: '@tiptap/pm': 3.10.7 optional: true + '@tiptap/extension-bullet-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-code-block@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': dependencies: '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) '@tiptap/pm': 3.10.7 + '@tiptap/extension-code@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-document@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-dropcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': dependencies: '@floating-ui/dom': 1.6.12 @@ -13568,6 +13709,14 @@ snapshots: '@tiptap/pm': 3.10.7 optional: true + '@tiptap/extension-gapcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + + '@tiptap/extension-hard-break@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/extension-heading@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': dependencies: '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) @@ -13577,6 +13726,60 @@ snapshots: '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) '@tiptap/pm': 3.10.7 + '@tiptap/extension-italic@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-link@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + + '@tiptap/extension-list-keymap@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + + '@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + + '@tiptap/extension-ordered-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + + '@tiptap/extension-paragraph@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-strike@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-text@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extension-underline@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + + '@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + + '@tiptap/html@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(happy-dom@20.0.11)': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + happy-dom: 20.0.11 + '@tiptap/pm@3.10.7': dependencies: prosemirror-changeset: 2.4.0 @@ -13615,6 +13818,33 @@ snapshots: transitivePeerDependencies: - '@floating-ui/dom' + '@tiptap/starter-kit@3.10.7': + dependencies: + '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/extension-blockquote': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-bold': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-bullet-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + '@tiptap/extension-code': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-code-block': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-document': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-dropcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + '@tiptap/extension-gapcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + '@tiptap/extension-hard-break': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-heading': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-horizontal-rule': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-italic': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-link': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-list-item': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + '@tiptap/extension-list-keymap': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + '@tiptap/extension-ordered-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + '@tiptap/extension-paragraph': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-strike': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-text': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extension-underline': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/pm': 3.10.7 + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tweenjs/tween.js@23.1.3': {} @@ -16504,6 +16734,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkifyjs@4.3.2: {} + loader-runner@4.3.1: {} locate-path@5.0.0: From 3578eb37272426226a5a93bbe1696c8020321dd6 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 23 Feb 2026 09:39:27 -0300 Subject: [PATCH 18/30] add other simple extensions and export them --- packages/editor/package.json | 3 +- .../src/extensions/alignment-attribute.tsx | 102 ++++++++++ packages/editor/src/extensions/bold.ts | 147 ++++++++++++++ .../editor/src/extensions/class-attribute.tsx | 80 ++++++++ packages/editor/src/extensions/index.ts | 8 + packages/editor/src/extensions/max-nesting.ts | 135 +++++++++++++ .../editor/src/extensions/placeholder.spec.ts | 183 ++++++++++++++++++ packages/editor/src/extensions/placeholder.ts | 24 +++ .../editor/src/extensions/preserved-style.ts | 116 +++++++++++ .../editor/src/extensions/preview-text.ts | 82 ++++++++ packages/editor/src/extensions/sup.ts | 79 ++++++++ pnpm-lock.yaml | 12 ++ 12 files changed, 970 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/extensions/alignment-attribute.tsx create mode 100644 packages/editor/src/extensions/bold.ts create mode 100644 packages/editor/src/extensions/class-attribute.tsx create mode 100644 packages/editor/src/extensions/max-nesting.ts create mode 100644 packages/editor/src/extensions/placeholder.spec.ts create mode 100644 packages/editor/src/extensions/placeholder.ts create mode 100644 packages/editor/src/extensions/preserved-style.ts create mode 100644 packages/editor/src/extensions/preview-text.ts create mode 100644 packages/editor/src/extensions/sup.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 9617cc2d8b..c14df2b352 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -50,8 +50,9 @@ "@tiptap/extension-code-block": "^3.10.7", "@tiptap/extension-heading": "^3.10.7", "@tiptap/extension-horizontal-rule": "^3.10.7", - "@tiptap/pm": "^3.10.7", + "@tiptap/extension-placeholder": "^3.10.7", "@tiptap/html": "^3.10.7", + "@tiptap/pm": "^3.10.7", "@tiptap/react": "^3.10.7", "@tiptap/starter-kit": "^3.10.7", "hast-util-from-html": "^2.0.3", diff --git a/packages/editor/src/extensions/alignment-attribute.tsx b/packages/editor/src/extensions/alignment-attribute.tsx new file mode 100644 index 0000000000..0234a04204 --- /dev/null +++ b/packages/editor/src/extensions/alignment-attribute.tsx @@ -0,0 +1,102 @@ +import { Extension } from '@tiptap/core'; + +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/bold.ts b/packages/editor/src/extensions/bold.ts new file mode 100644 index 0000000000..95863e4738 --- /dev/null +++ b/packages/editor/src/extensions/bold.ts @@ -0,0 +1,147 @@ +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core'; + +export interface BoldOptions { + /** + * HTML attributes to add to the bold element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + bold: { + /** + * Set a bold mark + */ + setBold: () => ReturnType; + /** + * Toggle a bold mark + */ + toggleBold: () => ReturnType; + /** + * Unset a bold mark + */ + unsetBold: () => ReturnType; + }; + } +} + +/** + * Matches bold text via `**` as input. + */ +const starInputRegex = /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/; + +/** + * Matches bold text via `**` while pasting. + */ +const starPasteRegex = /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))/g; + +/** + * Matches bold text via `__` as input. + */ +const underscoreInputRegex = /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/; + +/** + * Matches bold text via `__` while pasting. + */ +const underscorePasteRegex = /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))/g; + +/** + * This extension allows you to mark text as bold. + * @see https://tiptap.dev/api/marks/bold + */ +export const Bold = Mark.create({ + name: 'bold', + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + 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, + }, + // Removed the font-weight inference rule that was in the original + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'strong', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addCommands() { + return { + setBold: + () => + ({ commands }) => { + return commands.setMark(this.name); + }, + toggleBold: + () => + ({ commands }) => { + return commands.toggleMark(this.name); + }, + unsetBold: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, + + addKeyboardShortcuts() { + return { + 'Mod-b': () => this.editor.commands.toggleBold(), + 'Mod-B': () => this.editor.commands.toggleBold(), + }; + }, + + addInputRules() { + return [ + markInputRule({ + find: starInputRegex, + type: this.type, + }), + markInputRule({ + find: underscoreInputRegex, + type: this.type, + }), + ]; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: starPasteRegex, + type: this.type, + }), + markPasteRule({ + find: underscorePasteRegex, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/editor/src/extensions/class-attribute.tsx b/packages/editor/src/extensions/class-attribute.tsx new file mode 100644 index 0000000000..db660d61f9 --- /dev/null +++ b/packages/editor/src/extensions/class-attribute.tsx @@ -0,0 +1,80 @@ +import { Extension } from '@tiptap/core'; + +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/index.ts b/packages/editor/src/extensions/index.ts index 4fc9514cbd..f592c3d59e 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -1,6 +1,14 @@ +export * from './alignment-attribute'; export * from './body'; +export * from './bold'; export * from './button'; +export * from './class-attribute'; export * from './code-block'; export * from './div'; +export * from './max-nesting'; +export * from './placeholder'; +export * from './preserved-style'; export * from './section'; +export * from './style-attribute'; +export * from './sup'; export * from './table'; diff --git a/packages/editor/src/extensions/max-nesting.ts b/packages/editor/src/extensions/max-nesting.ts new file mode 100644 index 0000000000..baf6776849 --- /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'; + +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/placeholder.spec.ts b/packages/editor/src/extensions/placeholder.spec.ts new file mode 100644 index 0000000000..f9ab75fadd --- /dev/null +++ b/packages/editor/src/extensions/placeholder.spec.ts @@ -0,0 +1,183 @@ +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPlaceholderExtension, 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 = createPlaceholderExtension({ + includeChildren: false, + }); + + 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..520d8911ad --- /dev/null +++ b/packages/editor/src/extensions/placeholder.ts @@ -0,0 +1,24 @@ +import Placeholder from '@tiptap/extension-placeholder'; +import type { Node } from '@tiptap/pm/model'; + +export interface PlaceholderOptions { + placeholder?: string | ((props: { node: Node }) => string); + includeChildren?: boolean; +} + +export const createPlaceholderExtension = ( + options?: Partial, +) => { + return Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Press '/' for commands"; + }, + includeChildren: true, + ...options, + }); +}; + +export const placeholder = createPlaceholderExtension(); diff --git a/packages/editor/src/extensions/preserved-style.ts b/packages/editor/src/extensions/preserved-style.ts new file mode 100644 index 0000000000..5b1b292256 --- /dev/null +++ b/packages/editor/src/extensions/preserved-style.ts @@ -0,0 +1,116 @@ +import { Mark, mergeAttributes } from '@tiptap/core'; + +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]; + }, +}); + +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/sup.ts b/packages/editor/src/extensions/sup.ts new file mode 100644 index 0000000000..537d5ba77e --- /dev/null +++ b/packages/editor/src/extensions/sup.ts @@ -0,0 +1,79 @@ +import { Mark, mergeAttributes } from '@tiptap/core'; + +export interface SupOptions { + /** + * HTML attributes to add to the sup element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; +} + +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; + }; + } +} + +/** + * This extension allows you to mark text as superscript. + * @see https://tiptap.dev/api/marks/superscript + */ +export const Sup = Mark.create({ + name: 'sup', + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + parseHTML() { + return [ + { + tag: 'sup', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'sup', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addCommands() { + return { + setSup: + () => + ({ commands }) => { + return commands.setMark(this.name); + }, + toggleSup: + () => + ({ commands }) => { + return commands.toggleMark(this.name); + }, + unsetSup: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38039e30a3..da6017979d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,6 +530,9 @@ importers: '@tiptap/extension-horizontal-rule': specifier: ^3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-placeholder': + specifier: ^3.10.7 + version: 3.10.7(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) '@tiptap/html': specifier: ^3.10.7 version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(happy-dom@20.0.11) @@ -4706,6 +4709,11 @@ packages: peerDependencies: '@tiptap/core': ^3.20.0 + '@tiptap/extension-placeholder@3.10.7': + resolution: {integrity: sha512-fznCXL6tSbt+h/f1pTtpCnqR9WrTdiMdk51Ds99BHGaI38ssn86RTmXsFnAPI1VZypMgUJ8f3CHSAdTiPoCWNg==} + peerDependencies: + '@tiptap/extensions': ^3.10.7 + '@tiptap/extension-strike@3.20.0': resolution: {integrity: sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==} peerDependencies: @@ -13757,6 +13765,10 @@ snapshots: dependencies: '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/extension-placeholder@3.10.7(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-strike@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': dependencies: '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) From c9c79cdd4422fd40f0190fb89395ff9b532b820c Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 23 Feb 2026 09:39:37 -0300 Subject: [PATCH 19/30] bump version --- packages/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index c14df2b352..95e70cd7bc 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.1", + "version": "0.0.0-experimental.2", "description": "", "sideEffects": false, "main": "./dist/index.js", From 79e6742f618b1ab999872c073542dae9a3137a88 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 23 Feb 2026 09:42:29 -0300 Subject: [PATCH 20/30] no createPlaceholder --- .../editor/src/extensions/placeholder.spec.ts | 17 +++++++----- packages/editor/src/extensions/placeholder.ts | 27 +++++++------------ 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/editor/src/extensions/placeholder.spec.ts b/packages/editor/src/extensions/placeholder.spec.ts index f9ab75fadd..f4589d3528 100644 --- a/packages/editor/src/extensions/placeholder.spec.ts +++ b/packages/editor/src/extensions/placeholder.spec.ts @@ -1,14 +1,14 @@ import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createPlaceholderExtension, placeholder } from './placeholder'; +import { Placeholder } from './placeholder'; describe('Placeholder Extension', () => { let editor: Editor; beforeEach(() => { editor = new Editor({ - extensions: [StarterKit.configure({}), placeholder], + extensions: [StarterKit.configure({}), Placeholder], content: '', }); }); @@ -28,10 +28,13 @@ describe('Placeholder Extension', () => { }); it('allows custom configuration', () => { - const customExtension = createPlaceholderExtension({ + const customExtension = Placeholder.configure({ includeChildren: false, }); + expect(customExtension.options.placeholder).toStrictEqual( + Placeholder.options.placeholder, + ); expect(customExtension.options.includeChildren).toBe(false); }); }); @@ -54,7 +57,7 @@ describe('Placeholder Extension', () => { it('returns heading placeholder for heading nodes', () => { const testEditor = new Editor({ - extensions: [StarterKit.configure({ heading: {} }), placeholder], + extensions: [StarterKit.configure({ heading: {} }), Placeholder], content: '', }); @@ -76,7 +79,7 @@ describe('Placeholder Extension', () => { it('supports different heading levels', () => { const testEditor = new Editor({ - extensions: [StarterKit.configure({ heading: {} }), placeholder], + extensions: [StarterKit.configure({ heading: {} }), Placeholder], content: '', }); @@ -107,7 +110,7 @@ describe('Placeholder Extension', () => { expect(paragraph).toBeTruthy(); expect( paragraph?.classList.contains('is-empty') || - paragraph?.hasAttribute('data-placeholder'), + paragraph?.hasAttribute('data-placeholder'), ).toBe(true); }); @@ -157,7 +160,7 @@ describe('Placeholder Extension', () => { expect(paragraphs[0].textContent).toBe(''); expect( paragraphs[0].classList.contains('is-empty') || - paragraphs[0].hasAttribute('data-placeholder'), + paragraphs[0].hasAttribute('data-placeholder'), ).toBe(true); }); diff --git a/packages/editor/src/extensions/placeholder.ts b/packages/editor/src/extensions/placeholder.ts index 520d8911ad..d0d7cc9643 100644 --- a/packages/editor/src/extensions/placeholder.ts +++ b/packages/editor/src/extensions/placeholder.ts @@ -1,4 +1,4 @@ -import Placeholder from '@tiptap/extension-placeholder'; +import TipTapPlaceholder from '@tiptap/extension-placeholder'; import type { Node } from '@tiptap/pm/model'; export interface PlaceholderOptions { @@ -6,19 +6,12 @@ export interface PlaceholderOptions { includeChildren?: boolean; } -export const createPlaceholderExtension = ( - options?: Partial, -) => { - return Placeholder.configure({ - placeholder: ({ node }) => { - if (node.type.name === 'heading') { - return `Heading ${node.attrs.level}`; - } - return "Press '/' for commands"; - }, - includeChildren: true, - ...options, - }); -}; - -export const placeholder = createPlaceholderExtension(); +export const Placeholder = TipTapPlaceholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Press '/' for commands"; + }, + includeChildren: true, +}); From b50db9df669bd9543f43fbdad3c6488072224279 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 23 Feb 2026 10:11:38 -0300 Subject: [PATCH 21/30] bump --- packages/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index 95e70cd7bc..b2eb053ec4 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.2", + "version": "0.0.0-experimental.3", "description": "", "sideEffects": false, "main": "./dist/index.js", From 09478914a0bb05ce529000e00e4ccdc6ed3de0e8 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 23 Feb 2026 10:14:25 -0300 Subject: [PATCH 22/30] export preview-text, bump --- packages/editor/package.json | 2 +- packages/editor/src/extensions/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index b2eb053ec4..54706b793d 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.3", + "version": "0.0.0-experimental.4", "description": "", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts index f592c3d59e..70f58e76ef 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -8,6 +8,7 @@ export * from './div'; export * from './max-nesting'; export * from './placeholder'; export * from './preserved-style'; +export * from './preview-text'; export * from './section'; export * from './style-attribute'; export * from './sup'; From 5d8b191d885589ed05e9dcecaab27aa40dba52a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Tue, 24 Feb 2026 08:03:29 -0300 Subject: [PATCH 23/30] feat(editor): add columns extension (#2973) Co-authored-by: Claude Opus 4.6 (1M context) --- packages/editor/package.json | 20 +- .../__snapshots__/columns.spec.tsx.snap | 124 +++++ .../editor/src/extensions/columns.spec.tsx | 166 +++++++ packages/editor/src/extensions/columns.tsx | 236 +++++++++ packages/editor/src/extensions/index.ts | 1 + .../editor/src/extensions/placeholder.spec.ts | 4 +- packages/editor/src/extensions/table.spec.tsx | 1 - packages/editor/src/extensions/table.tsx | 14 +- packages/editor/src/utils/styles.ts | 2 +- pnpm-lock.yaml | 446 +++++++++++------- 10 files changed, 826 insertions(+), 188 deletions(-) create mode 100644 packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap create mode 100644 packages/editor/src/extensions/columns.spec.tsx create mode 100644 packages/editor/src/extensions/columns.tsx diff --git a/packages/editor/package.json b/packages/editor/package.json index 54706b793d..1a07b5b153 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.4", + "version": "0.0.0-experimental.5", "description": "", "sideEffects": false, "main": "./dist/index.js", @@ -46,15 +46,15 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "dependencies": { - "@tiptap/core": "^3.10.7", - "@tiptap/extension-code-block": "^3.10.7", - "@tiptap/extension-heading": "^3.10.7", - "@tiptap/extension-horizontal-rule": "^3.10.7", - "@tiptap/extension-placeholder": "^3.10.7", - "@tiptap/html": "^3.10.7", - "@tiptap/pm": "^3.10.7", - "@tiptap/react": "^3.10.7", - "@tiptap/starter-kit": "^3.10.7", + "@tiptap/core": "^3.17.1", + "@tiptap/extension-code-block": "^3.17.1", + "@tiptap/extension-heading": "^3.17.1", + "@tiptap/extension-horizontal-rule": "^3.17.1", + "@tiptap/extension-placeholder": "^3.17.1", + "@tiptap/html": "^3.17.1", + "@tiptap/pm": "^3.17.1", + "@tiptap/react": "^3.17.1", + "@tiptap/starter-kit": "^3.17.1", "hast-util-from-html": "^2.0.3", "prismjs": "^1.30.0" }, 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`] = ` +" + + + + + + + + + + +
ABCD
+ +" +`; + +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/columns.spec.tsx b/packages/editor/src/extensions/columns.spec.tsx new file mode 100644 index 0000000000..cfc4437132 --- /dev/null +++ b/packages/editor/src/extensions/columns.spec.tsx @@ -0,0 +1,166 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { + ColumnsColumn, + FourColumns, + ThreeColumns, + TwoColumns, +} from './columns'; + +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..973de22485 --- /dev/null +++ b/packages/editor/src/extensions/columns.tsx @@ -0,0 +1,236 @@ +import { Column, Row } from '@react-email/components'; +import { type CommandProps, mergeAttributes } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { EmailNode } from '../core/email-node'; +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 EmailNode.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, styles }) { + 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 = EmailNode.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 { + '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, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + const width = node.attrs?.width; + return ( + + {children} + + ); + }, +}); diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts index 70f58e76ef..5f7ab5f784 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -4,6 +4,7 @@ export * from './bold'; export * from './button'; export * from './class-attribute'; export * from './code-block'; +export * from './columns'; export * from './div'; export * from './max-nesting'; export * from './placeholder'; diff --git a/packages/editor/src/extensions/placeholder.spec.ts b/packages/editor/src/extensions/placeholder.spec.ts index f4589d3528..162dffe70f 100644 --- a/packages/editor/src/extensions/placeholder.spec.ts +++ b/packages/editor/src/extensions/placeholder.spec.ts @@ -110,7 +110,7 @@ describe('Placeholder Extension', () => { expect(paragraph).toBeTruthy(); expect( paragraph?.classList.contains('is-empty') || - paragraph?.hasAttribute('data-placeholder'), + paragraph?.hasAttribute('data-placeholder'), ).toBe(true); }); @@ -160,7 +160,7 @@ describe('Placeholder Extension', () => { expect(paragraphs[0].textContent).toBe(''); expect( paragraphs[0].classList.contains('is-empty') || - paragraphs[0].hasAttribute('data-placeholder'), + paragraphs[0].hasAttribute('data-placeholder'), ).toBe(true); }); diff --git a/packages/editor/src/extensions/table.spec.tsx b/packages/editor/src/extensions/table.spec.tsx index 61f4c3117a..5289306626 100644 --- a/packages/editor/src/extensions/table.spec.tsx +++ b/packages/editor/src/extensions/table.spec.tsx @@ -65,4 +65,3 @@ describe('Table Nodes', () => { ).toMatchSnapshot(); }); }); - diff --git a/packages/editor/src/extensions/table.tsx b/packages/editor/src/extensions/table.tsx index 008034febe..373358be27 100644 --- a/packages/editor/src/extensions/table.tsx +++ b/packages/editor/src/extensions/table.tsx @@ -20,13 +20,13 @@ declare module '@tiptap/core' { * @example () => 'table' */ tableRole?: - | string - | ((this: { - name: string; - options: Options; - storage: Storage; - parent: ParentConfig>['tableRole']; - }) => string); + | string + | ((this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>['tableRole']; + }) => string); } } diff --git a/packages/editor/src/utils/styles.ts b/packages/editor/src/utils/styles.ts index a54082c6f1..1807ceb948 100644 --- a/packages/editor/src/utils/styles.ts +++ b/packages/editor/src/utils/styles.ts @@ -1,4 +1,4 @@ -import type { CssJs } from "./types"; +import type { CssJs } from './types'; const WHITE_SPACE_REGEX = /\s+/; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6017979d..6ab2d9ba4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ importers: dependencies: mintlify: specifier: 4.2.280 - version: 4.2.280(@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.280(@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.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) zod: specifier: 3.24.3 version: 3.24.3 @@ -519,32 +519,32 @@ importers: specifier: workspace:* version: link:../components '@tiptap/core': - specifier: ^3.10.7 - version: 3.10.7(@tiptap/pm@3.10.7) + specifier: ^3.17.1 + version: 3.20.0(@tiptap/pm@3.20.0) '@tiptap/extension-code-block': - specifier: ^3.10.7 - version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) '@tiptap/extension-heading': - specifier: ^3.10.7 - version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) '@tiptap/extension-horizontal-rule': - specifier: ^3.10.7 - version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) '@tiptap/extension-placeholder': - specifier: ^3.10.7 - version: 3.10.7(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) + specifier: ^3.17.1 + version: 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) '@tiptap/html': - specifier: ^3.10.7 - version: 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(happy-dom@20.0.11) + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(happy-dom@20.0.11) '@tiptap/pm': - specifier: ^3.10.7 - version: 3.10.7 + specifier: ^3.17.1 + version: 3.20.0 '@tiptap/react': - specifier: ^3.10.7 - version: 3.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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) + specifier: ^3.17.1 + version: 3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@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.10.7 - version: 3.10.7 + specifier: ^3.17.1 + version: 3.20.0 hast-util-from-html: specifier: ^2.0.3 version: 2.0.3 @@ -4597,10 +4597,10 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} - '@tiptap/core@3.10.7': - resolution: {integrity: sha512-4rD3oHkXNOS6Fxm0mr+ECyq35iMFnnAXheIO+UsQbOexwTxn2yZ5Q1rQiFKcCf+p+rrg1yt8TtxQPM8VLWS+1g==} + '@tiptap/core@3.20.0': + resolution: {integrity: sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==} peerDependencies: - '@tiptap/pm': ^3.10.7 + '@tiptap/pm': ^3.20.0 '@tiptap/extension-blockquote@3.20.0': resolution: {integrity: sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==} @@ -4612,22 +4612,22 @@ packages: peerDependencies: '@tiptap/core': ^3.20.0 - '@tiptap/extension-bubble-menu@3.19.0': - resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} + '@tiptap/extension-bubble-menu@3.20.0': + resolution: {integrity: sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==} peerDependencies: - '@tiptap/core': ^3.19.0 - '@tiptap/pm': ^3.19.0 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 '@tiptap/extension-bullet-list@3.20.0': resolution: {integrity: sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==} peerDependencies: '@tiptap/extension-list': ^3.20.0 - '@tiptap/extension-code-block@3.10.7': - resolution: {integrity: sha512-Z6EH/DhSVQtOKL+vS9J2dbvJ81T3xJ2Htgn4BOxpuCGUCInu5Aymf/53tco3aQse/UHB3Gvr+/4AOwxphXYhgw==} + '@tiptap/extension-code-block@3.20.0': + resolution: {integrity: sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==} peerDependencies: - '@tiptap/core': ^3.10.7 - '@tiptap/pm': ^3.10.7 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 '@tiptap/extension-code@3.20.0': resolution: {integrity: sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==} @@ -4644,12 +4644,12 @@ packages: peerDependencies: '@tiptap/extensions': ^3.20.0 - '@tiptap/extension-floating-menu@3.19.0': - resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} + '@tiptap/extension-floating-menu@3.20.0': + resolution: {integrity: sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.19.0 - '@tiptap/pm': ^3.19.0 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 '@tiptap/extension-gapcursor@3.20.0': resolution: {integrity: sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==} @@ -4661,16 +4661,16 @@ packages: peerDependencies: '@tiptap/core': ^3.20.0 - '@tiptap/extension-heading@3.10.7': - resolution: {integrity: sha512-Pp0LYTEyimDfiXzy+8Ls2LDuhhmyM7jXr8go3myTHSLMTpt0ch7P5FVSnDxMFtQ5eRiAwXHET63/JOaiIwMa/w==} + '@tiptap/extension-heading@3.20.0': + resolution: {integrity: sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==} peerDependencies: - '@tiptap/core': ^3.10.7 + '@tiptap/core': ^3.20.0 - '@tiptap/extension-horizontal-rule@3.10.7': - resolution: {integrity: sha512-V9uWb341QUBDDbR3aoSs3Sx0PQQaKwZ/ESVEE03El9rkIrf8g5K82x8/M0nvSOvGobt6oRyI/rgbj196YQuXiQ==} + '@tiptap/extension-horizontal-rule@3.20.0': + resolution: {integrity: sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==} peerDependencies: - '@tiptap/core': ^3.10.7 - '@tiptap/pm': ^3.10.7 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 '@tiptap/extension-italic@3.20.0': resolution: {integrity: sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==} @@ -4709,10 +4709,10 @@ packages: peerDependencies: '@tiptap/core': ^3.20.0 - '@tiptap/extension-placeholder@3.10.7': - resolution: {integrity: sha512-fznCXL6tSbt+h/f1pTtpCnqR9WrTdiMdk51Ds99BHGaI38ssn86RTmXsFnAPI1VZypMgUJ8f3CHSAdTiPoCWNg==} + '@tiptap/extension-placeholder@3.20.0': + resolution: {integrity: sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==} peerDependencies: - '@tiptap/extensions': ^3.10.7 + '@tiptap/extensions': ^3.20.0 '@tiptap/extension-strike@3.20.0': resolution: {integrity: sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==} @@ -4735,28 +4735,28 @@ packages: '@tiptap/core': ^3.20.0 '@tiptap/pm': ^3.20.0 - '@tiptap/html@3.10.7': - resolution: {integrity: sha512-IIR1Q2FccjcjE9P77lNOD0h+wLgmAw6Cq3vNXOZNWiv+mhzo0A+C2nPAJiTbKS2Fhisdpbo1UNOvs3tvypOXcQ==} + '@tiptap/html@3.20.0': + resolution: {integrity: sha512-dbkXYp/ye3pheHcgJHI1Q3dWJWQnufd+Ki2Md1TQJuL39+J3M41bZQOWdn3QM0GYAzWlVhKBTJrL0ek6y7/Dlw==} peerDependencies: - '@tiptap/core': ^3.10.7 - '@tiptap/pm': ^3.10.7 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 happy-dom: ^20.0.2 - '@tiptap/pm@3.10.7': - resolution: {integrity: sha512-/iiurioqSukJk6CrEtfRpdOEafDybyVPToAllgn7i2XcusXSxJSX+K0GUndMUwVR+UqVOCyMYBTRTnE0hdQqgA==} + '@tiptap/pm@3.20.0': + resolution: {integrity: sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==} - '@tiptap/react@3.10.7': - resolution: {integrity: sha512-hhKj62zvs/mSu5HlcmZDRFHVHCjJ6v6/7vB45MTAziP+cZ0+CEbEh2rnGNRNwooumWwm5pWdkVqI1efp7GtnUA==} + '@tiptap/react@3.20.0': + resolution: {integrity: sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==} peerDependencies: - '@tiptap/core': ^3.10.7 - '@tiptap/pm': ^3.10.7 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 '@types/react': 19.2.13 '@types/react-dom': 19.2.3 react: ^19.0.0 react-dom: ^19.0.0 - '@tiptap/starter-kit@3.10.7': - resolution: {integrity: sha512-5oGeTzD8tvyeVICHNHJC/wsymacvXlyUQChlrweWYM8eMvNgzP/f14myiF0PyNlXJrGXkYtsAfq5004KeyLW/w==} + '@tiptap/starter-kit@3.20.0': + resolution: {integrity: sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==} '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -6285,6 +6285,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==} @@ -11588,6 +11592,36 @@ snapshots: - acorn - supports-color + '@mdx-js/mdx@3.1.0(acorn@8.15.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.15.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 @@ -11596,15 +11630,15 @@ snapshots: '@mediapipe/tasks-vision@0.10.17': {} - '@mintlify/cli@4.0.884(@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.884(@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.15.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.666(@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.823(@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.823(@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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) '@mintlify/models': 0.0.257 - '@mintlify/prebuild': 1.0.801(@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.857(@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/validation': 0.1.558(@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.801(@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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/previewing': 4.0.857(@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.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) + '@mintlify/validation': 0.1.558(@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.15.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 @@ -11755,13 +11789,13 @@ snapshots: - ts-node - typescript - '@mintlify/link-rot@3.0.823(@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.823(@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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': dependencies: '@mintlify/common': 1.0.666(@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.801(@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.857(@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.801(@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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/previewing': 4.0.857(@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.15.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.558(@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.558(@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.15.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: @@ -11807,6 +11841,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.15.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.0 + next-mdx-remote-client: 1.0.7(@types/react@19.2.13)(acorn@8.15.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.10.0 @@ -11830,12 +11891,12 @@ snapshots: leven: 4.0.0 yaml: 2.6.1 - '@mintlify/prebuild@1.0.801(@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.801(@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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': dependencies: '@mintlify/common': 1.0.666(@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.527(@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.558(@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.558(@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.15.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 @@ -11861,11 +11922,11 @@ snapshots: - typescript - utf-8-validate - '@mintlify/previewing@4.0.857(@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.857(@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.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)': dependencies: '@mintlify/common': 1.0.666(@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.801(@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.558(@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.801(@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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/validation': 0.1.558(@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.15.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 @@ -12010,6 +12071,29 @@ snapshots: - supports-color - typescript + '@mintlify/validation@0.1.558(@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.15.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.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/models': 0.0.257 + arktype: 2.1.27 + js-yaml: 4.1.0 + lcm: 0.0.3 + lodash: 4.17.21 + object-hash: 3.0.0 + openapi-types: 12.1.3 + uuid: 11.1.0 + zod: 3.21.4 + zod-to-json-schema: 3.20.4(zod@3.21.4) + 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 @@ -13670,129 +13754,129 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 - '@tiptap/core@3.10.7(@tiptap/pm@3.10.7)': + '@tiptap/core@3.20.0(@tiptap/pm@3.20.0)': dependencies: - '@tiptap/pm': 3.10.7 + '@tiptap/pm': 3.20.0 - '@tiptap/extension-blockquote@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-blockquote@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-bold@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-bold@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extension-bubble-menu@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: '@floating-ui/dom': 1.6.12 - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 optional: true - '@tiptap/extension-bullet-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-bullet-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-code-block@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extension-code-block@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 - '@tiptap/extension-code@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-code@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-document@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-document@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-dropcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-dropcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extension-floating-menu@3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: '@floating-ui/dom': 1.6.12 - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 optional: true - '@tiptap/extension-gapcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-gapcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-hard-break@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-hard-break@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-heading@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-heading@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-horizontal-rule@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extension-horizontal-rule@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 - '@tiptap/extension-italic@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-italic@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-link@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extension-link@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-list-item@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-list-keymap@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-list-keymap@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 - '@tiptap/extension-ordered-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-ordered-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-paragraph@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-paragraph@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-placeholder@3.10.7(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))': + '@tiptap/extension-placeholder@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) - '@tiptap/extension-strike@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-strike@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-text@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-text@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extension-underline@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))': + '@tiptap/extension-underline@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) - '@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)': + '@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 - '@tiptap/html@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(happy-dom@20.0.11)': + '@tiptap/html@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(happy-dom@20.0.11)': dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 happy-dom: 20.0.11 - '@tiptap/pm@3.10.7': + '@tiptap/pm@3.20.0': dependencies: prosemirror-changeset: 2.4.0 prosemirror-collab: 1.3.1 @@ -13813,49 +13897,49 @@ snapshots: prosemirror-transform: 1.11.0 prosemirror-view: 1.41.6 - '@tiptap/react@3.10.7(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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/react@3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@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.10.7(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 '@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-deep-equal: 3.1.3 + 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.19.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) - '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) + '@tiptap/extension-bubble-menu': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-floating-menu': 3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.10.7': - dependencies: - '@tiptap/core': 3.10.7(@tiptap/pm@3.10.7) - '@tiptap/extension-blockquote': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-bold': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-bullet-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) - '@tiptap/extension-code': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-code-block': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) - '@tiptap/extension-document': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-dropcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) - '@tiptap/extension-gapcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) - '@tiptap/extension-hard-break': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-heading': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-horizontal-rule': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) - '@tiptap/extension-italic': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-link': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) - '@tiptap/extension-list': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) - '@tiptap/extension-list-item': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) - '@tiptap/extension-list-keymap': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) - '@tiptap/extension-ordered-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)) - '@tiptap/extension-paragraph': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-strike': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-text': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extension-underline': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7)) - '@tiptap/extensions': 3.20.0(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7) - '@tiptap/pm': 3.10.7 + '@tiptap/starter-kit@3.20.0': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/extension-blockquote': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-bold': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-bullet-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-code': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-code-block': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-document': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-dropcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-gapcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-hard-break': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-heading': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-horizontal-rule': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-italic': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-link': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-list-item': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-list-keymap': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-ordered-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-paragraph': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-strike': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-text': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-underline': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -15610,6 +15694,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} fast-glob@3.3.2: @@ -17401,9 +17487,9 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mintlify@4.2.280(@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.280(@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.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.884(@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.884(@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.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -17481,6 +17567,22 @@ snapshots: - acorn - supports-color + next-mdx-remote-client@1.0.7(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/code-frame': 7.27.1 + '@mdx-js/mdx': 3.1.0(acorn@8.15.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.6(@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.6(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -18408,6 +18510,16 @@ snapshots: transitivePeerDependencies: - acorn + recma-jsx@1.0.0(acorn@8.15.0): + dependencies: + acorn-jsx: 5.3.2(acorn@8.15.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 From 43fd7de6106c59a757c91fec495af3dadd8a0915 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Tue, 24 Feb 2026 16:59:28 -0300 Subject: [PATCH 24/30] feat(editor): delete when doing backspace in next empty node (#2974) Co-authored-by: Cursor Agent --- packages/editor/src/extensions/columns.tsx | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/editor/src/extensions/columns.tsx b/packages/editor/src/extensions/columns.tsx index 973de22485..ea0465e83d 100644 --- a/packages/editor/src/extensions/columns.tsx +++ b/packages/editor/src/extensions/columns.tsx @@ -187,6 +187,35 @@ export const ColumnsColumn = EmailNode.create({ 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; From da2267bd6eae9706d991d75be243124296ad04c8 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Tue, 24 Feb 2026 16:59:44 -0300 Subject: [PATCH 25/30] bump --- packages/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index 1a07b5b153..39d2f8d78d 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@react-email/editor", - "version": "0.0.0-experimental.5", + "version": "0.0.0-experimental.6", "description": "", "sideEffects": false, "main": "./dist/index.js", From 5b2e437381f8430f59a3c26daaf83efae061e143 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Mon, 2 Mar 2026 13:45:06 -0300 Subject: [PATCH 26/30] feat(editor): scope Ctrl+A/Cmd+A selection to code block content (#2981) Co-authored-by: Cursor Agent --- packages/editor/src/extensions/code-block.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index a4dc20e812..edb4fa5a21 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -6,6 +6,7 @@ import { 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 { EmailNode } from '../core/email-node'; import { PrismPlugin } from './prism-plugin'; @@ -92,6 +93,38 @@ export const CodeBlockPrism = EmailNode.from( ]; }, + 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?.() || []), From def7a9c936a8cb9b8a02f1eb387da036d17789a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 16:03:15 +0000 Subject: [PATCH 27/30] feat(editor): tab indentation in code blocks respects selected theme's tab size Override Tab and Shift-Tab keyboard shortcuts in CodeBlockPrism to dynamically read the tabSize from the code block's selected theme (e.g., dracula uses 4 spaces, oneDark uses 2 spaces) instead of using a hardcoded value. Falls back to 2 spaces when no theme is set. Co-authored-by: Gabriel Miranda --- packages/editor/src/extensions/code-block.tsx | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index edb4fa5a21..de79c37091 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -2,6 +2,7 @@ import * as ReactEmailComponents from '@react-email/components'; import { type PrismLanguage, CodeBlock as ReactEmailCodeBlock, + type Theme, } from '@react-email/components'; import { mergeAttributes } from '@tiptap/core'; import type { CodeBlockOptions } from '@tiptap/extension-code-block'; @@ -15,6 +16,19 @@ export interface CodeBlockPrismOptions extends CodeBlockOptions { defaultTheme: string; } +const DEFAULT_TAB_SIZE = 2; + +function getThemeTabSize(themeName: string | undefined): number { + if (!themeName) return DEFAULT_TAB_SIZE; + // @ts-expect-error -- dynamic access to theme exports + const theme = ReactEmailComponents[themeName] as Theme | undefined; + if (!theme?.base) return DEFAULT_TAB_SIZE; + const raw = (theme.base as Record).tabSize; + if (raw == null) return DEFAULT_TAB_SIZE; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TAB_SIZE; +} + export const CodeBlockPrism = EmailNode.from( CodeBlock.extend({ addOptions(): CodeBlockPrismOptions { @@ -23,7 +37,7 @@ export const CodeBlockPrism = EmailNode.from( exitOnTripleEnter: false, exitOnArrowDown: false, enableTabIndentation: true, - tabSize: 2, + tabSize: DEFAULT_TAB_SIZE, defaultLanguage: 'javascript', defaultTheme: 'default', HTMLAttributes: {}, @@ -68,6 +82,109 @@ export const CodeBlockPrism = EmailNode.from( }; }, + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Tab: ({ editor }) => { + if (!this.options.enableTabIndentation) { + return false; + } + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + if ($from.parent.type !== this.type) { + return false; + } + const tabSize = getThemeTabSize($from.parent.attrs.theme); + const indent = ' '.repeat(tabSize); + if (empty) { + return editor.commands.insertContent(indent); + } + return editor.commands.command(({ tr }) => { + const { from, to } = selection; + const text = state.doc.textBetween(from, to, '\n', '\n'); + const lines = text.split('\n'); + const indentedText = lines + .map((line) => indent + line) + .join('\n'); + tr.replaceWith(from, to, state.schema.text(indentedText)); + return true; + }); + }, + 'Shift-Tab': ({ editor }) => { + if (!this.options.enableTabIndentation) { + return false; + } + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + if ($from.parent.type !== this.type) { + return false; + } + const tabSize = getThemeTabSize($from.parent.attrs.theme); + if (empty) { + return editor.commands.command(({ tr }) => { + const { pos } = $from; + const codeBlockStart = $from.start(); + const codeBlockEnd = $from.end(); + const allText = state.doc.textBetween( + codeBlockStart, + codeBlockEnd, + '\n', + '\n', + ); + const lines = allText.split('\n'); + let currentLineIndex = 0; + let charCount = 0; + const relativeCursorPos = pos - codeBlockStart; + for (let i = 0; i < lines.length; i += 1) { + if (charCount + lines[i].length >= relativeCursorPos) { + currentLineIndex = i; + break; + } + charCount += lines[i].length + 1; + } + const currentLine = lines[currentLineIndex]; + const leadingSpaces = currentLine.match(/^ */)?.[0] || ''; + const spacesToRemove = Math.min(leadingSpaces.length, tabSize); + if (spacesToRemove === 0) { + return true; + } + let lineStartPos = codeBlockStart; + for (let i = 0; i < currentLineIndex; i += 1) { + lineStartPos += lines[i].length + 1; + } + tr.delete(lineStartPos, lineStartPos + spacesToRemove); + const cursorPosInLine = pos - lineStartPos; + if (cursorPosInLine <= spacesToRemove) { + tr.setSelection( + TextSelection.create(tr.doc, lineStartPos), + ); + } + return true; + }); + } + return editor.commands.command(({ tr }) => { + const { from, to } = selection; + const text = state.doc.textBetween(from, to, '\n', '\n'); + const lines = text.split('\n'); + const reverseIndentText = lines + .map((line) => { + const leadingSpaces = line.match(/^ */)?.[0] || ''; + const spacesToRemove = Math.min( + leadingSpaces.length, + tabSize, + ); + return line.slice(spacesToRemove); + }) + .join('\n'); + tr.replaceWith(from, to, state.schema.text(reverseIndentText)); + return true; + }); + }, + }; + }, + renderHTML({ node, HTMLAttributes }) { return [ 'pre', From 8f4820d81dd115639350db85195865824f08ea46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 16:15:47 +0000 Subject: [PATCH 28/30] fix(editor): resolve biome lint and formatting issues - Replace dynamic namespace import with named imports and a static theme lookup record to avoid noDynamicNamespaceImportAccess warning - Fix biome formatting: collapse multi-line chains to single lines Co-authored-by: Gabriel Miranda --- packages/editor/src/extensions/code-block.tsx | 95 ++++++++++++++++--- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index de79c37091..8817a6564d 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -1,8 +1,46 @@ import * as ReactEmailComponents from '@react-email/components'; import { + a11yDark, + atomDark, + baseAteliersulphurpoolLight, + cb, + coldarkCold, + coldarkDark, + coyWithoutShadows, + darcula, + dracula, + duotoneDark, + duotoneEarth, + duotoneForest, + duotoneLight, + duotoneSea, + duotoneSpace, + ghcolors, + gruvboxDark, + gruvboxLight, + holiTheme, + hopscotch, + laserwave, + lucario, + materialDark, + materialLight, + materialOceanic, + nightOwl, + nord, + oneDark, + oneLight, type PrismLanguage, + pojoaque, CodeBlock as ReactEmailCodeBlock, + shadesOfPurple, + solarizedDarkAtom, + synthwave84, type Theme, + vesper, + vs, + vscDarkPlus, + xonokai, + zTouch, } from '@react-email/components'; import { mergeAttributes } from '@tiptap/core'; import type { CodeBlockOptions } from '@tiptap/extension-code-block'; @@ -18,10 +56,50 @@ export interface CodeBlockPrismOptions extends CodeBlockOptions { const DEFAULT_TAB_SIZE = 2; +const themesByName: Record = { + a11yDark, + atomDark, + baseAteliersulphurpoolLight, + cb, + coldarkCold, + coldarkDark, + coyWithoutShadows, + darcula, + dracula, + duotoneDark, + duotoneEarth, + duotoneForest, + duotoneLight, + duotoneSea, + duotoneSpace, + ghcolors, + gruvboxDark, + gruvboxLight, + holiTheme, + hopscotch, + laserwave, + lucario, + materialDark, + materialLight, + materialOceanic, + nightOwl, + nord, + oneDark, + oneLight, + pojoaque, + shadesOfPurple, + solarizedDarkAtom, + synthwave84, + vesper, + vs, + vscDarkPlus, + xonokai, + zTouch, +}; + function getThemeTabSize(themeName: string | undefined): number { if (!themeName) return DEFAULT_TAB_SIZE; - // @ts-expect-error -- dynamic access to theme exports - const theme = ReactEmailComponents[themeName] as Theme | undefined; + const theme = themesByName[themeName]; if (!theme?.base) return DEFAULT_TAB_SIZE; const raw = (theme.base as Record).tabSize; if (raw == null) return DEFAULT_TAB_SIZE; @@ -104,9 +182,7 @@ export const CodeBlockPrism = EmailNode.from( const { from, to } = selection; const text = state.doc.textBetween(from, to, '\n', '\n'); const lines = text.split('\n'); - const indentedText = lines - .map((line) => indent + line) - .join('\n'); + const indentedText = lines.map((line) => indent + line).join('\n'); tr.replaceWith(from, to, state.schema.text(indentedText)); return true; }); @@ -157,9 +233,7 @@ export const CodeBlockPrism = EmailNode.from( tr.delete(lineStartPos, lineStartPos + spacesToRemove); const cursorPosInLine = pos - lineStartPos; if (cursorPosInLine <= spacesToRemove) { - tr.setSelection( - TextSelection.create(tr.doc, lineStartPos), - ); + tr.setSelection(TextSelection.create(tr.doc, lineStartPos)); } return true; }); @@ -171,10 +245,7 @@ export const CodeBlockPrism = EmailNode.from( const reverseIndentText = lines .map((line) => { const leadingSpaces = line.match(/^ */)?.[0] || ''; - const spacesToRemove = Math.min( - leadingSpaces.length, - tabSize, - ); + const spacesToRemove = Math.min(leadingSpaces.length, tabSize); return line.slice(spacesToRemove); }) .join('\n'); From 30fcca8bdc00ce40e2f5a237de5781b9f52159c3 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 2 Mar 2026 13:46:31 -0300 Subject: [PATCH 29/30] remove useless changes --- packages/editor/src/extensions/code-block.tsx | 95 +------------------ 1 file changed, 2 insertions(+), 93 deletions(-) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index 8817a6564d..ce5b3e259f 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -1,46 +1,8 @@ import * as ReactEmailComponents from '@react-email/components'; import { - a11yDark, - atomDark, - baseAteliersulphurpoolLight, - cb, - coldarkCold, - coldarkDark, - coyWithoutShadows, - darcula, - dracula, - duotoneDark, - duotoneEarth, - duotoneForest, - duotoneLight, - duotoneSea, - duotoneSpace, - ghcolors, - gruvboxDark, - gruvboxLight, - holiTheme, - hopscotch, - laserwave, - lucario, - materialDark, - materialLight, - materialOceanic, - nightOwl, - nord, - oneDark, - oneLight, type PrismLanguage, - pojoaque, CodeBlock as ReactEmailCodeBlock, - shadesOfPurple, - solarizedDarkAtom, - synthwave84, type Theme, - vesper, - vs, - vscDarkPlus, - xonokai, - zTouch, } from '@react-email/components'; import { mergeAttributes } from '@tiptap/core'; import type { CodeBlockOptions } from '@tiptap/extension-code-block'; @@ -54,58 +16,7 @@ export interface CodeBlockPrismOptions extends CodeBlockOptions { defaultTheme: string; } -const DEFAULT_TAB_SIZE = 2; - -const themesByName: Record = { - a11yDark, - atomDark, - baseAteliersulphurpoolLight, - cb, - coldarkCold, - coldarkDark, - coyWithoutShadows, - darcula, - dracula, - duotoneDark, - duotoneEarth, - duotoneForest, - duotoneLight, - duotoneSea, - duotoneSpace, - ghcolors, - gruvboxDark, - gruvboxLight, - holiTheme, - hopscotch, - laserwave, - lucario, - materialDark, - materialLight, - materialOceanic, - nightOwl, - nord, - oneDark, - oneLight, - pojoaque, - shadesOfPurple, - solarizedDarkAtom, - synthwave84, - vesper, - vs, - vscDarkPlus, - xonokai, - zTouch, -}; - -function getThemeTabSize(themeName: string | undefined): number { - if (!themeName) return DEFAULT_TAB_SIZE; - const theme = themesByName[themeName]; - if (!theme?.base) return DEFAULT_TAB_SIZE; - const raw = (theme.base as Record).tabSize; - if (raw == null) return DEFAULT_TAB_SIZE; - const parsed = Number(raw); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TAB_SIZE; -} +const tabSize = 2; export const CodeBlockPrism = EmailNode.from( CodeBlock.extend({ @@ -115,7 +26,7 @@ export const CodeBlockPrism = EmailNode.from( exitOnTripleEnter: false, exitOnArrowDown: false, enableTabIndentation: true, - tabSize: DEFAULT_TAB_SIZE, + tabSize: tabSize, defaultLanguage: 'javascript', defaultTheme: 'default', HTMLAttributes: {}, @@ -173,7 +84,6 @@ export const CodeBlockPrism = EmailNode.from( if ($from.parent.type !== this.type) { return false; } - const tabSize = getThemeTabSize($from.parent.attrs.theme); const indent = ' '.repeat(tabSize); if (empty) { return editor.commands.insertContent(indent); @@ -197,7 +107,6 @@ export const CodeBlockPrism = EmailNode.from( if ($from.parent.type !== this.type) { return false; } - const tabSize = getThemeTabSize($from.parent.attrs.theme); if (empty) { return editor.commands.command(({ tr }) => { const { pos } = $from; From 398788e442bf63c6edba2948a4c399672c15b1c6 Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Mon, 2 Mar 2026 13:52:37 -0300 Subject: [PATCH 30/30] lint --- packages/editor/src/extensions/code-block.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index ce5b3e259f..4ad76a6a5e 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -2,7 +2,6 @@ import * as ReactEmailComponents from '@react-email/components'; import { type PrismLanguage, CodeBlock as ReactEmailCodeBlock, - type Theme, } from '@react-email/components'; import { mergeAttributes } from '@tiptap/core'; import type { CodeBlockOptions } from '@tiptap/extension-code-block';