UNPKG

@tiptap/core

Version:

headless rich text editor

1,270 lines (1,241 loc) 210 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@tiptap/pm/state'), require('@tiptap/pm/view'), require('@tiptap/pm/keymap'), require('@tiptap/pm/model'), require('@tiptap/pm/transform'), require('@tiptap/pm/commands'), require('@tiptap/pm/schema-list')) : typeof define === 'function' && define.amd ? define(['exports', '@tiptap/pm/state', '@tiptap/pm/view', '@tiptap/pm/keymap', '@tiptap/pm/model', '@tiptap/pm/transform', '@tiptap/pm/commands', '@tiptap/pm/schema-list'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global["@tiptap/core"] = {}, global.state, global.view, global.keymap, global.model, global.transform, global.commands$1, global.schemaList)); })(this, (function (exports, state, view, keymap, model, transform, commands$1, schemaList) { 'use strict'; /** * Takes a Transaction & Editor State and turns it into a chainable state object * @param config The transaction and state to create the chainable state from * @returns A chainable Editor state object */ function createChainableState(config) { const { state, transaction } = config; let { selection } = transaction; let { doc } = transaction; let { storedMarks } = transaction; return { ...state, apply: state.apply.bind(state), applyTransaction: state.applyTransaction.bind(state), plugins: state.plugins, schema: state.schema, reconfigure: state.reconfigure.bind(state), toJSON: state.toJSON.bind(state), get storedMarks() { return storedMarks; }, get selection() { return selection; }, get doc() { return doc; }, get tr() { selection = transaction.selection; doc = transaction.doc; storedMarks = transaction.storedMarks; return transaction; }, }; } class CommandManager { constructor(props) { this.editor = props.editor; this.rawCommands = this.editor.extensionManager.commands; this.customState = props.state; } get hasCustomState() { return !!this.customState; } get state() { return this.customState || this.editor.state; } get commands() { const { rawCommands, editor, state } = this; const { view } = editor; const { tr } = state; const props = this.buildProps(tr); return Object.fromEntries(Object.entries(rawCommands).map(([name, command]) => { const method = (...args) => { const callback = command(...args)(props); if (!tr.getMeta('preventDispatch') && !this.hasCustomState) { view.dispatch(tr); } return callback; }; return [name, method]; })); } get chain() { return () => this.createChain(); } get can() { return () => this.createCan(); } createChain(startTr, shouldDispatch = true) { const { rawCommands, editor, state } = this; const { view } = editor; const callbacks = []; const hasStartTransaction = !!startTr; const tr = startTr || state.tr; const run = () => { if (!hasStartTransaction && shouldDispatch && !tr.getMeta('preventDispatch') && !this.hasCustomState) { view.dispatch(tr); } return callbacks.every(callback => callback === true); }; const chain = { ...Object.fromEntries(Object.entries(rawCommands).map(([name, command]) => { const chainedCommand = (...args) => { const props = this.buildProps(tr, shouldDispatch); const callback = command(...args)(props); callbacks.push(callback); return chain; }; return [name, chainedCommand]; })), run, }; return chain; } createCan(startTr) { const { rawCommands, state } = this; const dispatch = false; const tr = startTr || state.tr; const props = this.buildProps(tr, dispatch); const formattedCommands = Object.fromEntries(Object.entries(rawCommands).map(([name, command]) => { return [name, (...args) => command(...args)({ ...props, dispatch: undefined })]; })); return { ...formattedCommands, chain: () => this.createChain(tr, dispatch), }; } buildProps(tr, shouldDispatch = true) { const { rawCommands, editor, state } = this; const { view } = editor; const props = { tr, editor, view, state: createChainableState({ state, transaction: tr, }), dispatch: shouldDispatch ? () => undefined : undefined, chain: () => this.createChain(tr, shouldDispatch), can: () => this.createCan(tr), get commands() { return Object.fromEntries(Object.entries(rawCommands).map(([name, command]) => { return [name, (...args) => command(...args)(props)]; })); }, }; return props; } } class EventEmitter { constructor() { this.callbacks = {}; } on(event, fn) { if (!this.callbacks[event]) { this.callbacks[event] = []; } this.callbacks[event].push(fn); return this; } emit(event, ...args) { const callbacks = this.callbacks[event]; if (callbacks) { callbacks.forEach(callback => callback.apply(this, args)); } return this; } off(event, fn) { const callbacks = this.callbacks[event]; if (callbacks) { if (fn) { this.callbacks[event] = callbacks.filter(callback => callback !== fn); } else { delete this.callbacks[event]; } } return this; } once(event, fn) { const onceFn = (...args) => { this.off(event, onceFn); fn.apply(this, args); }; return this.on(event, onceFn); } removeAllListeners() { this.callbacks = {}; } } /** * Returns a field from an extension * @param extension The Tiptap extension * @param field The field, for example `renderHTML` or `priority` * @param context The context object that should be passed as `this` into the function * @returns The field value */ function getExtensionField(extension, field, context) { if (extension.config[field] === undefined && extension.parent) { return getExtensionField(extension.parent, field, context); } if (typeof extension.config[field] === 'function') { const value = extension.config[field].bind({ ...context, parent: extension.parent ? getExtensionField(extension.parent, field, context) : null, }); return value; } return extension.config[field]; } function splitExtensions(extensions) { const baseExtensions = extensions.filter(extension => extension.type === 'extension'); const nodeExtensions = extensions.filter(extension => extension.type === 'node'); const markExtensions = extensions.filter(extension => extension.type === 'mark'); return { baseExtensions, nodeExtensions, markExtensions, }; } /** * Get a list of all extension attributes defined in `addAttribute` and `addGlobalAttribute`. * @param extensions List of extensions */ function getAttributesFromExtensions(extensions) { const extensionAttributes = []; const { nodeExtensions, markExtensions } = splitExtensions(extensions); const nodeAndMarkExtensions = [...nodeExtensions, ...markExtensions]; const defaultAttribute = { default: null, rendered: true, renderHTML: null, parseHTML: null, keepOnSplit: true, isRequired: false, }; extensions.forEach(extension => { const context = { name: extension.name, options: extension.options, storage: extension.storage, extensions: nodeAndMarkExtensions, }; const addGlobalAttributes = getExtensionField(extension, 'addGlobalAttributes', context); if (!addGlobalAttributes) { return; } const globalAttributes = addGlobalAttributes(); globalAttributes.forEach(globalAttribute => { globalAttribute.types.forEach(type => { Object .entries(globalAttribute.attributes) .forEach(([name, attribute]) => { extensionAttributes.push({ type, name, attribute: { ...defaultAttribute, ...attribute, }, }); }); }); }); }); nodeAndMarkExtensions.forEach(extension => { const context = { name: extension.name, options: extension.options, storage: extension.storage, }; const addAttributes = getExtensionField(extension, 'addAttributes', context); if (!addAttributes) { return; } // TODO: remove `as Attributes` const attributes = addAttributes(); Object .entries(attributes) .forEach(([name, attribute]) => { const mergedAttr = { ...defaultAttribute, ...attribute, }; if (typeof (mergedAttr === null || mergedAttr === void 0 ? void 0 : mergedAttr.default) === 'function') { mergedAttr.default = mergedAttr.default(); } if ((mergedAttr === null || mergedAttr === void 0 ? void 0 : mergedAttr.isRequired) && (mergedAttr === null || mergedAttr === void 0 ? void 0 : mergedAttr.default) === undefined) { delete mergedAttr.default; } extensionAttributes.push({ type: extension.name, name, attribute: mergedAttr, }); }); }); return extensionAttributes; } function getNodeType(nameOrType, schema) { if (typeof nameOrType === 'string') { if (!schema.nodes[nameOrType]) { throw Error(`There is no node type named '${nameOrType}'. Maybe you forgot to add the extension?`); } return schema.nodes[nameOrType]; } return nameOrType; } function mergeAttributes(...objects) { return objects .filter(item => !!item) .reduce((items, item) => { const mergedAttributes = { ...items }; Object.entries(item).forEach(([key, value]) => { const exists = mergedAttributes[key]; if (!exists) { mergedAttributes[key] = value; return; } if (key === 'class') { const valueClasses = value ? String(value).split(' ') : []; const existingClasses = mergedAttributes[key] ? mergedAttributes[key].split(' ') : []; const insertClasses = valueClasses.filter(valueClass => !existingClasses.includes(valueClass)); mergedAttributes[key] = [...existingClasses, ...insertClasses].join(' '); } else if (key === 'style') { const newStyles = value ? value.split(';').map((style) => style.trim()).filter(Boolean) : []; const existingStyles = mergedAttributes[key] ? mergedAttributes[key].split(';').map((style) => style.trim()).filter(Boolean) : []; const styleMap = new Map(); existingStyles.forEach(style => { const [property, val] = style.split(':').map(part => part.trim()); styleMap.set(property, val); }); newStyles.forEach(style => { const [property, val] = style.split(':').map(part => part.trim()); styleMap.set(property, val); }); mergedAttributes[key] = Array.from(styleMap.entries()).map(([property, val]) => `${property}: ${val}`).join('; '); } else { mergedAttributes[key] = value; } }); return mergedAttributes; }, {}); } function getRenderedAttributes(nodeOrMark, extensionAttributes) { return extensionAttributes .filter(attribute => attribute.type === nodeOrMark.type.name) .filter(item => item.attribute.rendered) .map(item => { if (!item.attribute.renderHTML) { return { [item.name]: nodeOrMark.attrs[item.name], }; } return item.attribute.renderHTML(nodeOrMark.attrs) || {}; }) .reduce((attributes, attribute) => mergeAttributes(attributes, attribute), {}); } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type function isFunction(value) { return typeof value === 'function'; } /** * Optionally calls `value` as a function. * Otherwise it is returned directly. * @param value Function or any value. * @param context Optional context to bind to function. * @param props Optional props to pass to function. */ function callOrReturn(value, context = undefined, ...props) { if (isFunction(value)) { if (context) { return value.bind(context)(...props); } return value(...props); } return value; } function isEmptyObject(value = {}) { return Object.keys(value).length === 0 && value.constructor === Object; } function fromString(value) { if (typeof value !== 'string') { return value; } if (value.match(/^[+-]?(?:\d*\.)?\d+$/)) { return Number(value); } if (value === 'true') { return true; } if (value === 'false') { return false; } return value; } /** * This function merges extension attributes into parserule attributes (`attrs` or `getAttrs`). * Cancels when `getAttrs` returned `false`. * @param parseRule ProseMirror ParseRule * @param extensionAttributes List of attributes to inject */ function injectExtensionAttributesToParseRule(parseRule, extensionAttributes) { if ('style' in parseRule) { return parseRule; } return { ...parseRule, getAttrs: (node) => { const oldAttributes = parseRule.getAttrs ? parseRule.getAttrs(node) : parseRule.attrs; if (oldAttributes === false) { return false; } const newAttributes = extensionAttributes.reduce((items, item) => { const value = item.attribute.parseHTML ? item.attribute.parseHTML(node) : fromString((node).getAttribute(item.name)); if (value === null || value === undefined) { return items; } return { ...items, [item.name]: value, }; }, {}); return { ...oldAttributes, ...newAttributes }; }, }; } function cleanUpSchemaItem(data) { return Object.fromEntries( // @ts-ignore Object.entries(data).filter(([key, value]) => { if (key === 'attrs' && isEmptyObject(value)) { return false; } return value !== null && value !== undefined; })); } /** * Creates a new Prosemirror schema based on the given extensions. * @param extensions An array of Tiptap extensions * @param editor The editor instance * @returns A Prosemirror schema */ function getSchemaByResolvedExtensions(extensions, editor) { var _a; const allAttributes = getAttributesFromExtensions(extensions); const { nodeExtensions, markExtensions } = splitExtensions(extensions); const topNode = (_a = nodeExtensions.find(extension => getExtensionField(extension, 'topNode'))) === null || _a === void 0 ? void 0 : _a.name; const nodes = Object.fromEntries(nodeExtensions.map(extension => { const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.name); const context = { name: extension.name, options: extension.options, storage: extension.storage, editor, }; const extraNodeFields = extensions.reduce((fields, e) => { const extendNodeSchema = getExtensionField(e, 'extendNodeSchema', context); return { ...fields, ...(extendNodeSchema ? extendNodeSchema(extension) : {}), }; }, {}); const schema = cleanUpSchemaItem({ ...extraNodeFields, content: callOrReturn(getExtensionField(extension, 'content', context)), marks: callOrReturn(getExtensionField(extension, 'marks', context)), group: callOrReturn(getExtensionField(extension, 'group', context)), inline: callOrReturn(getExtensionField(extension, 'inline', context)), atom: callOrReturn(getExtensionField(extension, 'atom', context)), selectable: callOrReturn(getExtensionField(extension, 'selectable', context)), draggable: callOrReturn(getExtensionField(extension, 'draggable', context)), code: callOrReturn(getExtensionField(extension, 'code', context)), whitespace: callOrReturn(getExtensionField(extension, 'whitespace', context)), linebreakReplacement: callOrReturn(getExtensionField(extension, 'linebreakReplacement', context)), defining: callOrReturn(getExtensionField(extension, 'defining', context)), isolating: callOrReturn(getExtensionField(extension, 'isolating', context)), attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => { var _a; return [extensionAttribute.name, { default: (_a = extensionAttribute === null || extensionAttribute === void 0 ? void 0 : extensionAttribute.attribute) === null || _a === void 0 ? void 0 : _a.default }]; })), }); const parseHTML = callOrReturn(getExtensionField(extension, 'parseHTML', context)); if (parseHTML) { schema.parseDOM = parseHTML.map(parseRule => injectExtensionAttributesToParseRule(parseRule, extensionAttributes)); } const renderHTML = getExtensionField(extension, 'renderHTML', context); if (renderHTML) { schema.toDOM = node => renderHTML({ node, HTMLAttributes: getRenderedAttributes(node, extensionAttributes), }); } const renderText = getExtensionField(extension, 'renderText', context); if (renderText) { schema.toText = renderText; } return [extension.name, schema]; })); const marks = Object.fromEntries(markExtensions.map(extension => { const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.name); const context = { name: extension.name, options: extension.options, storage: extension.storage, editor, }; const extraMarkFields = extensions.reduce((fields, e) => { const extendMarkSchema = getExtensionField(e, 'extendMarkSchema', context); return { ...fields, ...(extendMarkSchema ? extendMarkSchema(extension) : {}), }; }, {}); const schema = cleanUpSchemaItem({ ...extraMarkFields, inclusive: callOrReturn(getExtensionField(extension, 'inclusive', context)), excludes: callOrReturn(getExtensionField(extension, 'excludes', context)), group: callOrReturn(getExtensionField(extension, 'group', context)), spanning: callOrReturn(getExtensionField(extension, 'spanning', context)), code: callOrReturn(getExtensionField(extension, 'code', context)), attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => { var _a; return [extensionAttribute.name, { default: (_a = extensionAttribute === null || extensionAttribute === void 0 ? void 0 : extensionAttribute.attribute) === null || _a === void 0 ? void 0 : _a.default }]; })), }); const parseHTML = callOrReturn(getExtensionField(extension, 'parseHTML', context)); if (parseHTML) { schema.parseDOM = parseHTML.map(parseRule => injectExtensionAttributesToParseRule(parseRule, extensionAttributes)); } const renderHTML = getExtensionField(extension, 'renderHTML', context); if (renderHTML) { schema.toDOM = mark => renderHTML({ mark, HTMLAttributes: getRenderedAttributes(mark, extensionAttributes), }); } return [extension.name, schema]; })); return new model.Schema({ topNode, nodes, marks, }); } /** * Tries to get a node or mark type by its name. * @param name The name of the node or mark type * @param schema The Prosemiror schema to search in * @returns The node or mark type, or null if it doesn't exist */ function getSchemaTypeByName(name, schema) { return schema.nodes[name] || schema.marks[name] || null; } function isExtensionRulesEnabled(extension, enabled) { if (Array.isArray(enabled)) { return enabled.some(enabledExtension => { const name = typeof enabledExtension === 'string' ? enabledExtension : enabledExtension.name; return name === extension.name; }); } return enabled; } function getHTMLFromFragment(fragment, schema) { const documentFragment = model.DOMSerializer.fromSchema(schema).serializeFragment(fragment); const temporaryDocument = document.implementation.createHTMLDocument(); const container = temporaryDocument.createElement('div'); container.appendChild(documentFragment); return container.innerHTML; } /** * Returns the text content of a resolved prosemirror position * @param $from The resolved position to get the text content from * @param maxMatch The maximum number of characters to match * @returns The text content */ const getTextContentFromNodes = ($from, maxMatch = 500) => { let textBefore = ''; const sliceEndPos = $from.parentOffset; $from.parent.nodesBetween(Math.max(0, sliceEndPos - maxMatch), sliceEndPos, (node, pos, parent, index) => { var _a, _b; const chunk = ((_b = (_a = node.type.spec).toText) === null || _b === void 0 ? void 0 : _b.call(_a, { node, pos, parent, index, })) || node.textContent || '%leaf%'; textBefore += node.isAtom && !node.isText ? chunk : chunk.slice(0, Math.max(0, sliceEndPos - pos)); }); return textBefore; }; function isRegExp(value) { return Object.prototype.toString.call(value) === '[object RegExp]'; } class InputRule { constructor(config) { this.find = config.find; this.handler = config.handler; } } const inputRuleMatcherHandler = (text, find) => { if (isRegExp(find)) { return find.exec(text); } const inputRuleMatch = find(text); if (!inputRuleMatch) { return null; } const result = [inputRuleMatch.text]; result.index = inputRuleMatch.index; result.input = text; result.data = inputRuleMatch.data; if (inputRuleMatch.replaceWith) { if (!inputRuleMatch.text.includes(inputRuleMatch.replaceWith)) { console.warn('[tiptap warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".'); } result.push(inputRuleMatch.replaceWith); } return result; }; function run$1(config) { var _a; const { editor, from, to, text, rules, plugin, } = config; const { view } = editor; if (view.composing) { return false; } const $from = view.state.doc.resolve(from); if ( // check for code node $from.parent.type.spec.code // check for code mark || !!((_a = ($from.nodeBefore || $from.nodeAfter)) === null || _a === void 0 ? void 0 : _a.marks.find(mark => mark.type.spec.code))) { return false; } let matched = false; const textBefore = getTextContentFromNodes($from) + text; rules.forEach(rule => { if (matched) { return; } const match = inputRuleMatcherHandler(textBefore, rule.find); if (!match) { return; } const tr = view.state.tr; const state = createChainableState({ state: view.state, transaction: tr, }); const range = { from: from - (match[0].length - text.length), to, }; const { commands, chain, can } = new CommandManager({ editor, state, }); const handler = rule.handler({ state, range, match, commands, chain, can, }); // stop if there are no changes if (handler === null || !tr.steps.length) { return; } // store transform as meta data // so we can undo input rules within the `undoInputRules` command tr.setMeta(plugin, { transform: tr, from, to, text, }); view.dispatch(tr); matched = true; }); return matched; } /** * Create an input rules plugin. When enabled, it will cause text * input that matches any of the given rules to trigger the rule’s * action. */ function inputRulesPlugin(props) { const { editor, rules } = props; const plugin = new state.Plugin({ state: { init() { return null; }, apply(tr, prev, state) { const stored = tr.getMeta(plugin); if (stored) { return stored; } // if InputRule is triggered by insertContent() const simulatedInputMeta = tr.getMeta('applyInputRules'); const isSimulatedInput = !!simulatedInputMeta; if (isSimulatedInput) { setTimeout(() => { let { text } = simulatedInputMeta; if (typeof text === 'string') { text = text; } else { text = getHTMLFromFragment(model.Fragment.from(text), state.schema); } const { from } = simulatedInputMeta; const to = from + text.length; run$1({ editor, from, to, text, rules, plugin, }); }); } return tr.selectionSet || tr.docChanged ? null : prev; }, }, props: { handleTextInput(view, from, to, text) { return run$1({ editor, from, to, text, rules, plugin, }); }, handleDOMEvents: { compositionend: view => { setTimeout(() => { const { $cursor } = view.state.selection; if ($cursor) { run$1({ editor, from: $cursor.pos, to: $cursor.pos, text: '', rules, plugin, }); } }); return false; }, }, // add support for input rules to trigger on enter // this is useful for example for code blocks handleKeyDown(view, event) { if (event.key !== 'Enter') { return false; } const { $cursor } = view.state.selection; if ($cursor) { return run$1({ editor, from: $cursor.pos, to: $cursor.pos, text: '\n', rules, plugin, }); } return false; }, }, // @ts-ignore isInputRules: true, }); return plugin; } // see: https://github.com/mesqueeb/is-what/blob/88d6e4ca92fb2baab6003c54e02eedf4e729e5ab/src/index.ts function getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } function isPlainObject(value) { if (getType(value) !== 'Object') { return false; } return value.constructor === Object && Object.getPrototypeOf(value) === Object.prototype; } function mergeDeep(target, source) { const output = { ...target }; if (isPlainObject(target) && isPlainObject(source)) { Object.keys(source).forEach(key => { if (isPlainObject(source[key]) && isPlainObject(target[key])) { output[key] = mergeDeep(target[key], source[key]); } else { output[key] = source[key]; } }); } return output; } /** * The Mark class is used to create custom mark extensions. * @see https://tiptap.dev/api/extensions#create-a-new-extension */ class Mark { constructor(config = {}) { this.type = 'mark'; this.name = 'mark'; this.parent = null; this.child = null; this.config = { name: this.name, defaultOptions: {}, }; this.config = { ...this.config, ...config, }; this.name = this.config.name; if (config.defaultOptions && Object.keys(config.defaultOptions).length > 0) { console.warn(`[tiptap warn]: BREAKING CHANGE: "defaultOptions" is deprecated. Please use "addOptions" instead. Found in extension: "${this.name}".`); } // TODO: remove `addOptions` fallback this.options = this.config.defaultOptions; if (this.config.addOptions) { this.options = callOrReturn(getExtensionField(this, 'addOptions', { name: this.name, })); } this.storage = callOrReturn(getExtensionField(this, 'addStorage', { name: this.name, options: this.options, })) || {}; } static create(config = {}) { return new Mark(config); } configure(options = {}) { // return a new instance so we can use the same extension // with different calls of `configure` const extension = this.extend({ ...this.config, addOptions: () => { return mergeDeep(this.options, options); }, }); // Always preserve the current name extension.name = this.name; // Set the parent to be our parent extension.parent = this.parent; return extension; } extend(extendedConfig = {}) { const extension = new Mark(extendedConfig); extension.parent = this; this.child = extension; extension.name = extendedConfig.name ? extendedConfig.name : extension.parent.name; if (extendedConfig.defaultOptions && Object.keys(extendedConfig.defaultOptions).length > 0) { console.warn(`[tiptap warn]: BREAKING CHANGE: "defaultOptions" is deprecated. Please use "addOptions" instead. Found in extension: "${extension.name}".`); } extension.options = callOrReturn(getExtensionField(extension, 'addOptions', { name: extension.name, })); extension.storage = callOrReturn(getExtensionField(extension, 'addStorage', { name: extension.name, options: extension.options, })); return extension; } static handleExit({ editor, mark }) { const { tr } = editor.state; const currentPos = editor.state.selection.$from; const isAtEnd = currentPos.pos === currentPos.end(); if (isAtEnd) { const currentMarks = currentPos.marks(); const isInMark = !!currentMarks.find(m => (m === null || m === void 0 ? void 0 : m.type.name) === mark.name); if (!isInMark) { return false; } const removeMark = currentMarks.find(m => (m === null || m === void 0 ? void 0 : m.type.name) === mark.name); if (removeMark) { tr.removeStoredMark(removeMark); } tr.insertText(' ', currentPos.pos); editor.view.dispatch(tr); return true; } return false; } } function isNumber(value) { return typeof value === 'number'; } /** * Paste rules are used to react to pasted content. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules */ class PasteRule { constructor(config) { this.find = config.find; this.handler = config.handler; } } const pasteRuleMatcherHandler = (text, find, event) => { if (isRegExp(find)) { return [...text.matchAll(find)]; } const matches = find(text, event); if (!matches) { return []; } return matches.map(pasteRuleMatch => { const result = [pasteRuleMatch.text]; result.index = pasteRuleMatch.index; result.input = text; result.data = pasteRuleMatch.data; if (pasteRuleMatch.replaceWith) { if (!pasteRuleMatch.text.includes(pasteRuleMatch.replaceWith)) { console.warn('[tiptap warn]: "pasteRuleMatch.replaceWith" must be part of "pasteRuleMatch.text".'); } result.push(pasteRuleMatch.replaceWith); } return result; }); }; function run(config) { const { editor, state, from, to, rule, pasteEvent, dropEvent, } = config; const { commands, chain, can } = new CommandManager({ editor, state, }); const handlers = []; state.doc.nodesBetween(from, to, (node, pos) => { if (!node.isTextblock || node.type.spec.code) { return; } const resolvedFrom = Math.max(from, pos); const resolvedTo = Math.min(to, pos + node.content.size); const textToMatch = node.textBetween(resolvedFrom - pos, resolvedTo - pos, undefined, '\ufffc'); const matches = pasteRuleMatcherHandler(textToMatch, rule.find, pasteEvent); matches.forEach(match => { if (match.index === undefined) { return; } const start = resolvedFrom + match.index + 1; const end = start + match[0].length; const range = { from: state.tr.mapping.map(start), to: state.tr.mapping.map(end), }; const handler = rule.handler({ state, range, match, commands, chain, can, pasteEvent, dropEvent, }); handlers.push(handler); }); }); const success = handlers.every(handler => handler !== null); return success; } // When dragging across editors, must get another editor instance to delete selection content. let tiptapDragFromOtherEditor = null; const createClipboardPasteEvent = (text) => { var _a; const event = new ClipboardEvent('paste', { clipboardData: new DataTransfer(), }); (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.setData('text/html', text); return event; }; /** * Create an paste rules plugin. When enabled, it will cause pasted * text that matches any of the given rules to trigger the rule’s * action. */ function pasteRulesPlugin(props) { const { editor, rules } = props; let dragSourceElement = null; let isPastedFromProseMirror = false; let isDroppedFromProseMirror = false; let pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null; let dropEvent; try { dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null; } catch { dropEvent = null; } const processEvent = ({ state, from, to, rule, pasteEvt, }) => { const tr = state.tr; const chainableState = createChainableState({ state, transaction: tr, }); const handler = run({ editor, state: chainableState, from: Math.max(from - 1, 0), to: to.b - 1, rule, pasteEvent: pasteEvt, dropEvent, }); if (!handler || !tr.steps.length) { return; } try { dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null; } catch { dropEvent = null; } pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null; return tr; }; const plugins = rules.map(rule => { return new state.Plugin({ // we register a global drag handler to track the current drag source element view(view) { const handleDragstart = (event) => { var _a; dragSourceElement = ((_a = view.dom.parentElement) === null || _a === void 0 ? void 0 : _a.contains(event.target)) ? view.dom.parentElement : null; if (dragSourceElement) { tiptapDragFromOtherEditor = editor; } }; const handleDragend = () => { if (tiptapDragFromOtherEditor) { tiptapDragFromOtherEditor = null; } }; window.addEventListener('dragstart', handleDragstart); window.addEventListener('dragend', handleDragend); return { destroy() { window.removeEventListener('dragstart', handleDragstart); window.removeEventListener('dragend', handleDragend); }, }; }, props: { handleDOMEvents: { drop: (view, event) => { isDroppedFromProseMirror = dragSourceElement === view.dom.parentElement; dropEvent = event; if (!isDroppedFromProseMirror) { const dragFromOtherEditor = tiptapDragFromOtherEditor; if (dragFromOtherEditor) { // setTimeout to avoid the wrong content after drop, timeout arg can't be empty or 0 setTimeout(() => { const selection = dragFromOtherEditor.state.selection; if (selection) { dragFromOtherEditor.commands.deleteRange({ from: selection.from, to: selection.to }); } }, 10); } } return false; }, paste: (_view, event) => { var _a; const html = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.getData('text/html'); pasteEvent = event; isPastedFromProseMirror = !!(html === null || html === void 0 ? void 0 : html.includes('data-pm-slice')); return false; }, }, }, appendTransaction: (transactions, oldState, state) => { const transaction = transactions[0]; const isPaste = transaction.getMeta('uiEvent') === 'paste' && !isPastedFromProseMirror; const isDrop = transaction.getMeta('uiEvent') === 'drop' && !isDroppedFromProseMirror; // if PasteRule is triggered by insertContent() const simulatedPasteMeta = transaction.getMeta('applyPasteRules'); const isSimulatedPaste = !!simulatedPasteMeta; if (!isPaste && !isDrop && !isSimulatedPaste) { return; } // Handle simulated paste if (isSimulatedPaste) { let { text } = simulatedPasteMeta; if (typeof text === 'string') { text = text; } else { text = getHTMLFromFragment(model.Fragment.from(text), state.schema); } const { from } = simulatedPasteMeta; const to = from + text.length; const pasteEvt = createClipboardPasteEvent(text); return processEvent({ rule, state, from, to: { b: to }, pasteEvt, }); } // handle actual paste/drop const from = oldState.doc.content.findDiffStart(state.doc.content); const to = oldState.doc.content.findDiffEnd(state.doc.content); // stop if there is no changed range if (!isNumber(from) || !to || from === to.b) { return; } return processEvent({ rule, state, from, to, pasteEvt: pasteEvent, }); }, }); }); return plugins; } function findDuplicates(items) { const filtered = items.filter((el, index) => items.indexOf(el) !== index); return Array.from(new Set(filtered)); } class ExtensionManager { constructor(extensions, editor) { this.splittableMarks = []; this.editor = editor; this.extensions = ExtensionManager.resolve(extensions); this.schema = getSchemaByResolvedExtensions(this.extensions, editor); this.setupExtensions(); } /** * Returns a flattened and sorted extension list while * also checking for duplicated extensions and warns the user. * @param extensions An array of Tiptap extensions * @returns An flattened and sorted array of Tiptap extensions */ static resolve(extensions) { const resolvedExtensions = ExtensionManager.sort(ExtensionManager.flatten(extensions)); const duplicatedNames = findDuplicates(resolvedExtensions.map(extension => extension.name)); if (duplicatedNames.length) { console.warn(`[tiptap warn]: Duplicate extension names found: [${duplicatedNames .map(item => `'${item}'`) .join(', ')}]. This can lead to issues.`); } return resolvedExtensions; } /** * Create a flattened array of extensions by traversing the `addExtensions` field. * @param extensions An array of Tiptap extensions * @returns A flattened array of Tiptap extensions */ static flatten(extensions) { return (extensions .map(extension => { const context = { name: extension.name, options: extension.options, storage: extension.storage, }; const addExtensions = getExtensionField(extension, 'addExtensions', context); if (addExtensions) { return [extension, ...this.flatten(addExtensions())]; } return extension; }) // `Infinity` will break TypeScript so we set a number that is probably high enough .flat(10)); } /** * Sort extensions by priority. * @param extensions An array of Tiptap extensions * @returns A sorted array of Tiptap extensions by priority */ static sort(extensions) { const defaultPriority = 100; return extensions.sort((a, b) => { const priorityA = getExtensionField(a, 'priority') || defaultPriority; const priorityB = getExtensionField(b, 'priority') || defaultPriority; if (priorityA > priorityB) { return -1; } if (priorityA < priorityB) { return 1; } return 0; }); } /** * Get all commands from the extensions. * @returns An object with all commands where the key is the command name and the value is the command function */ get commands() { return this.extensions.reduce((commands, extension) => { const context = { name: extension.name, options: extension.options, storage: extension.storage, editor: this.editor, type: getSchemaTypeByName(extension.name, this.schema), }; const addCommands = getExtensionField(extension, 'addCommands', context); if (!addCommands) { return commands; } return { ...commands, ...addCommands(), }; }, {}); } /** * Get all registered Prosemirror plugins from the extensions. * @returns An array of Prosemirror plugins */ get plugins() { const { editor } = this; // With ProseMirror, first plugins within an array are executed first. // In Tiptap, we provide the ability to override plugins, // so it feels more natural to run plugins at the end of an array first. // That’s why we have to reverse the `extensions` array and sort again // based on the `priority` option. const extensions = ExtensionManager.sort([...this.extensions].reverse()); const inputRules = []; const pasteRules = []; const allPlugins = extensions .map(extension => { const context = { name: extension.name, options: extension.options,