UNPKG

@tiptap/core

Version:

headless rich text editor

1,266 lines (1,239 loc) 198 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; } 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 ? 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(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), {}); } 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)), 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; } /** * 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) { 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(() => { const { from, text } = 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; } function isNumber(value) { return typeof value === 'number'; } /** * Paste rules are used to react to pasted content. * @see https://tiptap.dev/guide/custom-extensions/#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; } 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 = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : 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; } dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : 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; }; window.addEventListener('dragstart', handleDragstart); return { destroy() { window.removeEventListener('dragstart', handleDragstart); }, }; }, props: { handleDOMEvents: { drop: (view, event) => { isDroppedFromProseMirror = dragSourceElement === view.dom.parentElement; dropEvent = event; 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) { const { from, text } = 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, storage: extension.storage, editor, type: getSchemaTypeByName(extension.name, this.schema), }; const plugins = []; const addKeyboardShortcuts = getExtensionField(extension, 'addKeyboardShortcuts', context); let defaultBindings = {}; // bind exit handling if (extension.type === 'mark' && getExtensionField(extension, 'exitable', context)) { defaultBindings.ArrowRight = () => Mark.handleExit({ editor, mark: extension }); } if (addKeyboardShortcuts) { const bindings = Object.fromEntries(Object.entries(addKeyboardShortcuts()).map(([shortcut, method]) => { return [shortcut, () => method({ editor })]; })); defaultBindings = { ...defaultBindings, ...bindings }; } const keyMapPlugin = keymap.keymap(defaultBindings); plugins.push(keyMapPlugin); const addInputRules = getExtensionField(extension, 'addInputRules', context); if (isExtensionRulesEnabled(extension, editor.options.enableInputRules) && addInputRules) { inputRules.push(...addInputRules()); } const addPasteRules = getExtensionField(extension, 'addPasteRules', context); if (isExtensionRulesEnabled(extension, editor.options.enablePasteRules) && addPasteRules) { pasteRules.push(...addPasteRules()); } const addProseMirrorPlugins = getExtensionField(extension, 'addProseMirrorPlugins', context); if (addProseMirrorPlugins) { const proseMirrorPlugins = addProseMirrorPlugins(); plugins.push(...proseMirrorPlugins); } return plugins; }) .flat(); return [ inputRulesPlugin({ editor, rules: inputRules, }), ...pasteRulesPlugin({ editor, rules: pasteRules, }), ...allPlugins, ]; } /** * Get all attributes from the extensions. * @returns An array of attributes */ get attributes() { return getAttributesFromExtensions(this.extensions); } /** * Get all node views from the extensions. * @returns An object with all node views where the key is the node name and the value is the node view function */ get nodeViews() { const { editor } = this; const { nodeExtensions } = splitExtensions(this.extensions); return Object.fromEntries(nodeExtensions .filter(extension => !!getExtensionField(extension, 'addNodeView')) .map(extension => { const extensionAttributes = this.attributes.filter(attribute => attribute.type === extension.name); const context = { name: extension.name, options: extension.options, storage: extension.storage, editor, type: getNodeType(extension.name, this.schema), }; const addNodeView = getExtensionField(extension, 'addNodeView', context); if (!addNodeView) { return []; } const nodeview = (node, view, getPos, decorations, innerDecorations) => { const HTMLAttributes = getRenderedAttributes(node, extensionAttributes); return addNodeView()({ // pass-through node, view, getPos: getPos, decorations, innerDecorations, // tiptap-specific editor, extension, HTMLAttributes, }); }; return [extension.name, nodeview]; })); } /** * Go through all extensions, create extension storages & setup marks * & bind editor event listener. */ setupExtensions() { this.extensions.forEach(extension => { var _a; // store extension storage in editor this.editor.extensionStorage[extension.name] = extension.storage; const context = { name: extension.name, options: extension.options, storage: extension.storage, editor: this.editor, type: getSchemaTypeByName(extension.name, this.schema), }; if (extension.type === 'mark') { const keepOnSplit = (_a = callOrReturn(getExtensionField(extension, 'keepOnSplit', context))) !== null && _a !== void 0 ? _a : true; if (keepOnSplit) { this.splittableMarks.push(extension.name); } } const onBeforeCreate = getExtensionField(extension, 'onBeforeCreate', context); const onCreate = getExtensionField(extension, 'onCreate', context); const onUpdate = getExtensionField(extension, 'onUpdate', context); const onSelectionUpdate = getExtensionField(extension, 'onSelectionUpdate', context); const onTransaction = getExtensionField(extension, 'onTransaction', context); const onFocus = getExtensionField(extension, 'onFocus', context); const onBlur = getExtensionField(extension, 'onBlur', context); const onDestroy = getExtensionField(extension, 'onDestroy', context); if (onBeforeCreate) { this.editor.on('beforeCreate', onBeforeCreate); } if (onCreate) { this.editor.on('create', onCreate); } if (onUpdate) { this.editor.on('update', onUpdate); } if (onSelectionUpdate) { this.editor.on('selectionUpdate', onSelectionUpdate); } if (onTransaction) { this.editor.on('transaction', onTransaction); } if (onFocus) { this.editor.on('focus', onFocus); } if (onBlur) { this.editor.on('blur', onBlur); } if (onDestroy) { this.editor.on('destroy', onDestroy); } }); } } // 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 Extension class is the base class for all extensions. * @see https://tiptap.dev/api