import type { Plugin } from '@tiptap/pm/state' import type { Editor } from './Editor.js' import { getExtensionField } from './helpers/getExtensionField.js' import type { ExtensionConfig, MarkConfig, NodeConfig } from './index.js' import type { InputRule } from './InputRule.js' import type { Mark } from './Mark.js' import type { Node } from './Node.js' import type { PasteRule } from './PasteRule.js' import type { AnyConfig, DispatchTransactionProps, EditorEvents, Extensions, GlobalAttributes, JSONContent, KeyboardShortcutCommand, MarkdownParseHelpers, MarkdownParseResult, MarkdownRendererHelpers, MarkdownToken, MarkdownTokenizer, ParentConfig, RawCommands, RenderContext, } from './types.js' import { callOrReturn } from './utilities/callOrReturn.js' import { mergeDeep } from './utilities/mergeDeep.js' export interface ExtendableConfig< Options = any, Storage = any, Config extends | ExtensionConfig | NodeConfig | MarkConfig | ExtendableConfig = ExtendableConfig, PMType = any, > { /** * The extension name - this must be unique. * It will be used to identify the extension. * * @example 'myExtension' */ name: string /** * The priority of your extension. The higher, the earlier it will be called * and will take precedence over other extensions with a lower priority. * @default 100 * @example 101 */ priority?: number /** * This method will add options to this extension * @see https://tiptap.dev/docs/editor/guide/custom-extensions#settings * @example * addOptions() { * return { * myOption: 'foo', * myOtherOption: 10, * } */ addOptions?: (this: { name: string; parent: ParentConfig['addOptions'] }) => Options /** * The default storage this extension can save data to. * @see https://tiptap.dev/docs/editor/guide/custom-extensions#storage * @example * defaultStorage: { * prefetchedUsers: [], * loading: false, * } */ addStorage?: (this: { name: string; options: Options; parent: ParentConfig['addStorage'] }) => Storage /** * This function adds globalAttributes to specific nodes. * @see https://tiptap.dev/docs/editor/guide/custom-extensions#global-attributes * @example * addGlobalAttributes() { * return [ * { // Extend the following extensions * types: [ * 'heading', * 'paragraph', * ], * // … with those attributes * attributes: { * textAlign: { * default: 'left', * renderHTML: attributes => ({ * style: `text-align: ${attributes.textAlign}`, * }), * parseHTML: element => element.style.textAlign || 'left', * }, * }, * }, * ] * } */ addGlobalAttributes?: (this: { name: string options: Options storage: Storage extensions: (Node | Mark)[] parent: ParentConfig['addGlobalAttributes'] }) => GlobalAttributes /** * This function adds commands to the editor * @see https://tiptap.dev/docs/editor/guide/custom-extensions#commands * @example * addCommands() { * return { * myCommand: () => ({ chain }) => chain().setMark('type', 'foo').run(), * } * } */ addCommands?: (this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['addCommands'] }) => Partial /** * This function registers keyboard shortcuts. * @see https://tiptap.dev/docs/editor/guide/custom-extensions#keyboard-shortcuts * @example * addKeyboardShortcuts() { * return { * 'Mod-l': () => this.editor.commands.toggleBulletList(), * } * }, */ addKeyboardShortcuts?: (this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['addKeyboardShortcuts'] }) => { [key: string]: KeyboardShortcutCommand } /** * This function adds input rules to the editor. * @see https://tiptap.dev/docs/editor/guide/custom-extensions#input-rules * @example * addInputRules() { * return [ * markInputRule({ * find: inputRegex, * type: this.type, * }), * ] * }, */ addInputRules?: (this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['addInputRules'] }) => InputRule[] /** * This function adds paste rules to the editor. * @see https://tiptap.dev/docs/editor/guide/custom-extensions#paste-rules * @example * addPasteRules() { * return [ * markPasteRule({ * find: pasteRegex, * type: this.type, * }), * ] * }, */ addPasteRules?: (this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['addPasteRules'] }) => PasteRule[] /** * This function adds Prosemirror plugins to the editor * @see https://tiptap.dev/docs/editor/guide/custom-extensions#prosemirror-plugins * @example * addProseMirrorPlugins() { * return [ * customPlugin(), * ] * } */ addProseMirrorPlugins?: (this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['addProseMirrorPlugins'] }) => Plugin[] /** * This function transforms pasted HTML content before it's parsed. * Extensions can use this to modify or clean up pasted HTML. * The transformations are chained - each extension's transform receives * the output from the previous extension's transform. * @see https://tiptap.dev/docs/editor/guide/custom-extensions#transform-pasted-html * @example * transformPastedHTML(html) { * // Remove all style attributes * return html.replace(/style="[^"]*"/g, '') * } */ transformPastedHTML?: ( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['transformPastedHTML'] }, html: string, ) => string /** * This function adds additional extensions to the editor. This is useful for * building extension kits. * @example * addExtensions() { * return [ * BulletList, * OrderedList, * ListItem * ] * } */ addExtensions?: (this: { name: string options: Options storage: Storage parent: ParentConfig['addExtensions'] }) => Extensions /** * The markdown token name * * This is the name of the token that this extension uses to parse and render markdown and comes from the Marked Lexer. * * @see https://github.com/markedjs/marked/blob/master/src/Tokens.ts * */ markdownTokenName?: string /** * The parse function used by the markdown parser to convert markdown tokens to ProseMirror nodes. */ parseMarkdown?: (token: MarkdownToken, helpers: MarkdownParseHelpers) => MarkdownParseResult /** * The serializer function used by the markdown serializer to convert ProseMirror nodes to markdown tokens. */ renderMarkdown?: (node: JSONContent, helpers: MarkdownRendererHelpers, ctx: RenderContext) => string /** * The markdown tokenizer responsible for turning a markdown string into tokens * * Custom tokenizers are only needed when you want to parse non-standard markdown token. */ markdownTokenizer?: MarkdownTokenizer /** * Optional markdown options for indentation */ markdownOptions?: { /** * Defines if this markdown element should indent it's child elements */ indentsContent?: boolean /** * Lets a mark tell the Markdown serializer which inline HTML tags it can * safely use when plain markdown delimiters would become ambiguous. * * This is mainly useful for overlapping marks. For example, bold followed * by bold+italic followed by italic cannot always be written back with only * `*` and `**` in a way that still parses correctly. In that case, the * serializer can close the overlapping section with markdown and reopen the * remaining tail with HTML instead. * * Example: * - desired formatting: `**123` + `*456*` + `789 italic` * - serialized result: `**123*456***789` * * If your extension defines custom mark names, set `htmlReopen` on that * extension so the serializer can reuse its HTML form for overlap cases. */ htmlReopen?: { open: string close: string } } /** * This function extends the schema of the node. * @example * extendNodeSchema() { * return { * group: 'inline', * selectable: false, * } * } */ extendNodeSchema?: | (( this: { name: string options: Options storage: Storage parent: ParentConfig['extendNodeSchema'] }, extension: Node, ) => Record) | null /** * This function extends the schema of the mark. * @example * extendMarkSchema() { * return { * group: 'inline', * selectable: false, * } * } */ extendMarkSchema?: | (( this: { name: string options: Options storage: Storage parent: ParentConfig['extendMarkSchema'] }, extension: Mark, ) => Record) | null /** * The editor is not ready yet. */ onBeforeCreate?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onBeforeCreate'] }, event: EditorEvents['beforeCreate'], ) => void) | null /** * The editor is ready. */ onCreate?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onCreate'] }, event: EditorEvents['create'], ) => void) | null /** * The content has changed. */ onUpdate?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onUpdate'] }, event: EditorEvents['update'], ) => void) | null /** * The selection has changed. */ onSelectionUpdate?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onSelectionUpdate'] }, event: EditorEvents['selectionUpdate'], ) => void) | null /** * The editor state has changed. */ onTransaction?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onTransaction'] }, event: EditorEvents['transaction'], ) => void) | null /** * The editor is focused. */ onFocus?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onFocus'] }, event: EditorEvents['focus'], ) => void) | null /** * The editor isn’t focused anymore. */ onBlur?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onBlur'] }, event: EditorEvents['blur'], ) => void) | null /** * The editor is destroyed. */ onDestroy?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['onDestroy'] }, event: EditorEvents['destroy'], ) => void) | null /** * This hook allows you to intercept and modify transactions before they are dispatched. * * Example * ```ts * dispatchTransaction({ transaction, next }) { * console.log('Dispatching transaction:', transaction) * next(transaction) * } * ``` * * @param props - The dispatch transaction props */ dispatchTransaction?: | (( this: { name: string options: Options storage: Storage editor: Editor type: PMType parent: ParentConfig['dispatchTransaction'] }, props: DispatchTransactionProps, ) => void) | null } export class Extendable< Options = any, Storage = any, Config = ExtensionConfig | NodeConfig | MarkConfig, > { type = 'extendable' parent: Extendable | null = null child: Extendable | null = null name = '' config: Config = { name: this.name, } as Config constructor(config: Partial = {}) { this.config = { ...this.config, ...config, } this.name = (this.config as any).name } get options(): Options { return { ...(callOrReturn( getExtensionField(this as any, 'addOptions', { name: this.name, }), ) || {}), } } get storage(): Readonly { return { ...(callOrReturn( getExtensionField(this as any, 'addStorage', { name: this.name, options: this.options, }), ) || {}), } } configure(options: Partial = {}) { const extension = this.extend({ ...this.config, addOptions: () => { return mergeDeep(this.options as Record, options) as Options }, }) extension.name = this.name extension.parent = this.parent return extension } extend< ExtendedOptions = Options, ExtendedStorage = Storage, ExtendedConfig = | ExtensionConfig | NodeConfig | MarkConfig, >(extendedConfig: Partial = {}): Extendable { const extension = new (this.constructor as any)({ ...this.config, ...extendedConfig }) extension.parent = this this.child = extension extension.name = 'name' in extendedConfig ? extendedConfig.name : extension.parent.name return extension } }