'use strict'; var state = require('@tiptap/pm/state'); var view = require('@tiptap/pm/view'); var keymap = require('@tiptap/pm/keymap'); var model = require('@tiptap/pm/model'); var transform = require('@tiptap/pm/transform'); var commands$1 = require('@tiptap/pm/commands'); var schemaList = require('@tiptap/pm/schema-list'); /** * 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 ? 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), {}); } 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; } 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 (e) { 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 (e) { 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; }; 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) { 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, 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); } }); } } /** * The Extension class is the base class for all extensions. * @see https://tiptap.dev/api/extensions#create-a-new-extension */ class Extension { constructor(config = {}) { this.type = 'extension'; this.name = 'extension'; 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 Extension(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 Extension({ ...this.config, ...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; } } /** * Gets the text between two positions in a Prosemirror node * and serializes it using the given text serializers and block separator (see getText) * @param startNode The Prosemirror node to start from * @param range The range of the text to get * @param options Options for the text serializer & block separator * @returns The text between the two positions */ function getTextBetween(startNode, range, options) { const { from, to } = range; const { blockSeparator = '\n\n', textSerializers = {} } = options || {}; let text = ''; startNode.nodesBetween(from, to, (node, pos, parent, index) => { var _a; if (node.isBlock && pos > from) { text += blockSeparator; } const textSerializer = textSerializers === null || textSerializers === void 0 ? void 0 : textSerializers[node.type.name]; if (textSerializer) { if (parent) { text += textSerializer({ node, pos, parent, index, range, }); } // do not descend into child nodes when there exists a serializer return false; } if (node.isText) { text += (_a = node === null || node === void 0 ? void 0 : node.text) === null || _a === void 0 ? void 0 : _a.slice(Math.max(from, pos) - pos, to - pos); // eslint-disable-line } }); return text; } /** * Find text serializers `toText` in a Prosemirror schema * @param schema The Prosemirror schema to search in * @returns A record of text serializers by node name */ function getTextSerializersFromSchema(schema) { return Object.fromEntries(Object.entries(schema.nodes) .filter(([, node]) => node.spec.toText) .map(([name, node]) => [name, node.spec.toText])); } const ClipboardTextSerializer = Extension.create({ name: 'clipboardTextSerializer', addOptions() { return { blockSeparator: undefined, }; }, addProseMirrorPlugins() { return [ new state.Plugin({ key: new state.PluginKey('clipboardTextSerializer'), props: { clipboardTextSerializer: () => { const { editor } = this; const { state, schema } = editor; const { doc, selection } = state; const { ranges } = selection; const from = Math.min(...ranges.map(range => range.$from.pos)); const to = Math.max(...ranges.map(range => range.$to.pos)); const textSerializers = getTextSerializersFromSchema(schema); const range = { from, to }; return getTextBetween(doc, range, { ...(this.options.blockSeparator !== undefined ? { blockSeparator: this.options.blockSeparator } : {}), textSerializers, }); }, }, }), ]; }, }); const blur = () => ({ editor, view }) => { requestAnimationFrame(() => { var _a; if (!editor.isDestroyed) { view.dom.blur(); // Browsers should remove the caret on blur but safari does not. // See: https://github.com/ueberdosis/tiptap/issues/2405 (_a = window === null || window === void 0 ? void 0 : window.getSelection()) === null || _a === void 0 ? void 0 : _a.removeAllRanges(); } }); return true; }; const clearContent = (emitUpdate = false) => ({ commands }) => { return commands.setContent('', emitUpdate); }; const clearNodes = () => ({ state, tr, dispatch }) => { const { selection } = tr; const { ranges } = selection; if (!dispatch) { return true; } ranges.forEach(({ $from, $to }) => { state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { if (node.type.isText) { return; } const { doc, mapping } = tr; const $mappedFrom = doc.resolve(mapping.map(pos)); const $mappedTo = doc.resolve(mapping.map(pos + node.nodeSize)); const nodeRange = $mappedFrom.blockRange($mappedTo); if (!nodeRange) { return; } const targetLiftDepth = transform.liftTarget(nodeRange); if (node.type.isTextblock) { const { defaultType } = $mappedFrom.parent.contentMatchAt($mappedFrom.index()); tr.setNodeMarkup(nodeRange.start, defaultType); } if (targetLiftDepth || targetLiftDepth === 0) { tr.lift(nodeRange, targetLiftDepth); } }); }); return true; }; const command = fn => props => { return fn(props); }; const createParagraphNear = () => ({ state, dispatch }) => { return commands$1.createParagraphNear(state, dispatch); }; const cut = (originRange, targetPos) => ({ editor, tr }) => { const { state: state$1 } = editor; const contentSlice = state$1.doc.slice(originRange.from, originRange.to); tr.deleteRange(originRange.from, originRange.to); const newPos = tr.mapping.map(targetPos); tr.insert(newPos, contentSlice.content); tr.setSelection(new state.TextSelection(tr.doc.resolve(newPos - 1))); return true; }; const deleteCurrentNode = () => ({ tr, dispatch }) => { const { selection } = tr; const currentNode = selection.$anchor.node(); // if there is content inside the current node, break out of this command if (currentNode.content.size > 0) { return false; } const $pos = tr.selection.$anchor; for (let depth = $pos.depth; depth > 0; depth -= 1) { const node = $pos.node(depth); if (node.type === currentNode.type) { if (dispatch) { const from = $pos.before(depth); const to = $pos.after(depth); tr.delete(from, to).scrollIntoView(); } return true; } } return false; }; const deleteNode = typeOrName => ({ tr, state, dispatch }) => { const type = getNodeType(typeOrName, state.schema); const $pos = tr.selection.$anchor; for (let depth = $pos.depth; depth > 0; depth -= 1) { const node = $pos.node(depth); if (node.type === type) { if (dispatch) { const from = $pos.before(depth); const to = $pos.after(depth); tr.delete(from, to).scrollIntoView(); } return true; } } return false; }; const deleteRange = range => ({ tr, dispatch }) => { const { from, to } = range; if (dispatch) { tr.delete(from, to); } return true; }; const deleteSelection = () => ({ state, dispatch }) => { return commands$1.deleteSelection(state, dispatch); }; const enter = () => ({ commands }) => { return commands.keyboardShortcut('Enter'); }; const exitCode = () => ({ state, dispatch }) => { return commands$1.exitCode(state, dispatch); }; /** * Check if object1 includes object2 * @param object1 Object * @param object2 Object */ function objectIncludes(object1, object2, options = { strict: true }) { const keys = Object.keys(object2); if (!keys.length) { return true; } return keys.every(key => { if (options.strict) { return object2[key] === object1[key]; } if (isRegExp(object2[key])) { return object2[key].test(object1[key]); } return object2[key] === object1[key]; }); } function findMarkInSet(marks, type, attributes = {}) { return marks.find(item => { return (item.type === type && objectIncludes( // Only check equality for the attributes that are provided Object.fromEntries(Object.keys(attributes).map(k => [k, item.attrs[k]])), attributes)); }); } function isMarkInSet(marks, type, attributes = {}) { return !!findMarkInSet(marks, type, attributes); } /** * Get the range of a mark at a resolved position. */ function getMarkRange( /** * The position to get the mark range for. */ $pos, /** * The mark type to get the range for. */ type, /** * The attributes to match against. * If not provided, only the first mark at the position will be matched. */ attributes) { var _a; if (!$pos || !type) { return; } let start = $pos.parent.childAfter($pos.parentOffset); // If the cursor is at the start of a text node that does not have the mark, look backward if (!start.node || !start.node.marks.some(mark => mark.type === type)) { start = $pos.parent.childBefore($pos.parentOffset); } // If there is no text node with the mark even backward, return undefined if (!start.node || !start.node.marks.some(mark => mark.type === type)) { return; } // Default to only matching against the first mark's attributes attributes = attributes || ((_a = start.node.marks[0]) === null || _a === void 0 ? void 0 : _a.attrs); // We now know that the cursor is either at the start, middle or end of a text node with the specified mark // so we can look it up on the targeted mark const mark = findMarkInSet([...start.node.marks], type, attributes); if (!mark) { return; } let startIndex = start.index; let startPos = $pos.start() + start.offset; let endIndex = startIndex + 1; let endPos = startPos + start.node.nodeSize; while (startIndex > 0 && isMarkInSet([...$pos.parent.child(startIndex - 1).marks], type, attributes)) { startIndex -= 1; startPos -= $pos.parent.child(startIndex).nodeSize; } while (endIndex < $pos.parent.childCount && isMarkInSet([...$pos.parent.child(endIndex).marks], type, attributes)) { endPos += $pos.parent.child(endIndex).nodeSize; endIndex += 1; } return { from: startPos, to: endPos, }; } function getMarkType(nameOrType, schema) { if (typeof nameOrType === 'string') { if (!schema.marks[nameOrType]) { throw Error(`There is no mark type named '${nameOrType}'. Maybe you forgot to add the extension?`); } return schema.marks[nameOrType]; } return nameOrType; } const extendMarkRange = (typeOrName, attributes = {}) => ({ tr, state: state$1, dispatch }) => { const type = getMarkType(typeOrName, state$1.schema); const { doc, selection } = tr; const { $from, from, to } = selection; if (dispatch) { const range = getMarkRange($from, type, attributes); if (range && range.from <= from && range.to >= to) { const newSelection = state.TextSelection.create(doc, range.from, range.to); tr.setSelection(newSelection); } } return true; }; const first = commands => props => { const items = typeof commands === 'function' ? commands(props) : commands; for (let i = 0; i < items.length; i += 1) { if (items[i](props)) { return true; } } return false; }; function isTextSelection(value) { return value instanceof state.TextSelection; } function minMax(value = 0, min = 0, max = 0) { return Math.min(Math.max(value, min), max); } function resolveFocusPosition(doc, position = null) { if (!position) { return null; } const selectionAtStart = state.Selection.atStart(doc); const selectionAtEnd = state.Selection.atEnd(doc); if (position === 'start' || position === true) { return selectionAtStart; } if (position === 'end') { return selectionAtEnd; } const minPos = selectionAtStart.from; const maxPos = selectionAtEnd.to; if (position === 'all') { return state.TextSelection.create(doc, minMax(0, minPos, maxPos), minMax(doc.content.size, minPos, maxPos)); } return state.TextSelection.create(doc, minMax(position, minPos, maxPos), minMax(position, minPos, maxPos)); } function isiOS() { return [ 'iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod', ].includes(navigator.platform) // iPad on iOS 13 detection || (navigator.userAgent.includes('Mac') && 'ontouchend' in document); } const focus = (position = null, options = {}) => ({ editor, view, tr, dispatch, }) => { options = { scrollIntoView: true, ...options, }; const delayedFocus = () => { // focus within `requestAnimationFrame` breaks focus on iOS // so we have to call this if (isiOS()) { view.dom.focus(); } // For React we have to focus asynchronously. Otherwise wild things happen. // see: https://github.com/ueberdosis/tiptap/issues/1520 requestAnimationFrame(() => { if (!editor.isDestroyed) { view.focus(); if (options === null || options === void 0 ? void 0 : options.scrollIntoView) { editor.commands.scrollIntoView(); } } }); }; if ((view.hasFocus() && position === null) || position === false) { return true; } // we don’t try to resolve a NodeSelection or CellSelection if (dispatch && position === null && !isTextSelection(editor.state.selection)) { delayedFocus(); return true; } // pass through tr.doc instead of editor.state.doc // since transactions could change the editors state before this command has been run const selection = resolveFocusPosition(tr.doc, position) || editor.state.selection; const isSameSelection = editor.state.selection.eq(selection); if (dispatch) { if (!isSameSelection) { tr.setSelection(selection); } // `tr.setSelection` resets the stored marks // so we’ll restore them if the selection is the same as before if (isSameSelection && tr.storedMarks) { tr.setStoredMarks(tr.storedMarks); } delayedFocus(); } return true; }; const forEach = (items, fn) => props => { return items.every((item, index) => fn(item, { ...props, index })); }; const insertContent = (value, options) => ({ tr, commands }) => { return commands.insertContentAt({ from: tr.selection.from, to: tr.selection.to }, value, options); }; const removeWhitespaces = (node) => { const children = node.childNodes; for (let i = children.length - 1; i >= 0; i -= 1) { const child = children[i]; if (child.nodeType === 3 && child.nodeValue && /^(\n\s\s|\n)$/.test(child.nodeValue)) { node.removeChild(child); } else if (child.nodeType === 1) { removeWhitespaces(child); } } return node; }; function elementFromString(value) { // add a wrapper to preserve leading and trailing whitespace const wrappedValue = `${value}`; const html = new window.DOMParser().parseFromString(wrappedValue, 'text/html').body; return removeWhitespaces(html); } /** * Takes a JSON or HTML content and creates a Prosemirror node or fragment from it. * @param content The JSON or HTML content to create the node from * @param schema The Prosemirror schema to use for the node * @param options Options for the parser * @returns The created Prosemirror node or fragment */ function createNodeFromContent(content, schema, options) { if (content instanceof model.Node || content instanceof model.Fragment) { return content; } options = { slice: true, parseOptions: {}, ...options, }; const isJSONContent = typeof content === 'object' && content !== null; const isTextContent = typeof content === 'string'; if (isJSONContent) { try { const isArrayContent = Array.isArray(content) && content.length > 0; // if the JSON Content is an array of nodes, create a fragment for each node if (isArrayContent) { return model.Fragment.fromArray(content.map(item => schema.nodeFromJSON(item))); } const node = schema.nodeFromJSON(content); if (options.errorOnInvalidContent) { node.check(); } return node; } catch (error) { if (options.errorOnInvalidContent) { throw new Error('[tiptap error]: Invalid JSON content', { cause: error }); } console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error); return createNodeFromContent('', schema, options); } } if (isTextContent) { // Check for invalid content if (options.errorOnInvalidContent) { let hasInvalidContent = false; let invalidContent = ''; // A copy of the current schema with a catch-all node at the end const contentCheckSchema = new model.Schema({ topNode: schema.spec.topNode, marks: schema.spec.marks, // Prosemirror's schemas are executed such that: the last to execute, matches last // This means that we can add a catch-all node at the end of the schema to catch any content that we don't know how to handle nodes: schema.spec.nodes.append({ __tiptap__private__unknown__catch__all__node: { content: 'inline*', group: 'block', parseDOM: [ { tag: '*', getAttrs: e => { // If this is ever called, we know that the content has something that we don't know how to handle in the schema hasInvalidContent = true; // Try to stringify the element for a more helpful error message invalidContent = typeof e === 'string' ? e : e.outerHTML; return null; }, }, ], }, }), }); if (options.slice) { model.DOMParser.fromSchema(contentCheckSchema).parseSlice(elementFromString(content), options.parseOptions); } else { model.DOMParser.fromSchema(contentCheckSchema).parse(elementFromString(content), options.parseOptions); } if (options.errorOnInvalidContent && hasInvalidContent) { throw new Error('[tiptap error]: Invalid HTML content', { cause: new Error(`Invalid element found: ${invalidContent}`) }); } } const parser = model.DOMParser.fromSchema(schema); if (options.slice) { return parser.parseSlice(elementFromString(content), options.parseOptions).content; } return parser.parse(elementFromString(content), options.parseOptions); } return createNodeFromContent('', schema, options); } // source: https://github.com/ProseMirror/prosemirror-state/blob/master/src/selection.js#L466 function selectionToInsertionEnd(tr, startLen, bias) { const last = tr.steps.length - 1; if (last < startLen) { return; } const step = tr.steps[last]; if (!(step instanceof transform.ReplaceStep || step instanceof transform.ReplaceAroundStep)) { return; } const map = tr.mapping.maps[last]; let end = 0; map.forEach((_from, _to, _newFrom, newTo) => { if (end === 0) { end = newTo; } }); tr.setSelection(state.Selection.near(tr.doc.resolve(end), bias)); } const isFragment = (nodeOrFragment) => { return !('type' in nodeOrFragment); }; const insertContentAt = (position, value, options) => ({ tr, dispatch, editor }) => { var _a; if (dispatch) { options = { parseOptions: editor.options.parseOptions, updateSelection: true, applyInputRules: false, applyPasteRules: false, ...options, }; let content; try { content = createNodeFromContent(value, editor.schema, { parseOptions: { preserveWhitespace: 'full', ...options.parseOptions, }, errorOnInvalidContent: (_a = options.errorOnInvalidContent) !== null && _a !== void 0 ? _a : editor.options.enableContentCheck, }); } catch (e) { editor.emit('contentError', { editor, error: e, disableCollaboration: () => { if (editor.storage.collaboration) { editor.storage.collaboration.isDisabled = true; } }, }); return false; } let { from, to } = typeof position === 'number' ? { from: position, to: position } : { from: position.from, to: position.to }; let isOnlyTextContent = true; let isOnlyBlockContent = true; const nodes = isFragment(content) ? content : [content]; nodes.forEach(node => { // check if added node is valid node.check(); isOnlyTextContent = isOnlyTextContent ? node.isText && node.marks.length === 0 : false; isOnlyBlockContent = isOnlyBlockContent ? node.isBlock : false; }); // check if we can replace the wrapping node by // the newly inserted content // example: // replace an empty paragraph by an inserted image // instead of inserting the image below the paragraph if (from === to && isOnlyBlockContent) { const { parent } = tr.doc.resolve(from); const isEmptyTextBlock = parent.isTextblock && !parent.type.spec.code && !parent.childCount; if (isEmptyTextBlock) { from -= 1; to += 1; } } let newContent; // if there is only plain text we have to use `insertText` // because this will keep the current marks if (isOnlyTextContent) { // if value is string, we can use it directly // otherwise if it is an array, we have to join it if (Array.isArray(value)) { newContent = value.map(v => v.text || '').join(''); } else if (value instanceof model.Fragment) { let text = ''; value.forEach(node => { if (node.text) { text += node.text; } }); newContent = text; } else if (typeof value === 'object' && !!value && !!value.text) { newContent = value.text; } else { newContent = value; } tr.insertText(newContent, from, to); } else { newContent = content; tr.replaceWith(from, to, newContent); } // set cursor at end of inserted content if (options.updateSelection) { selectionToInsertionEnd(tr, tr.steps.length - 1, -1); } if (options.applyInputRules) { tr.setMeta('applyInputRules', { from, text: newContent }); } if (options.applyPasteRules) { tr.setMeta('applyPasteRules', { from, text: newContent }); } } return true; }; const joinUp = () => ({ state, dispatch }) => { return commands$1.joinUp(state, dispatch); }; const joinDown = () => ({ state, dispatch }) => { return commands$1.joinDown(state, dispatch); }; const joinBackward = () => ({ state, dispatch }) => { return commands$1.joinBackward(state, dispatch); }; const joinForward = () => ({ state, dispatch }) => { return commands$1.joinForward(state, dispatch); }; const joinItemBackward = () => ({ state, dispatch, tr, }) => { try { const point = transform.joinPoint(state.doc, state.selection.$from.pos, -1); if (point === null || point === undefined) { return false; } tr.join(point, 2); if (dispatch) { dispatch(tr); } return true; } catch (e) { return false; } }; const joinItemForward = () => ({ state, dispatch, tr, }) => { try { const point = transform.joinPoint(state.doc, state.selection.$from.pos, +1); if (point === null || point === undefined) { return false; } tr.join(point, 2); if (dispatch) { dispatch(tr); } return true; } catch (e) { return false; } }; const joinTextblockBackward = () => ({ state, dispatch }) => { return commands$1.joinTextblockBackward(state, dispatch); }; const joinTextblockForward = () => ({ state, dispatch }) => { return commands$1.joinTextblockForward(state, dispatch); }; function isMacOS() { return typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false; } function normalizeKeyName(name) { const parts = name.split(/-(?!$)/); let result = parts[parts.length - 1]; if (result === 'Space') { result = ' '; } let alt; let ctrl; let shift; let meta; for (let i = 0; i < parts.length - 1; i += 1) { const mod = parts[i]; if (/^(cmd|meta|m)$/i.test(mod)) { meta = true; } else if (/^a(lt)?$/i.test(mod)) { alt = true; } else if (/^(c|ctrl|control)$/i.test(mod)) { ctrl = true; } else if (/^s(hift)?$/i.test(mod)) { shift = true; } else if (/^mod$/i.test(mod)) { if (isiOS() || isMacOS()) { meta = true; } else { ctrl = true; } } else { throw new Error(`Unrecognized modifier name: ${mod}`); } } if (alt) { result = `Alt-${result}`; } if (ctrl) { result = `Ctrl-${result}`; } if (meta) { result = `Meta-${result}`; } if (shift) { result = `Shift-${result}`; } return result; } const keyboardShortcut = name => ({ editor, view, tr, dispatch, }) => { const keys = normalizeKeyName(name).split(/-(?!$)/); const key = keys.find(item => !['Alt', 'Ctrl', 'Meta', 'Shift'].includes(item)); const event = new KeyboardEvent('keydown', { key: key === 'Space' ? ' ' : key, altKey: keys.includes('Alt'), ctrlKey: keys.includes('Ctrl'), metaKey: keys.includes('Meta'), shiftKey: keys.includes('Shift'), bubbles: true, cancelable: true, }); const capturedTransaction = editor.captureTransaction(() => { view.someProp('handleKeyDown', f => f(view, event)); }); capturedTransaction === null || capturedTransaction === void 0 ? void 0 : capturedTransaction.steps.forEach(step => { const newStep = step.map(tr.mapping); if (newStep && dispatch) { tr.maybeStep(newStep); } }); return true; }; function isNodeActive(state, typeOrName, attributes = {}) { const { from, to, empty } = state.selection; const type = typeOrName ? getNodeType(typeOrName, state.schema) : null; const nodeRanges = []; state.doc.nodesBetween(from, to, (node, pos) => { if (node.isText) { return; } const relativeFrom = Math.max(from, pos); const relativeTo = Math.min(to, pos + node.nodeSize); nodeRanges.push({ node, from: relativeFrom, to: relativeTo, }); }); const selectionRange = to - from; const matchedNodeRanges = nodeRanges .filter(nodeRange => { if (!type) { return true; } return type.name === nodeRange.node.type.name; }) .filter(nodeRange => objectIncludes(nodeRange.node.attrs, attributes, { strict: false })); if (empty) { return !!matchedNodeRanges.length; } const range = matchedNodeRanges.reduce((sum, nodeRange) => sum + nodeRange.to - nodeRange.from, 0); return range >= selectionRange; } const lift = (typeOrName, attributes = {}) => ({ state, dispatch }) => { const type = getNodeType(typeOrName, state.schema); const isActive = isNodeActive(state, type, attributes); if (!isActive) { return false; } return commands$1.lift(state, dispatch); }; const liftEmptyBlock = () => ({ state, dispatch }) => { return commands$1.liftEmptyBlock(state, dispatch); }; const liftListItem = typeOrName => ({ state, dispatch }) => { const type = getNodeType(typeOrName, state.schema); return schemaList.liftListItem(type)(state, dispatch); }; const newlineInCode = () => ({ state, dispatch }) => { return commands$1.newlineInCode(state, dispatch); }; /** * Get the type of a schema item by its name. * @param name The name of the schema item * @param schema The Prosemiror schema to search in * @returns The type of the schema item (`node` or `mark`), or null if it doesn't exist */ function getSchemaTypeNameByName(name, schema) { if (schema.nodes[name]) { return 'node'; } if (schema.marks[name]) { return 'mark'; } return null; } /** * Remove a property or an array of properties from an object * @param obj Object * @param key Key to remove */ function deleteProps(obj, propOrProps) { const props = typeof propOrProps === 'string' ? [propOrProps] : propOrProps; return Object .keys(obj) .reduce((newObj, prop) => { if (!props.includes(prop)) { newObj[prop] = obj[prop]; } return newObj; }, {}); } const resetAttributes = (typeOrName, attributes) => ({ tr, state, dispatch }) => { let nodeType = null; let markType = null; const schemaType = getSchemaTypeNameByName(typeof typeOrName === 'string' ? typeOrName : typeOrName.name, state.schema); if (!schemaType) { return false; } if (schemaType === 'node') { nodeType = getNodeType(typeOrName, state.schema); } if (schemaType === 'mark') { markType = getMarkType(typeOrName, state.schema); } if (dispatch) { tr.selection.ranges.forEach(range => { state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => { if (nodeType && nodeType === node.type) { tr.setNodeMarkup(pos, undefined, deleteProps(node.attrs, attributes)); } if (markType && node.marks.length) { node.marks.forEach(mark => { if (markType === mark.type) { tr.addMark(pos, pos + node.nodeSize, markType.create(deleteProps(mark.attrs, attributes))); } }); } }); }); } return true; }; const scrollIntoView = () => ({ tr, dispatch }) => { if (dispatch) { tr.scrollIntoView(); } return true; }; const selectAll = () => ({ tr, commands }) => { return commands.setTextSelection({ from: 0, to: tr.doc.content.size, }); }; const selectNodeBackward = () => ({ state, dispatch }) => { return commands$1.selectNodeBackward(state, dispatch); }; const selectNodeForward = () => ({ state, dispatch }) => { return commands$1.selectNodeForward(state, dispatch); }; const selectParentNode = () => ({ state, dispatch }) => { return commands$1.selectParentNode(state, dispatch); }; // @ts-ignore // TODO: add types to @types/prosemirror-commands const selectTextblockEnd = () => ({ state, dispatch }) => { return commands$1.selectTextblockEnd(state, dispatch); }; // @ts-ignore // TODO: add types to @types/prosemirror-commands const selectTextblockStart = () => ({ state, dispatch }) => { return commands$1.selectTextblockStart(state, dispatch); }; /** * Create a new Prosemirror document node from content. * @param content The JSON or HTML content to create the document from * @param schema The Prosemirror schema to use for the document * @param parseOptions Options for the parser * @returns The created Prosemirror document node */ function createDocument(content, schema, parseOptions = {}, options = {}) { return createNodeFromContent(content, schema, { slice: false, parseOptions, errorOnInvalidContent: options.errorOnInvalidContent, }); } const setContent = (content, emitUpdate = false, parseOptions = {}, options = {}) => ({ editor, tr, dispatch, commands, }) => { var _a, _b; const { doc } = tr; // This is to keep backward compatibility with the previous behavior // TODO remove this in the next major version if (parseOptions.preserveWhitespace !== 'full') { const document = createDocument(content, editor.schema, parseOptions, { errorOnInvalidContent: (_a = options.errorOnInvalidContent) !== null && _a !== void 0 ? _a : editor.options.enableContentCheck, }); if (dispatch) { tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', !emitUpdate); } return true; } if (dispatch) { tr.setMeta('preventUpdate', !emitUpdate); } return commands.insertContentAt({ from: 0, to: doc.content.size }, content, { parseOptions, errorOnInvalidContent: (_b = options.errorOnInvalidContent) !== null && _b !== void 0 ? _b : editor.options.enableContentCheck, }); }; function getMarkAttributes(state, typeOrName) { const type = getMarkType(typeOrName, state.schema); const { from, to, empty } = state.selection; const marks = []; if (empty) { if (state.storedMarks) { marks.push(...state.storedMarks); } marks.push(...state.selection.$head.marks()); } else { state.doc.nodesBetween(from, to, node => { marks.push(...node.marks); }); } const mark = marks.find(markItem => markItem.type.name === type.name); if (!mark) { return {}; } return { ...mark.attrs }; } /** * Returns a new `Transform` based on all steps of the passed transactions. * @param oldDoc The Prosemirror node to start from * @param transactions The transactions to combine * @returns A new `Transform` with all steps of the passed transactions */ function combineTransactionSteps(oldDoc, transactions) { const transform$1 = new transform.Transform(oldDoc); transactions.forEach(transaction => { transaction.steps.forEach(step => { transform$1.step(step); }); }); return transform$1; } /** * Gets the default block type at a given match * @param match The content match to get the default block type from * @returns The default block type or null */ function defaultBlockAt(match) { for (let i = 0; i < match.edgeCount; i += 1) { const { type } = match.edge(i); if (type.isTextblock && !type.hasRequiredAttrs()) { return type; } } return null; } /** * Find children inside a Prosemirror node that match a predicate. * @param node The Prosemirror node to search in * @param predicate The predicate to match * @returns An array of nodes with their positions */ function findChildren(node, predicate) { const nodesWithPos = []; node.descendants((child, pos) => { if (predicate(child)) { nodesWithPos.push({ node: child, pos, }); } }); return nodesWithPos; } /** * Same as `findChildren` but searches only within a `range`. * @param node The Prosemirror node to search in * @param range The range to search in * @param predicate The predicate to match * @returns An array of nodes with their positions */ function findChildrenInRange(node, range, predicate) { const nodesWithPos = []; // if (range.from === range.to) { // const nodeAt = node.nodeAt(range.from) // if (nodeAt) { // nodesWithPos.push({ // node: nodeAt, // pos: range.from, // }) // } // } node.nodesBetween(range.from, range.to, (child, pos) => { if (predicate(child)) { nodesWithPos.push({ node: child, pos, }); } }); return nodesWithPos; } /** * Finds the closest parent node to a resolved position that matches a predicate. * @param $pos The resolved position to search from * @param predicate The predicate to match * @returns The closest parent node to the resolved position that matches the predicate * @example ```js * findParentNodeClosestToPos($from, node => node.type.name === 'paragraph') * ``` */ function findParentNodeClosestToPos($pos, predicate) { for (let i = $pos.depth; i > 0; i -= 1) { const node = $pos.node(i); if (predicate(node)) { return { pos: i > 0 ? $pos.before(i) : 0, start: $pos.start(i), depth: i, node, }; } } } /** * Finds the closest parent node to the current selection that matches a predicate. * @param predicate The predicate to match * @returns A command that finds the closest parent node to the current selection that matches the predicate * @example ```js * findParentNode(node => node.type.name === 'paragraph') * ``` */ function findParentNode(predicate) { return (selection) => findParentNodeClosestToPos(selection.$from, predicate); } function getSchema(extensions, editor) { const resolvedExtensions = ExtensionManager.resolve(extensions); return getSchemaByResolvedExtensions(resolvedExtensions, editor); } /** * Generate HTML from a JSONContent * @param doc The JSONContent to generate HTML from * @param extensions The extensions to use for the schema * @returns The generated HTML */ function generateHTML(doc, extensions) { const schema = getSchema(extensions); const contentNode = model.Node.fromJSON(schema, doc); return getHTMLFromFragment(contentNode.content, schema); } /** * Generate JSONContent from HTML * @param html The HTML to generate JSONContent from * @param extensions The extensions to use for the schema * @returns The generated JSONContent */ function generateJSON(html, extensions) { const schema = getSchema(extensions); const dom = elementFromString(html); return model.DOMParser.fromSchema(schema).parse(dom).toJSON(); } /** * Gets the text of a Prosemirror node * @param node The Prosemirror node * @param options Options for the text serializer & block separator * @returns The text of the node * @example ```js * const text = getText(node, { blockSeparator: '\n' }) * ``` */ function getText(node, options) { const range = { from: 0, to: node.content.size, }; return getTextBetween(node, range, options); } /** * Generate raw text from a JSONContent * @param doc The JSONContent to generate text from * @param extensions The extensions to use for the schema * @param options Options for the text generation f.e. blockSeparator or textSerializers * @returns The generated text */ function generateText(doc, extensions, options) { const { blockSeparator = '\n\n', textSerializers = {} } = options || {}; const schema = getSchema(extensions); const contentNode = model.Node.fromJSON(schema, doc); return getText(contentNode, { blockSeparator, textSerializers: { ...getTextSerializersFromSchema(schema), ...textSerializers, }, }); } function getNodeAttributes(state, typeOrName) { const type = getNodeType(typeOrName, state.schema); const { from, to } = state.selection; const nodes = []; state.doc.nodesBetween(from, to, node => { nodes.push(node); }); const node = nodes.reverse().find(nodeItem => nodeItem.type.name === type.name); if (!node) { return {}; } return { ...node.attrs }; } /** * Get node or mark attributes by type or name on the current editor state * @param state The current editor state * @param typeOrName The node or mark type or name * @returns The attributes of the node or mark or an empty object */ function getAttributes(state, typeOrName) { const schemaType = getSchemaTypeNameByName(typeof typeOrName === 'string' ? typeOrName : typeOrName.name, state.schema); if (schemaType === 'node') { return getNodeAttributes(state, typeOrName); } if (schemaType === 'mark') { return getMarkAttributes(state, typeOrName); } return {}; } /** * Removes duplicated values within an array. * Supports numbers, strings and objects. */ function removeDuplicates(array, by = JSON.stringify) { const seen = {}; return array.filter(item => { const key = by(item); return Object.prototype.hasOwnProperty.call(seen, key) ? false : (seen[key] = true); }); } /** * Removes duplicated ranges and ranges that are * fully captured by other ranges. */ function simplifyChangedRanges(changes) { const uniqueChanges = removeDuplicates(changes); return uniqueChanges.length === 1 ? uniqueChanges : uniqueChanges.filter((change, index) => { const rest = uniqueChanges.filter((_, i) => i !== index); return !rest.some(otherChange => { return change.oldRange.from >= otherChange.oldRange.from && change.oldRange.to <= otherChange.oldRange.to && change.newRange.from >= otherChange.newRange.from && change.newRange.to <= otherChange.newRange.to; }); }); } /** * Returns a list of changed ranges * based on the first and last state of all steps. */ function getChangedRanges(transform) { const { mapping, steps } = transform; const changes = []; mapping.maps.forEach((stepMap, index) => { const ranges = []; // This accounts for step changes where no range was actually altered // e.g. when setting a mark, node attribute, etc. // @ts-ignore if (!stepMap.ranges.length) { const { from, to } = steps[index]; if (from === undefined || to === undefined) { return; } ranges.push({ from, to }); } else { stepMap.forEach((from, to) => { ranges.push({ from, to }); }); } ranges.forEach(({ from, to }) => { const newStart = mapping.slice(index).map(from, -1); const newEnd = mapping.slice(index).map(to); const oldStart = mapping.invert().map(newStart, -1); const oldEnd = mapping.invert().map(newEnd); changes.push({ oldRange: { from: oldStart, to: oldEnd, }, newRange: { from: newStart, to: newEnd, }, }); }); }); return simplifyChangedRanges(changes); } function getDebugJSON(node, startOffset = 0) { const isTopNode = node.type === node.type.schema.topNodeType; const increment = isTopNode ? 0 : 1; const from = startOffset; const to = from + node.nodeSize; const marks = node.marks.map(mark => { const output = { type: mark.type.name, }; if (Object.keys(mark.attrs).length) { output.attrs = { ...mark.attrs }; } return output; }); const attrs = { ...node.attrs }; const output = { type: node.type.name, from, to, }; if (Object.keys(attrs).length) { output.attrs = attrs; } if (marks.length) { output.marks = marks; } if (node.content.childCount) { output.content = []; node.forEach((child, offset) => { var _a; (_a = output.content) === null || _a === void 0 ? void 0 : _a.push(getDebugJSON(child, startOffset + offset + increment)); }); } if (node.text) { output.text = node.text; } return output; } function getMarksBetween(from, to, doc) { const marks = []; // get all inclusive marks on empty selection if (from === to) { doc .resolve(from) .marks() .forEach(mark => { const $pos = doc.resolve(from); const range = getMarkRange($pos, mark.type); if (!range) { return; } marks.push({ mark, ...range, }); }); } else { doc.nodesBetween(from, to, (node, pos) => { if (!node || (node === null || node === void 0 ? void 0 : node.nodeSize) === undefined) { return; } marks.push(...node.marks.map(mark => ({ from: pos, to: pos + node.nodeSize, mark, }))); }); } return marks; } /** * Finds the first node of a given type or name in the current selection. * @param state The editor state. * @param typeOrName The node type or name. * @param pos The position to start searching from. * @param maxDepth The maximum depth to search. * @returns The node and the depth as an array. */ const getNodeAtPosition = (state, typeOrName, pos, maxDepth = 20) => { const $pos = state.doc.resolve(pos); let currentDepth = maxDepth; let node = null; while (currentDepth > 0 && node === null) { const currentNode = $pos.node(currentDepth); if ((currentNode === null || currentNode === void 0 ? void 0 : currentNode.type.name) === typeOrName) { node = currentNode; } else { currentDepth -= 1; } } return [node, currentDepth]; }; /** * Return attributes of an extension that should be splitted by keepOnSplit flag * @param extensionAttributes Array of extension attributes * @param typeName The type of the extension * @param attributes The attributes of the extension * @returns The splitted attributes */ function getSplittedAttributes(extensionAttributes, typeName, attributes) { return Object.fromEntries(Object .entries(attributes) .filter(([name]) => { const extensionAttribute = extensionAttributes.find(item => { return item.type === typeName && item.name === name; }); if (!extensionAttribute) { return false; } return extensionAttribute.attribute.keepOnSplit; })); } function isMarkActive(state, typeOrName, attributes = {}) { const { empty, ranges } = state.selection; const type = typeOrName ? getMarkType(typeOrName, state.schema) : null; if (empty) { return !!(state.storedMarks || state.selection.$from.marks()) .filter(mark => { if (!type) { return true; } return type.name === mark.type.name; }) .find(mark => objectIncludes(mark.attrs, attributes, { strict: false })); } let selectionRange = 0; const markRanges = []; ranges.forEach(({ $from, $to }) => { const from = $from.pos; const to = $to.pos; state.doc.nodesBetween(from, to, (node, pos) => { if (!node.isText && !node.marks.length) { return; } const relativeFrom = Math.max(from, pos); const relativeTo = Math.min(to, pos + node.nodeSize); const range = relativeTo - relativeFrom; selectionRange += range; markRanges.push(...node.marks.map(mark => ({ mark, from: relativeFrom, to: relativeTo, }))); }); }); if (selectionRange === 0) { return false; } // calculate range of matched mark const matchedRange = markRanges .filter(markRange => { if (!type) { return true; } return type.name === markRange.mark.type.name; }) .filter(markRange => objectIncludes(markRange.mark.attrs, attributes, { strict: false })) .reduce((sum, markRange) => sum + markRange.to - markRange.from, 0); // calculate range of marks that excludes the searched mark // for example `code` doesn’t allow any other marks const excludedRange = markRanges .filter(markRange => { if (!type) { return true; } return markRange.mark.type !== type && markRange.mark.type.excludes(type); }) .reduce((sum, markRange) => sum + markRange.to - markRange.from, 0); // we only include the result of `excludedRange` // if there is a match at all const range = matchedRange > 0 ? matchedRange + excludedRange : matchedRange; return range >= selectionRange; } function isActive(state, name, attributes = {}) { if (!name) { return isNodeActive(state, null, attributes) || isMarkActive(state, null, attributes); } const schemaType = getSchemaTypeNameByName(name, state.schema); if (schemaType === 'node') { return isNodeActive(state, name, attributes); } if (schemaType === 'mark') { return isMarkActive(state, name, attributes); } return false; } const isAtEndOfNode = (state, nodeType) => { const { $from, $to, $anchor } = state.selection; if (nodeType) { const parentNode = findParentNode(node => node.type.name === nodeType)(state.selection); if (!parentNode) { return false; } const $parentPos = state.doc.resolve(parentNode.pos + 1); if ($anchor.pos + 1 === $parentPos.end()) { return true; } return false; } if ($to.parentOffset < $to.parent.nodeSize - 2 || $from.pos !== $to.pos) { return false; } return true; }; const isAtStartOfNode = (state) => { const { $from, $to } = state.selection; if ($from.parentOffset > 0 || $from.pos !== $to.pos) { return false; } return true; }; function isList(name, extensions) { const { nodeExtensions } = splitExtensions(extensions); const extension = nodeExtensions.find(item => item.name === name); if (!extension) { return false; } const context = { name: extension.name, options: extension.options, storage: extension.storage, }; const group = callOrReturn(getExtensionField(extension, 'group', context)); if (typeof group !== 'string') { return false; } return group.split(' ').includes('list'); } /** * Returns true if the given prosemirror node is empty. */ function isNodeEmpty(node, { checkChildren = true, ignoreWhitespace = false, } = {}) { var _a; if (ignoreWhitespace) { if (node.type.name === 'hardBreak') { // Hard breaks are considered empty return true; } if (node.isText) { return /^\s*$/m.test((_a = node.text) !== null && _a !== void 0 ? _a : ''); } } if (node.isText) { return !node.text; } if (node.isAtom || node.isLeaf) { return false; } if (node.content.childCount === 0) { return true; } if (checkChildren) { let isContentEmpty = true; node.content.forEach(childNode => { if (isContentEmpty === false) { // Exit early for perf return; } if (!isNodeEmpty(childNode, { ignoreWhitespace, checkChildren })) { isContentEmpty = false; } }); return isContentEmpty; } return false; } function isNodeSelection(value) { return value instanceof state.NodeSelection; } function posToDOMRect(view, from, to) { const minPos = 0; const maxPos = view.state.doc.content.size; const resolvedFrom = minMax(from, minPos, maxPos); const resolvedEnd = minMax(to, minPos, maxPos); const start = view.coordsAtPos(resolvedFrom); const end = view.coordsAtPos(resolvedEnd, -1); const top = Math.min(start.top, end.top); const bottom = Math.max(start.bottom, end.bottom); const left = Math.min(start.left, end.left); const right = Math.max(start.right, end.right); const width = right - left; const height = bottom - top; const x = left; const y = top; const data = { top, bottom, left, right, width, height, x, y, }; return { ...data, toJSON: () => data, }; } function canSetMark(state, tr, newMarkType) { var _a; const { selection } = tr; let cursor = null; if (isTextSelection(selection)) { cursor = selection.$cursor; } if (cursor) { const currentMarks = (_a = state.storedMarks) !== null && _a !== void 0 ? _a : cursor.marks(); // There can be no current marks that exclude the new mark return (!!newMarkType.isInSet(currentMarks) || !currentMarks.some(mark => mark.type.excludes(newMarkType))); } const { ranges } = selection; return ranges.some(({ $from, $to }) => { let someNodeSupportsMark = $from.depth === 0 ? state.doc.inlineContent && state.doc.type.allowsMarkType(newMarkType) : false; state.doc.nodesBetween($from.pos, $to.pos, (node, _pos, parent) => { // If we already found a mark that we can enable, return false to bypass the remaining search if (someNodeSupportsMark) { return false; } if (node.isInline) { const parentAllowsMarkType = !parent || parent.type.allowsMarkType(newMarkType); const currentMarksAllowMarkType = !!newMarkType.isInSet(node.marks) || !node.marks.some(otherMark => otherMark.type.excludes(newMarkType)); someNodeSupportsMark = parentAllowsMarkType && currentMarksAllowMarkType; } return !someNodeSupportsMark; }); return someNodeSupportsMark; }); } const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => { const { selection } = tr; const { empty, ranges } = selection; const type = getMarkType(typeOrName, state.schema); if (dispatch) { if (empty) { const oldAttributes = getMarkAttributes(state, type); tr.addStoredMark(type.create({ ...oldAttributes, ...attributes, })); } else { ranges.forEach(range => { const from = range.$from.pos; const to = range.$to.pos; state.doc.nodesBetween(from, to, (node, pos) => { const trimmedFrom = Math.max(pos, from); const trimmedTo = Math.min(pos + node.nodeSize, to); const someHasMark = node.marks.find(mark => mark.type === type); // if there is already a mark of this type // we know that we have to merge its attributes // otherwise we add a fresh new mark if (someHasMark) { node.marks.forEach(mark => { if (type === mark.type) { tr.addMark(trimmedFrom, trimmedTo, type.create({ ...mark.attrs, ...attributes, })); } }); } else { tr.addMark(trimmedFrom, trimmedTo, type.create(attributes)); } }); }); } } return canSetMark(state, tr, type); }; const setMeta = (key, value) => ({ tr }) => { tr.setMeta(key, value); return true; }; const setNode = (typeOrName, attributes = {}) => ({ state, dispatch, chain }) => { const type = getNodeType(typeOrName, state.schema); let attributesToCopy; if (state.selection.$anchor.sameParent(state.selection.$head)) { // only copy attributes if the selection is pointing to a node of the same type attributesToCopy = state.selection.$anchor.parent.attrs; } // TODO: use a fallback like insertContent? if (!type.isTextblock) { console.warn('[tiptap warn]: Currently "setNode()" only supports text block nodes.'); return false; } return (chain() // try to convert node to default node if needed .command(({ commands }) => { const canSetBlock = commands$1.setBlockType(type, { ...attributesToCopy, ...attributes })(state); if (canSetBlock) { return true; } return commands.clearNodes(); }) .command(({ state: updatedState }) => { return commands$1.setBlockType(type, { ...attributesToCopy, ...attributes })(updatedState, dispatch); }) .run()); }; const setNodeSelection = position => ({ tr, dispatch }) => { if (dispatch) { const { doc } = tr; const from = minMax(position, 0, doc.content.size); const selection = state.NodeSelection.create(doc, from); tr.setSelection(selection); } return true; }; const setTextSelection = position => ({ tr, dispatch }) => { if (dispatch) { const { doc } = tr; const { from, to } = typeof position === 'number' ? { from: position, to: position } : position; const minPos = state.TextSelection.atStart(doc).from; const maxPos = state.TextSelection.atEnd(doc).to; const resolvedFrom = minMax(from, minPos, maxPos); const resolvedEnd = minMax(to, minPos, maxPos); const selection = state.TextSelection.create(doc, resolvedFrom, resolvedEnd); tr.setSelection(selection); } return true; }; const sinkListItem = typeOrName => ({ state, dispatch }) => { const type = getNodeType(typeOrName, state.schema); return schemaList.sinkListItem(type)(state, dispatch); }; function ensureMarks(state, splittableMarks) { const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); if (marks) { const filteredMarks = marks.filter(mark => splittableMarks === null || splittableMarks === void 0 ? void 0 : splittableMarks.includes(mark.type.name)); state.tr.ensureMarks(filteredMarks); } } const splitBlock = ({ keepMarks = true } = {}) => ({ tr, state: state$1, dispatch, editor, }) => { const { selection, doc } = tr; const { $from, $to } = selection; const extensionAttributes = editor.extensionManager.attributes; const newAttributes = getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs); if (selection instanceof state.NodeSelection && selection.node.isBlock) { if (!$from.parentOffset || !transform.canSplit(doc, $from.pos)) { return false; } if (dispatch) { if (keepMarks) { ensureMarks(state$1, editor.extensionManager.splittableMarks); } tr.split($from.pos).scrollIntoView(); } return true; } if (!$from.parent.isBlock) { return false; } const atEnd = $to.parentOffset === $to.parent.content.size; const deflt = $from.depth === 0 ? undefined : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1))); let types = atEnd && deflt ? [ { type: deflt, attrs: newAttributes, }, ] : undefined; let can = transform.canSplit(tr.doc, tr.mapping.map($from.pos), 1, types); if (!types && !can && transform.canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt ? [{ type: deflt }] : undefined)) { can = true; types = deflt ? [ { type: deflt, attrs: newAttributes, }, ] : undefined; } if (dispatch) { if (can) { if (selection instanceof state.TextSelection) { tr.deleteSelection(); } tr.split(tr.mapping.map($from.pos), 1, types); if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) { const first = tr.mapping.map($from.before()); const $first = tr.doc.resolve(first); if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) { tr.setNodeMarkup(tr.mapping.map($from.before()), deflt); } } } if (keepMarks) { ensureMarks(state$1, editor.extensionManager.splittableMarks); } tr.scrollIntoView(); } return can; }; const splitListItem = (typeOrName, overrideAttrs = {}) => ({ tr, state: state$1, dispatch, editor, }) => { var _a; const type = getNodeType(typeOrName, state$1.schema); const { $from, $to } = state$1.selection; // @ts-ignore // eslint-disable-next-line const node = state$1.selection.node; if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false; } const grandParent = $from.node(-1); if (grandParent.type !== type) { return false; } const extensionAttributes = editor.extensionManager.attributes; if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) { // In an empty block. If this is a nested list, the wrapping // list item should be split. Otherwise, bail out and let next // command handle lifting. if ($from.depth === 2 || $from.node(-3).type !== type || $from.index(-2) !== $from.node(-2).childCount - 1) { return false; } if (dispatch) { let wrap = model.Fragment.empty; // eslint-disable-next-line const depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3; // Build a fragment containing empty versions of the structure // from the outer list item to the parent node of the cursor for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d -= 1) { wrap = model.Fragment.from($from.node(d).copy(wrap)); } // eslint-disable-next-line const depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1 : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3; // Add a second list item with an empty default start node const newNextTypeAttributes = { ...getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs), ...overrideAttrs, }; const nextType = ((_a = type.contentMatch.defaultType) === null || _a === void 0 ? void 0 : _a.createAndFill(newNextTypeAttributes)) || undefined; wrap = wrap.append(model.Fragment.from(type.createAndFill(null, nextType) || undefined)); const start = $from.before($from.depth - (depthBefore - 1)); tr.replace(start, $from.after(-depthAfter), new model.Slice(wrap, 4 - depthBefore, 0)); let sel = -1; tr.doc.nodesBetween(start, tr.doc.content.size, (n, pos) => { if (sel > -1) { return false; } if (n.isTextblock && n.content.size === 0) { sel = pos + 1; } }); if (sel > -1) { tr.setSelection(state.TextSelection.near(tr.doc.resolve(sel))); } tr.scrollIntoView(); } return true; } const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null; const newTypeAttributes = { ...getSplittedAttributes(extensionAttributes, grandParent.type.name, grandParent.attrs), ...overrideAttrs, }; const newNextTypeAttributes = { ...getSplittedAttributes(extensionAttributes, $from.node().type.name, $from.node().attrs), ...overrideAttrs, }; tr.delete($from.pos, $to.pos); const types = nextType ? [ { type, attrs: newTypeAttributes }, { type: nextType, attrs: newNextTypeAttributes }, ] : [{ type, attrs: newTypeAttributes }]; if (!transform.canSplit(tr.doc, $from.pos, 2)) { return false; } if (dispatch) { const { selection, storedMarks } = state$1; const { splittableMarks } = editor.extensionManager; const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks()); tr.split($from.pos, 2, types).scrollIntoView(); if (!marks || !dispatch) { return true; } const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name)); tr.ensureMarks(filteredMarks); } return true; }; const joinListBackwards = (tr, listType) => { const list = findParentNode(node => node.type === listType)(tr.selection); if (!list) { return true; } const before = tr.doc.resolve(Math.max(0, list.pos - 1)).before(list.depth); if (before === undefined) { return true; } const nodeBefore = tr.doc.nodeAt(before); const canJoinBackwards = list.node.type === (nodeBefore === null || nodeBefore === void 0 ? void 0 : nodeBefore.type) && transform.canJoin(tr.doc, list.pos); if (!canJoinBackwards) { return true; } tr.join(list.pos); return true; }; const joinListForwards = (tr, listType) => { const list = findParentNode(node => node.type === listType)(tr.selection); if (!list) { return true; } const after = tr.doc.resolve(list.start).after(list.depth); if (after === undefined) { return true; } const nodeAfter = tr.doc.nodeAt(after); const canJoinForwards = list.node.type === (nodeAfter === null || nodeAfter === void 0 ? void 0 : nodeAfter.type) && transform.canJoin(tr.doc, after); if (!canJoinForwards) { return true; } tr.join(after); return true; }; const toggleList = (listTypeOrName, itemTypeOrName, keepMarks, attributes = {}) => ({ editor, tr, state, dispatch, chain, commands, can, }) => { const { extensions, splittableMarks } = editor.extensionManager; const listType = getNodeType(listTypeOrName, state.schema); const itemType = getNodeType(itemTypeOrName, state.schema); const { selection, storedMarks } = state; const { $from, $to } = selection; const range = $from.blockRange($to); const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks()); if (!range) { return false; } const parentList = findParentNode(node => isList(node.type.name, extensions))(selection); if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) { // remove list if (parentList.node.type === listType) { return commands.liftListItem(itemType); } // change list type if (isList(parentList.node.type.name, extensions) && listType.validContent(parentList.node.content) && dispatch) { return chain() .command(() => { tr.setNodeMarkup(parentList.pos, listType); return true; }) .command(() => joinListBackwards(tr, listType)) .command(() => joinListForwards(tr, listType)) .run(); } } if (!keepMarks || !marks || !dispatch) { return chain() // try to convert node to default node if needed .command(() => { const canWrapInList = can().wrapInList(listType, attributes); if (canWrapInList) { return true; } return commands.clearNodes(); }) .wrapInList(listType, attributes) .command(() => joinListBackwards(tr, listType)) .command(() => joinListForwards(tr, listType)) .run(); } return (chain() // try to convert node to default node if needed .command(() => { const canWrapInList = can().wrapInList(listType, attributes); const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name)); tr.ensureMarks(filteredMarks); if (canWrapInList) { return true; } return commands.clearNodes(); }) .wrapInList(listType, attributes) .command(() => joinListBackwards(tr, listType)) .command(() => joinListForwards(tr, listType)) .run()); }; const toggleMark = (typeOrName, attributes = {}, options = {}) => ({ state, commands }) => { const { extendEmptyMarkRange = false } = options; const type = getMarkType(typeOrName, state.schema); const isActive = isMarkActive(state, type, attributes); if (isActive) { return commands.unsetMark(type, { extendEmptyMarkRange }); } return commands.setMark(type, attributes); }; const toggleNode = (typeOrName, toggleTypeOrName, attributes = {}) => ({ state, commands }) => { const type = getNodeType(typeOrName, state.schema); const toggleType = getNodeType(toggleTypeOrName, state.schema); const isActive = isNodeActive(state, type, attributes); let attributesToCopy; if (state.selection.$anchor.sameParent(state.selection.$head)) { // only copy attributes if the selection is pointing to a node of the same type attributesToCopy = state.selection.$anchor.parent.attrs; } if (isActive) { return commands.setNode(toggleType, attributesToCopy); } // If the node is not active, we want to set the new node type with the given attributes // Copying over the attributes from the current node if the selection is pointing to a node of the same type return commands.setNode(type, { ...attributesToCopy, ...attributes }); }; const toggleWrap = (typeOrName, attributes = {}) => ({ state, commands }) => { const type = getNodeType(typeOrName, state.schema); const isActive = isNodeActive(state, type, attributes); if (isActive) { return commands.lift(type); } return commands.wrapIn(type, attributes); }; const undoInputRule = () => ({ state, dispatch }) => { const plugins = state.plugins; for (let i = 0; i < plugins.length; i += 1) { const plugin = plugins[i]; let undoable; // @ts-ignore // eslint-disable-next-line if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) { if (dispatch) { const tr = state.tr; const toUndo = undoable.transform; for (let j = toUndo.steps.length - 1; j >= 0; j -= 1) { tr.step(toUndo.steps[j].invert(toUndo.docs[j])); } if (undoable.text) { const marks = tr.doc.resolve(undoable.from).marks(); tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks)); } else { tr.delete(undoable.from, undoable.to); } } return true; } } return false; }; const unsetAllMarks = () => ({ tr, dispatch }) => { const { selection } = tr; const { empty, ranges } = selection; if (empty) { return true; } if (dispatch) { ranges.forEach(range => { tr.removeMark(range.$from.pos, range.$to.pos); }); } return true; }; const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch }) => { var _a; const { extendEmptyMarkRange = false } = options; const { selection } = tr; const type = getMarkType(typeOrName, state.schema); const { $from, empty, ranges } = selection; if (!dispatch) { return true; } if (empty && extendEmptyMarkRange) { let { from, to } = selection; const attrs = (_a = $from.marks().find(mark => mark.type === type)) === null || _a === void 0 ? void 0 : _a.attrs; const range = getMarkRange($from, type, attrs); if (range) { from = range.from; to = range.to; } tr.removeMark(from, to, type); } else { ranges.forEach(range => { tr.removeMark(range.$from.pos, range.$to.pos, type); }); } tr.removeStoredMark(type); return true; }; const updateAttributes = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => { let nodeType = null; let markType = null; const schemaType = getSchemaTypeNameByName(typeof typeOrName === 'string' ? typeOrName : typeOrName.name, state.schema); if (!schemaType) { return false; } if (schemaType === 'node') { nodeType = getNodeType(typeOrName, state.schema); } if (schemaType === 'mark') { markType = getMarkType(typeOrName, state.schema); } if (dispatch) { tr.selection.ranges.forEach((range) => { const from = range.$from.pos; const to = range.$to.pos; let lastPos; let lastNode; let trimmedFrom; let trimmedTo; if (tr.selection.empty) { state.doc.nodesBetween(from, to, (node, pos) => { if (nodeType && nodeType === node.type) { trimmedFrom = Math.max(pos, from); trimmedTo = Math.min(pos + node.nodeSize, to); lastPos = pos; lastNode = node; } }); } else { state.doc.nodesBetween(from, to, (node, pos) => { if (pos < from && nodeType && nodeType === node.type) { trimmedFrom = Math.max(pos, from); trimmedTo = Math.min(pos + node.nodeSize, to); lastPos = pos; lastNode = node; } if (pos >= from && pos <= to) { if (nodeType && nodeType === node.type) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attributes, }); } if (markType && node.marks.length) { node.marks.forEach((mark) => { if (markType === mark.type) { const trimmedFrom2 = Math.max(pos, from); const trimmedTo2 = Math.min(pos + node.nodeSize, to); tr.addMark(trimmedFrom2, trimmedTo2, markType.create({ ...mark.attrs, ...attributes, })); } }); } } }); } if (lastNode) { if (lastPos !== undefined) { tr.setNodeMarkup(lastPos, undefined, { ...lastNode.attrs, ...attributes, }); } if (markType && lastNode.marks.length) { lastNode.marks.forEach((mark) => { if (markType === mark.type) { tr.addMark(trimmedFrom, trimmedTo, markType.create({ ...mark.attrs, ...attributes, })); } }); } } }); } return true; }; const wrapIn = (typeOrName, attributes = {}) => ({ state, dispatch }) => { const type = getNodeType(typeOrName, state.schema); return commands$1.wrapIn(type, attributes)(state, dispatch); }; const wrapInList = (typeOrName, attributes = {}) => ({ state, dispatch }) => { const type = getNodeType(typeOrName, state.schema); return schemaList.wrapInList(type, attributes)(state, dispatch); }; var commands = /*#__PURE__*/Object.freeze({ __proto__: null, blur: blur, clearContent: clearContent, clearNodes: clearNodes, command: command, createParagraphNear: createParagraphNear, cut: cut, deleteCurrentNode: deleteCurrentNode, deleteNode: deleteNode, deleteRange: deleteRange, deleteSelection: deleteSelection, enter: enter, exitCode: exitCode, extendMarkRange: extendMarkRange, first: first, focus: focus, forEach: forEach, insertContent: insertContent, insertContentAt: insertContentAt, joinBackward: joinBackward, joinDown: joinDown, joinForward: joinForward, joinItemBackward: joinItemBackward, joinItemForward: joinItemForward, joinTextblockBackward: joinTextblockBackward, joinTextblockForward: joinTextblockForward, joinUp: joinUp, keyboardShortcut: keyboardShortcut, lift: lift, liftEmptyBlock: liftEmptyBlock, liftListItem: liftListItem, newlineInCode: newlineInCode, resetAttributes: resetAttributes, scrollIntoView: scrollIntoView, selectAll: selectAll, selectNodeBackward: selectNodeBackward, selectNodeForward: selectNodeForward, selectParentNode: selectParentNode, selectTextblockEnd: selectTextblockEnd, selectTextblockStart: selectTextblockStart, setContent: setContent, setMark: setMark, setMeta: setMeta, setNode: setNode, setNodeSelection: setNodeSelection, setTextSelection: setTextSelection, sinkListItem: sinkListItem, splitBlock: splitBlock, splitListItem: splitListItem, toggleList: toggleList, toggleMark: toggleMark, toggleNode: toggleNode, toggleWrap: toggleWrap, undoInputRule: undoInputRule, unsetAllMarks: unsetAllMarks, unsetMark: unsetMark, updateAttributes: updateAttributes, wrapIn: wrapIn, wrapInList: wrapInList }); const Commands = Extension.create({ name: 'commands', addCommands() { return { ...commands, }; }, }); const Drop = Extension.create({ name: 'drop', addProseMirrorPlugins() { return [ new state.Plugin({ key: new state.PluginKey('tiptapDrop'), props: { handleDrop: (_, e, slice, moved) => { this.editor.emit('drop', { editor: this.editor, event: e, slice, moved, }); }, }, }), ]; }, }); const Editable = Extension.create({ name: 'editable', addProseMirrorPlugins() { return [ new state.Plugin({ key: new state.PluginKey('editable'), props: { editable: () => this.editor.options.editable, }, }), ]; }, }); const FocusEvents = Extension.create({ name: 'focusEvents', addProseMirrorPlugins() { const { editor } = this; return [ new state.Plugin({ key: new state.PluginKey('focusEvents'), props: { handleDOMEvents: { focus: (view, event) => { editor.isFocused = true; const transaction = editor.state.tr .setMeta('focus', { event }) .setMeta('addToHistory', false); view.dispatch(transaction); return false; }, blur: (view, event) => { editor.isFocused = false; const transaction = editor.state.tr .setMeta('blur', { event }) .setMeta('addToHistory', false); view.dispatch(transaction); return false; }, }, }, }), ]; }, }); const Keymap = Extension.create({ name: 'keymap', addKeyboardShortcuts() { const handleBackspace = () => this.editor.commands.first(({ commands }) => [ () => commands.undoInputRule(), // maybe convert first text block node to default node () => commands.command(({ tr }) => { const { selection, doc } = tr; const { empty, $anchor } = selection; const { pos, parent } = $anchor; const $parentPos = $anchor.parent.isTextblock && pos > 0 ? tr.doc.resolve(pos - 1) : $anchor; const parentIsIsolating = $parentPos.parent.type.spec.isolating; const parentPos = $anchor.pos - $anchor.parentOffset; const isAtStart = (parentIsIsolating && $parentPos.parent.childCount === 1) ? parentPos === $anchor.pos : state.Selection.atStart(doc).from === pos; if (!empty || !parent.type.isTextblock || parent.textContent.length || !isAtStart || (isAtStart && $anchor.parent.type.name === 'paragraph') // prevent clearNodes when no nodes to clear, otherwise history stack is appended ) { return false; } return commands.clearNodes(); }), () => commands.deleteSelection(), () => commands.joinBackward(), () => commands.selectNodeBackward(), ]); const handleDelete = () => this.editor.commands.first(({ commands }) => [ () => commands.deleteSelection(), () => commands.deleteCurrentNode(), () => commands.joinForward(), () => commands.selectNodeForward(), ]); const handleEnter = () => this.editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), () => commands.createParagraphNear(), () => commands.liftEmptyBlock(), () => commands.splitBlock(), ]); const baseKeymap = { Enter: handleEnter, 'Mod-Enter': () => this.editor.commands.exitCode(), Backspace: handleBackspace, 'Mod-Backspace': handleBackspace, 'Shift-Backspace': handleBackspace, Delete: handleDelete, 'Mod-Delete': handleDelete, 'Mod-a': () => this.editor.commands.selectAll(), }; const pcKeymap = { ...baseKeymap, }; const macKeymap = { ...baseKeymap, 'Ctrl-h': handleBackspace, 'Alt-Backspace': handleBackspace, 'Ctrl-d': handleDelete, 'Ctrl-Alt-Backspace': handleDelete, 'Alt-Delete': handleDelete, 'Alt-d': handleDelete, 'Ctrl-a': () => this.editor.commands.selectTextblockStart(), 'Ctrl-e': () => this.editor.commands.selectTextblockEnd(), }; if (isiOS() || isMacOS()) { return macKeymap; } return pcKeymap; }, addProseMirrorPlugins() { return [ // With this plugin we check if the whole document was selected and deleted. // In this case we will additionally call `clearNodes()` to convert e.g. a heading // to a paragraph if necessary. // This is an alternative to ProseMirror's `AllSelection`, which doesn’t work well // with many other commands. new state.Plugin({ key: new state.PluginKey('clearDocument'), appendTransaction: (transactions, oldState, newState) => { const docChanges = transactions.some(transaction => transaction.docChanged) && !oldState.doc.eq(newState.doc); const ignoreTr = transactions.some(transaction => transaction.getMeta('preventClearDocument')); if (!docChanges || ignoreTr) { return; } const { empty, from, to } = oldState.selection; const allFrom = state.Selection.atStart(oldState.doc).from; const allEnd = state.Selection.atEnd(oldState.doc).to; const allWasSelected = from === allFrom && to === allEnd; if (empty || !allWasSelected) { return; } const isEmpty = isNodeEmpty(newState.doc); if (!isEmpty) { return; } const tr = newState.tr; const state$1 = createChainableState({ state: newState, transaction: tr, }); const { commands } = new CommandManager({ editor: this.editor, state: state$1, }); commands.clearNodes(); if (!tr.steps.length) { return; } return tr; }, }), ]; }, }); const Paste = Extension.create({ name: 'paste', addProseMirrorPlugins() { return [ new state.Plugin({ key: new state.PluginKey('tiptapPaste'), props: { handlePaste: (_view, e, slice) => { this.editor.emit('paste', { editor: this.editor, event: e, slice, }); }, }, }), ]; }, }); const Tabindex = Extension.create({ name: 'tabindex', addProseMirrorPlugins() { return [ new state.Plugin({ key: new state.PluginKey('tabindex'), props: { attributes: () => (this.editor.isEditable ? { tabindex: '0' } : {}), }, }), ]; }, }); var index = /*#__PURE__*/Object.freeze({ __proto__: null, ClipboardTextSerializer: ClipboardTextSerializer, Commands: Commands, Drop: Drop, Editable: Editable, FocusEvents: FocusEvents, Keymap: Keymap, Paste: Paste, Tabindex: Tabindex }); class NodePos { get name() { return this.node.type.name; } constructor(pos, editor, isBlock = false, node = null) { this.currentNode = null; this.actualDepth = null; this.isBlock = isBlock; this.resolvedPos = pos; this.editor = editor; this.currentNode = node; } get node() { return this.currentNode || this.resolvedPos.node(); } get element() { return this.editor.view.domAtPos(this.pos).node; } get depth() { var _a; return (_a = this.actualDepth) !== null && _a !== void 0 ? _a : this.resolvedPos.depth; } get pos() { return this.resolvedPos.pos; } get content() { return this.node.content; } set content(content) { let from = this.from; let to = this.to; if (this.isBlock) { if (this.content.size === 0) { console.error(`You can’t set content on a block node. Tried to set content on ${this.name} at ${this.pos}`); return; } from = this.from + 1; to = this.to - 1; } this.editor.commands.insertContentAt({ from, to }, content); } get attributes() { return this.node.attrs; } get textContent() { return this.node.textContent; } get size() { return this.node.nodeSize; } get from() { if (this.isBlock) { return this.pos; } return this.resolvedPos.start(this.resolvedPos.depth); } get range() { return { from: this.from, to: this.to, }; } get to() { if (this.isBlock) { return this.pos + this.size; } return this.resolvedPos.end(this.resolvedPos.depth) + (this.node.isText ? 0 : 1); } get parent() { if (this.depth === 0) { return null; } const parentPos = this.resolvedPos.start(this.resolvedPos.depth - 1); const $pos = this.resolvedPos.doc.resolve(parentPos); return new NodePos($pos, this.editor); } get before() { let $pos = this.resolvedPos.doc.resolve(this.from - (this.isBlock ? 1 : 2)); if ($pos.depth !== this.depth) { $pos = this.resolvedPos.doc.resolve(this.from - 3); } return new NodePos($pos, this.editor); } get after() { let $pos = this.resolvedPos.doc.resolve(this.to + (this.isBlock ? 2 : 1)); if ($pos.depth !== this.depth) { $pos = this.resolvedPos.doc.resolve(this.to + 3); } return new NodePos($pos, this.editor); } get children() { const children = []; this.node.content.forEach((node, offset) => { const isBlock = node.isBlock && !node.isTextblock; const isNonTextAtom = node.isAtom && !node.isText; const targetPos = this.pos + offset + (isNonTextAtom ? 0 : 1); const $pos = this.resolvedPos.doc.resolve(targetPos); if (!isBlock && $pos.depth <= this.depth) { return; } const childNodePos = new NodePos($pos, this.editor, isBlock, isBlock ? node : null); if (isBlock) { childNodePos.actualDepth = this.depth + 1; } children.push(new NodePos($pos, this.editor, isBlock, isBlock ? node : null)); }); return children; } get firstChild() { return this.children[0] || null; } get lastChild() { const children = this.children; return children[children.length - 1] || null; } closest(selector, attributes = {}) { let node = null; let currentNode = this.parent; while (currentNode && !node) { if (currentNode.node.type.name === selector) { if (Object.keys(attributes).length > 0) { const nodeAttributes = currentNode.node.attrs; const attrKeys = Object.keys(attributes); for (let index = 0; index < attrKeys.length; index += 1) { const key = attrKeys[index]; if (nodeAttributes[key] !== attributes[key]) { break; } } } else { node = currentNode; } } currentNode = currentNode.parent; } return node; } querySelector(selector, attributes = {}) { return this.querySelectorAll(selector, attributes, true)[0] || null; } querySelectorAll(selector, attributes = {}, firstItemOnly = false) { let nodes = []; if (!this.children || this.children.length === 0) { return nodes; } const attrKeys = Object.keys(attributes); /** * Finds all children recursively that match the selector and attributes * If firstItemOnly is true, it will return the first item found */ this.children.forEach(childPos => { // If we already found a node and we only want the first item, we dont need to keep going if (firstItemOnly && nodes.length > 0) { return; } if (childPos.node.type.name === selector) { const doesAllAttributesMatch = attrKeys.every(key => attributes[key] === childPos.node.attrs[key]); if (doesAllAttributesMatch) { nodes.push(childPos); } } // If we already found a node and we only want the first item, we can stop here and skip the recursion if (firstItemOnly && nodes.length > 0) { return; } nodes = nodes.concat(childPos.querySelectorAll(selector, attributes, firstItemOnly)); }); return nodes; } setAttribute(attributes) { const { tr } = this.editor.state; tr.setNodeMarkup(this.from, undefined, { ...this.node.attrs, ...attributes, }); this.editor.view.dispatch(tr); } } const style = `.ProseMirror { position: relative; } .ProseMirror { word-wrap: break-word; white-space: pre-wrap; white-space: break-spaces; -webkit-font-variant-ligatures: none; font-variant-ligatures: none; font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ } .ProseMirror [contenteditable="false"] { white-space: normal; } .ProseMirror [contenteditable="false"] [contenteditable="true"] { white-space: pre-wrap; } .ProseMirror pre { white-space: pre-wrap; } img.ProseMirror-separator { display: inline !important; border: none !important; margin: 0 !important; width: 0 !important; height: 0 !important; } .ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; margin: 0; } .ProseMirror-gapcursor:after { content: ""; display: block; position: absolute; top: -2px; width: 20px; border-top: 1px solid black; animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; } @keyframes ProseMirror-cursor-blink { to { visibility: hidden; } } .ProseMirror-hideselection *::selection { background: transparent; } .ProseMirror-hideselection *::-moz-selection { background: transparent; } .ProseMirror-hideselection * { caret-color: transparent; } .ProseMirror-focused .ProseMirror-gapcursor { display: block; } .tippy-box[data-animation=fade][data-state=hidden] { opacity: 0 }`; function createStyleTag(style, nonce, suffix) { const tiptapStyleTag = document.querySelector(`style[data-tiptap-style${suffix ? `-${suffix}` : ''}]`); if (tiptapStyleTag !== null) { return tiptapStyleTag; } const styleNode = document.createElement('style'); if (nonce) { styleNode.setAttribute('nonce', nonce); } styleNode.setAttribute(`data-tiptap-style${suffix ? `-${suffix}` : ''}`, ''); styleNode.innerHTML = style; document.getElementsByTagName('head')[0].appendChild(styleNode); return styleNode; } class Editor extends EventEmitter { constructor(options = {}) { super(); this.isFocused = false; /** * The editor is considered initialized after the `create` event has been emitted. */ this.isInitialized = false; this.extensionStorage = {}; this.options = { element: document.createElement('div'), content: '', injectCSS: true, injectNonce: undefined, extensions: [], autofocus: false, editable: true, editorProps: {}, parseOptions: {}, coreExtensionOptions: {}, enableInputRules: true, enablePasteRules: true, enableCoreExtensions: true, enableContentCheck: false, onBeforeCreate: () => null, onCreate: () => null, onUpdate: () => null, onSelectionUpdate: () => null, onTransaction: () => null, onFocus: () => null, onBlur: () => null, onDestroy: () => null, onContentError: ({ error }) => { throw error; }, onPaste: () => null, onDrop: () => null, }; this.isCapturingTransaction = false; this.capturedTransaction = null; this.setOptions(options); this.createExtensionManager(); this.createCommandManager(); this.createSchema(); this.on('beforeCreate', this.options.onBeforeCreate); this.emit('beforeCreate', { editor: this }); this.on('contentError', this.options.onContentError); this.createView(); this.injectCSS(); this.on('create', this.options.onCreate); this.on('update', this.options.onUpdate); this.on('selectionUpdate', this.options.onSelectionUpdate); this.on('transaction', this.options.onTransaction); this.on('focus', this.options.onFocus); this.on('blur', this.options.onBlur); this.on('destroy', this.options.onDestroy); this.on('drop', ({ event, slice, moved }) => this.options.onDrop(event, slice, moved)); this.on('paste', ({ event, slice }) => this.options.onPaste(event, slice)); window.setTimeout(() => { if (this.isDestroyed) { return; } this.commands.focus(this.options.autofocus); this.emit('create', { editor: this }); this.isInitialized = true; }, 0); } /** * Returns the editor storage. */ get storage() { return this.extensionStorage; } /** * An object of all registered commands. */ get commands() { return this.commandManager.commands; } /** * Create a command chain to call multiple commands at once. */ chain() { return this.commandManager.chain(); } /** * Check if a command or a command chain can be executed. Without executing it. */ can() { return this.commandManager.can(); } /** * Inject CSS styles. */ injectCSS() { if (this.options.injectCSS && document) { this.css = createStyleTag(style, this.options.injectNonce); } } /** * Update editor options. * * @param options A list of options */ setOptions(options = {}) { this.options = { ...this.options, ...options, }; if (!this.view || !this.state || this.isDestroyed) { return; } if (this.options.editorProps) { this.view.setProps(this.options.editorProps); } this.view.updateState(this.state); } /** * Update editable state of the editor. */ setEditable(editable, emitUpdate = true) { this.setOptions({ editable }); if (emitUpdate) { this.emit('update', { editor: this, transaction: this.state.tr }); } } /** * Returns whether the editor is editable. */ get isEditable() { // since plugins are applied after creating the view // `editable` is always `true` for one tick. // that’s why we also have to check for `options.editable` return this.options.editable && this.view && this.view.editable; } /** * Returns the editor state. */ get state() { return this.view.state; } /** * Register a ProseMirror plugin. * * @param plugin A ProseMirror plugin * @param handlePlugins Control how to merge the plugin into the existing plugins. * @returns The new editor state */ registerPlugin(plugin, handlePlugins) { const plugins = isFunction(handlePlugins) ? handlePlugins(plugin, [...this.state.plugins]) : [...this.state.plugins, plugin]; const state = this.state.reconfigure({ plugins }); this.view.updateState(state); return state; } /** * Unregister a ProseMirror plugin. * * @param nameOrPluginKeyToRemove The plugins name * @returns The new editor state or undefined if the editor is destroyed */ unregisterPlugin(nameOrPluginKeyToRemove) { if (this.isDestroyed) { return undefined; } const prevPlugins = this.state.plugins; let plugins = prevPlugins; [].concat(nameOrPluginKeyToRemove).forEach(nameOrPluginKey => { // @ts-ignore const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key; // @ts-ignore plugins = prevPlugins.filter(plugin => !plugin.key.startsWith(name)); }); if (prevPlugins.length === plugins.length) { // No plugin was removed, so we don’t need to update the state return undefined; } const state = this.state.reconfigure({ plugins, }); this.view.updateState(state); return state; } /** * Creates an extension manager. */ createExtensionManager() { var _a, _b; const coreExtensions = this.options.enableCoreExtensions ? [ Editable, ClipboardTextSerializer.configure({ blockSeparator: (_b = (_a = this.options.coreExtensionOptions) === null || _a === void 0 ? void 0 : _a.clipboardTextSerializer) === null || _b === void 0 ? void 0 : _b.blockSeparator, }), Commands, FocusEvents, Keymap, Tabindex, Drop, Paste, ].filter(ext => { if (typeof this.options.enableCoreExtensions === 'object') { return this.options.enableCoreExtensions[ext.name] !== false; } return true; }) : []; const allExtensions = [...coreExtensions, ...this.options.extensions].filter(extension => { return ['extension', 'node', 'mark'].includes(extension === null || extension === void 0 ? void 0 : extension.type); }); this.extensionManager = new ExtensionManager(allExtensions, this); } /** * Creates an command manager. */ createCommandManager() { this.commandManager = new CommandManager({ editor: this, }); } /** * Creates a ProseMirror schema. */ createSchema() { this.schema = this.extensionManager.schema; } /** * Creates a ProseMirror view. */ createView() { var _a; let doc; try { doc = createDocument(this.options.content, this.schema, this.options.parseOptions, { errorOnInvalidContent: this.options.enableContentCheck }); } catch (e) { if (!(e instanceof Error) || !['[tiptap error]: Invalid JSON content', '[tiptap error]: Invalid HTML content'].includes(e.message)) { // Not the content error we were expecting throw e; } this.emit('contentError', { editor: this, error: e, disableCollaboration: () => { if (this.storage.collaboration) { this.storage.collaboration.isDisabled = true; } // To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration'); // Restart the initialization process by recreating the extension manager with the new set of extensions this.createExtensionManager(); }, }); // Content is invalid, but attempt to create it anyway, stripping out the invalid parts doc = createDocument(this.options.content, this.schema, this.options.parseOptions, { errorOnInvalidContent: false }); } const selection = resolveFocusPosition(doc, this.options.autofocus); this.view = new view.EditorView(this.options.element, { ...this.options.editorProps, attributes: { // add `role="textbox"` to the editor element role: 'textbox', ...(_a = this.options.editorProps) === null || _a === void 0 ? void 0 : _a.attributes, }, dispatchTransaction: this.dispatchTransaction.bind(this), state: state.EditorState.create({ doc, selection: selection || undefined, }), }); // `editor.view` is not yet available at this time. // Therefore we will add all plugins and node views directly afterwards. const newState = this.state.reconfigure({ plugins: this.extensionManager.plugins, }); this.view.updateState(newState); this.createNodeViews(); this.prependClass(); // Let’s store the editor instance in the DOM element. // So we’ll have access to it for tests. // @ts-ignore const dom = this.view.dom; dom.editor = this; } /** * Creates all node views. */ createNodeViews() { if (this.view.isDestroyed) { return; } this.view.setProps({ nodeViews: this.extensionManager.nodeViews, }); } /** * Prepend class name to element. */ prependClass() { this.view.dom.className = `tiptap ${this.view.dom.className}`; } captureTransaction(fn) { this.isCapturingTransaction = true; fn(); this.isCapturingTransaction = false; const tr = this.capturedTransaction; this.capturedTransaction = null; return tr; } /** * The callback over which to send transactions (state updates) produced by the view. * * @param transaction An editor state transaction */ dispatchTransaction(transaction) { // if the editor / the view of the editor was destroyed // the transaction should not be dispatched as there is no view anymore. if (this.view.isDestroyed) { return; } if (this.isCapturingTransaction) { if (!this.capturedTransaction) { this.capturedTransaction = transaction; return; } transaction.steps.forEach(step => { var _a; return (_a = this.capturedTransaction) === null || _a === void 0 ? void 0 : _a.step(step); }); return; } const state = this.state.apply(transaction); const selectionHasChanged = !this.state.selection.eq(state.selection); this.emit('beforeTransaction', { editor: this, transaction, nextState: state, }); this.view.updateState(state); this.emit('transaction', { editor: this, transaction, }); if (selectionHasChanged) { this.emit('selectionUpdate', { editor: this, transaction, }); } const focus = transaction.getMeta('focus'); const blur = transaction.getMeta('blur'); if (focus) { this.emit('focus', { editor: this, event: focus.event, transaction, }); } if (blur) { this.emit('blur', { editor: this, event: blur.event, transaction, }); } if (!transaction.docChanged || transaction.getMeta('preventUpdate')) { return; } this.emit('update', { editor: this, transaction, }); } /** * Get attributes of the currently selected node or mark. */ getAttributes(nameOrType) { return getAttributes(this.state, nameOrType); } isActive(nameOrAttributes, attributesOrUndefined) { const name = typeof nameOrAttributes === 'string' ? nameOrAttributes : null; const attributes = typeof nameOrAttributes === 'string' ? attributesOrUndefined : nameOrAttributes; return isActive(this.state, name, attributes); } /** * Get the document as JSON. */ getJSON() { return this.state.doc.toJSON(); } /** * Get the document as HTML. */ getHTML() { return getHTMLFromFragment(this.state.doc.content, this.schema); } /** * Get the document as text. */ getText(options) { const { blockSeparator = '\n\n', textSerializers = {} } = options || {}; return getText(this.state.doc, { blockSeparator, textSerializers: { ...getTextSerializersFromSchema(this.schema), ...textSerializers, }, }); } /** * Check if there is no content. */ get isEmpty() { return isNodeEmpty(this.state.doc); } /** * Get the number of characters for the current document. * * @deprecated */ getCharacterCount() { console.warn('[tiptap warn]: "editor.getCharacterCount()" is deprecated. Please use "editor.storage.characterCount.characters()" instead.'); return this.state.doc.content.size - 2; } /** * Destroy the editor. */ destroy() { this.emit('destroy'); if (this.view) { // Cleanup our reference to prevent circular references which caused memory leaks // @ts-ignore const dom = this.view.dom; if (dom && dom.editor) { delete dom.editor; } this.view.destroy(); } this.removeAllListeners(); } /** * Check if the editor is already destroyed. */ get isDestroyed() { var _a; // @ts-ignore return !((_a = this.view) === null || _a === void 0 ? void 0 : _a.docView); } $node(selector, attributes) { var _a; return ((_a = this.$doc) === null || _a === void 0 ? void 0 : _a.querySelector(selector, attributes)) || null; } $nodes(selector, attributes) { var _a; return ((_a = this.$doc) === null || _a === void 0 ? void 0 : _a.querySelectorAll(selector, attributes)) || null; } $pos(pos) { const $pos = this.state.doc.resolve(pos); return new NodePos($pos, this); } get $doc() { return this.$pos(0); } } /** * Build an input rule that adds a mark when the * matched text is typed into it. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules */ function markInputRule(config) { return new InputRule({ find: config.find, handler: ({ state, range, match }) => { const attributes = callOrReturn(config.getAttributes, undefined, match); if (attributes === false || attributes === null) { return null; } const { tr } = state; const captureGroup = match[match.length - 1]; const fullMatch = match[0]; if (captureGroup) { const startSpaces = fullMatch.search(/\S/); const textStart = range.from + fullMatch.indexOf(captureGroup); const textEnd = textStart + captureGroup.length; const excludedMarks = getMarksBetween(range.from, range.to, state.doc) .filter(item => { // @ts-ignore const excluded = item.mark.type.excluded; return excluded.find(type => type === config.type && type !== item.mark.type); }) .filter(item => item.to > textStart); if (excludedMarks.length) { return null; } if (textEnd < range.to) { tr.delete(textEnd, range.to); } if (textStart > range.from) { tr.delete(range.from + startSpaces, textStart); } const markEnd = range.from + startSpaces + captureGroup.length; tr.addMark(range.from + startSpaces, markEnd, config.type.create(attributes || {})); tr.removeStoredMark(config.type); } }, }); } /** * Build an input rule that adds a node when the * matched text is typed into it. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules */ function nodeInputRule(config) { return new InputRule({ find: config.find, handler: ({ state, range, match }) => { const attributes = callOrReturn(config.getAttributes, undefined, match) || {}; const { tr } = state; const start = range.from; let end = range.to; const newNode = config.type.create(attributes); if (match[1]) { const offset = match[0].lastIndexOf(match[1]); let matchStart = start + offset; if (matchStart > end) { matchStart = end; } else { end = matchStart + match[1].length; } // insert last typed character const lastChar = match[0][match[0].length - 1]; tr.insertText(lastChar, start + match[0].length - 1); // insert node from input rule tr.replaceWith(matchStart, end, newNode); } else if (match[0]) { const insertionStart = config.type.isInline ? start : start - 1; tr.insert(insertionStart, config.type.create(attributes)).delete(tr.mapping.map(start), tr.mapping.map(end)); } tr.scrollIntoView(); }, }); } /** * Build an input rule that changes the type of a textblock when the * matched text is typed into it. When using a regular expresion you’ll * probably want the regexp to start with `^`, so that the pattern can * only occur at the start of a textblock. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules */ function textblockTypeInputRule(config) { return new InputRule({ find: config.find, handler: ({ state, range, match }) => { const $start = state.doc.resolve(range.from); const attributes = callOrReturn(config.getAttributes, undefined, match) || {}; if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), config.type)) { return null; } state.tr .delete(range.from, range.to) .setBlockType(range.from, range.from, config.type, attributes); }, }); } /** * Build an input rule that replaces text when the * matched text is typed into it. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules */ function textInputRule(config) { return new InputRule({ find: config.find, handler: ({ state, range, match }) => { let insert = config.replace; let start = range.from; const end = range.to; if (match[1]) { const offset = match[0].lastIndexOf(match[1]); insert += match[0].slice(offset + match[1].length); start += offset; const cutOff = start - end; if (cutOff > 0) { insert = match[0].slice(offset - cutOff, offset) + insert; start = end; } } state.tr.insertText(insert, start, end); }, }); } /** * Build an input rule for automatically wrapping a textblock when a * given string is typed. When using a regular expresion you’ll * probably want the regexp to start with `^`, so that the pattern can * only occur at the start of a textblock. * * `type` is the type of node to wrap in. * * By default, if there’s a node with the same type above the newly * wrapped node, the rule will try to join those * two nodes. You can pass a join predicate, which takes a regular * expression match and the node before the wrapped node, and can * return a boolean to indicate whether a join should happen. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules */ function wrappingInputRule(config) { return new InputRule({ find: config.find, handler: ({ state, range, match, chain, }) => { const attributes = callOrReturn(config.getAttributes, undefined, match) || {}; const tr = state.tr.delete(range.from, range.to); const $start = tr.doc.resolve(range.from); const blockRange = $start.blockRange(); const wrapping = blockRange && transform.findWrapping(blockRange, config.type, attributes); if (!wrapping) { return null; } tr.wrap(blockRange, wrapping); if (config.keepMarks && config.editor) { const { selection, storedMarks } = state; const { splittableMarks } = config.editor.extensionManager; const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks()); if (marks) { const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name)); tr.ensureMarks(filteredMarks); } } if (config.keepAttributes) { /** If the nodeType is `bulletList` or `orderedList` set the `nodeType` as `listItem` */ const nodeType = config.type.name === 'bulletList' || config.type.name === 'orderedList' ? 'listItem' : 'taskList'; chain().updateAttributes(nodeType, attributes).run(); } const before = tr.doc.resolve(range.from - 1).nodeBefore; if (before && before.type === config.type && transform.canJoin(tr.doc, range.from - 1) && (!config.joinPredicate || config.joinPredicate(match, before))) { tr.join(range.from - 1); } }, }); } /** * The Node class is used to create custom node extensions. * @see https://tiptap.dev/api/extensions#create-a-new-extension */ class Node { constructor(config = {}) { this.type = 'node'; this.name = 'node'; 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 Node(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 Node(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; } } function isAndroid() { return navigator.platform === 'Android' || /android/i.test(navigator.userAgent); } /** * Node views are used to customize the rendered DOM structure of a node. * @see https://tiptap.dev/guide/node-views */ class NodeView { constructor(component, props, options) { this.isDragging = false; this.component = component; this.editor = props.editor; this.options = { stopEvent: null, ignoreMutation: null, ...options, }; this.extension = props.extension; this.node = props.node; this.decorations = props.decorations; this.innerDecorations = props.innerDecorations; this.view = props.view; this.HTMLAttributes = props.HTMLAttributes; this.getPos = props.getPos; this.mount(); } mount() { // eslint-disable-next-line return; } get dom() { return this.editor.view.dom; } get contentDOM() { return null; } onDragStart(event) { var _a, _b, _c, _d, _e, _f, _g; const { view } = this.editor; const target = event.target; // get the drag handle element // `closest` is not available for text nodes so we may have to use its parent const dragHandle = target.nodeType === 3 ? (_a = target.parentElement) === null || _a === void 0 ? void 0 : _a.closest('[data-drag-handle]') : target.closest('[data-drag-handle]'); if (!this.dom || ((_b = this.contentDOM) === null || _b === void 0 ? void 0 : _b.contains(target)) || !dragHandle) { return; } let x = 0; let y = 0; // calculate offset for drag element if we use a different drag handle element if (this.dom !== dragHandle) { const domBox = this.dom.getBoundingClientRect(); const handleBox = dragHandle.getBoundingClientRect(); // In React, we have to go through nativeEvent to reach offsetX/offsetY. const offsetX = (_c = event.offsetX) !== null && _c !== void 0 ? _c : (_d = event.nativeEvent) === null || _d === void 0 ? void 0 : _d.offsetX; const offsetY = (_e = event.offsetY) !== null && _e !== void 0 ? _e : (_f = event.nativeEvent) === null || _f === void 0 ? void 0 : _f.offsetY; x = handleBox.x - domBox.x + offsetX; y = handleBox.y - domBox.y + offsetY; } (_g = event.dataTransfer) === null || _g === void 0 ? void 0 : _g.setDragImage(this.dom, x, y); const pos = this.getPos(); if (typeof pos !== 'number') { return; } // we need to tell ProseMirror that we want to move the whole node // so we create a NodeSelection const selection = state.NodeSelection.create(view.state.doc, pos); const transaction = view.state.tr.setSelection(selection); view.dispatch(transaction); } stopEvent(event) { var _a; if (!this.dom) { return false; } if (typeof this.options.stopEvent === 'function') { return this.options.stopEvent({ event }); } const target = event.target; const isInElement = this.dom.contains(target) && !((_a = this.contentDOM) === null || _a === void 0 ? void 0 : _a.contains(target)); // any event from child nodes should be handled by ProseMirror if (!isInElement) { return false; } const isDragEvent = event.type.startsWith('drag'); const isDropEvent = event.type === 'drop'; const isInput = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable; // any input event within node views should be ignored by ProseMirror if (isInput && !isDropEvent && !isDragEvent) { return true; } const { isEditable } = this.editor; const { isDragging } = this; const isDraggable = !!this.node.type.spec.draggable; const isSelectable = state.NodeSelection.isSelectable(this.node); const isCopyEvent = event.type === 'copy'; const isPasteEvent = event.type === 'paste'; const isCutEvent = event.type === 'cut'; const isClickEvent = event.type === 'mousedown'; // ProseMirror tries to drag selectable nodes // even if `draggable` is set to `false` // this fix prevents that if (!isDraggable && isSelectable && isDragEvent) { event.preventDefault(); } if (isDraggable && isDragEvent && !isDragging) { event.preventDefault(); return false; } // we have to store that dragging started if (isDraggable && isEditable && !isDragging && isClickEvent) { const dragHandle = target.closest('[data-drag-handle]'); const isValidDragHandle = dragHandle && (this.dom === dragHandle || this.dom.contains(dragHandle)); if (isValidDragHandle) { this.isDragging = true; document.addEventListener('dragend', () => { this.isDragging = false; }, { once: true }); document.addEventListener('drop', () => { this.isDragging = false; }, { once: true }); document.addEventListener('mouseup', () => { this.isDragging = false; }, { once: true }); } } // these events are handled by prosemirror if (isDragging || isDropEvent || isCopyEvent || isPasteEvent || isCutEvent || (isClickEvent && isSelectable)) { return false; } return true; } /** * Called when a DOM [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) or a selection change happens within the view. * @return `false` if the editor should re-read the selection or re-parse the range around the mutation * @return `true` if it can safely be ignored. */ ignoreMutation(mutation) { if (!this.dom || !this.contentDOM) { return true; } if (typeof this.options.ignoreMutation === 'function') { return this.options.ignoreMutation({ mutation }); } // a leaf/atom node is like a black box for ProseMirror // and should be fully handled by the node view if (this.node.isLeaf || this.node.isAtom) { return true; } // ProseMirror should handle any selections if (mutation.type === 'selection') { return false; } // try to prevent a bug on iOS and Android that will break node views on enter // this is because ProseMirror can’t preventDispatch on enter // this will lead to a re-render of the node view on enter // see: https://github.com/ueberdosis/tiptap/issues/1214 // see: https://github.com/ueberdosis/tiptap/issues/2534 if (this.dom.contains(mutation.target) && mutation.type === 'childList' && (isiOS() || isAndroid()) && this.editor.isFocused) { const changedNodes = [ ...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes), ]; // we’ll check if every changed node is contentEditable // to make sure it’s probably mutated by ProseMirror if (changedNodes.every(node => node.isContentEditable)) { return false; } } // we will allow mutation contentDOM with attributes // so we can for example adding classes within our node view if (this.contentDOM === mutation.target && mutation.type === 'attributes') { return true; } // ProseMirror should handle any changes within contentDOM if (this.contentDOM.contains(mutation.target)) { return false; } return true; } /** * Update the attributes of the prosemirror node. */ updateAttributes(attributes) { this.editor.commands.command(({ tr }) => { const pos = this.getPos(); if (typeof pos !== 'number') { return false; } tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attributes, }); return true; }); } /** * Delete the node. */ deleteNode() { const from = this.getPos(); if (typeof from !== 'number') { return; } const to = from + this.node.nodeSize; this.editor.commands.deleteRange({ from, to }); } } /** * Build an paste rule that adds a mark when the * matched text is pasted into it. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules */ function markPasteRule(config) { return new PasteRule({ find: config.find, handler: ({ state, range, match, pasteEvent, }) => { const attributes = callOrReturn(config.getAttributes, undefined, match, pasteEvent); if (attributes === false || attributes === null) { return null; } const { tr } = state; const captureGroup = match[match.length - 1]; const fullMatch = match[0]; let markEnd = range.to; if (captureGroup) { const startSpaces = fullMatch.search(/\S/); const textStart = range.from + fullMatch.indexOf(captureGroup); const textEnd = textStart + captureGroup.length; const excludedMarks = getMarksBetween(range.from, range.to, state.doc) .filter(item => { // @ts-ignore const excluded = item.mark.type.excluded; return excluded.find(type => type === config.type && type !== item.mark.type); }) .filter(item => item.to > textStart); if (excludedMarks.length) { return null; } if (textEnd < range.to) { tr.delete(textEnd, range.to); } if (textStart > range.from) { tr.delete(range.from + startSpaces, textStart); } markEnd = range.from + startSpaces + captureGroup.length; tr.addMark(range.from + startSpaces, markEnd, config.type.create(attributes || {})); tr.removeStoredMark(config.type); } }, }); } // source: https://stackoverflow.com/a/6969486 function escapeForRegEx(string) { return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); } function isString(value) { return typeof value === 'string'; } /** * Build an paste rule that adds a node when the * matched text is pasted into it. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules */ function nodePasteRule(config) { return new PasteRule({ find: config.find, handler({ match, chain, range, pasteEvent, }) { const attributes = callOrReturn(config.getAttributes, undefined, match, pasteEvent); const content = callOrReturn(config.getContent, undefined, attributes); if (attributes === false || attributes === null) { return null; } const node = { type: config.type.name, attrs: attributes }; if (content) { node.content = content; } if (match.input) { chain().deleteRange(range).insertContentAt(range.from, node); } }, }); } /** * Build an paste rule that replaces text when the * matched text is pasted into it. * @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules */ function textPasteRule(config) { return new PasteRule({ find: config.find, handler: ({ state, range, match }) => { let insert = config.replace; let start = range.from; const end = range.to; if (match[1]) { const offset = match[0].lastIndexOf(match[1]); insert += match[0].slice(offset + match[1].length); start += offset; const cutOff = start - end; if (cutOff > 0) { insert = match[0].slice(offset - cutOff, offset) + insert; start = end; } } state.tr.insertText(insert, start, end); }, }); } class Tracker { constructor(transaction) { this.transaction = transaction; this.currentStep = this.transaction.steps.length; } map(position) { let deleted = false; const mappedPosition = this.transaction.steps .slice(this.currentStep) .reduce((newPosition, step) => { const mapResult = step.getMap().mapResult(newPosition); if (mapResult.deleted) { deleted = true; } return mapResult.pos; }, position); return { position: mappedPosition, deleted, }; } } exports.CommandManager = CommandManager; exports.Editor = Editor; exports.Extension = Extension; exports.InputRule = InputRule; exports.Mark = Mark; exports.Node = Node; exports.NodePos = NodePos; exports.NodeView = NodeView; exports.PasteRule = PasteRule; exports.Tracker = Tracker; exports.callOrReturn = callOrReturn; exports.combineTransactionSteps = combineTransactionSteps; exports.createChainableState = createChainableState; exports.createDocument = createDocument; exports.createNodeFromContent = createNodeFromContent; exports.createStyleTag = createStyleTag; exports.defaultBlockAt = defaultBlockAt; exports.deleteProps = deleteProps; exports.elementFromString = elementFromString; exports.escapeForRegEx = escapeForRegEx; exports.extensions = index; exports.findChildren = findChildren; exports.findChildrenInRange = findChildrenInRange; exports.findDuplicates = findDuplicates; exports.findParentNode = findParentNode; exports.findParentNodeClosestToPos = findParentNodeClosestToPos; exports.fromString = fromString; exports.generateHTML = generateHTML; exports.generateJSON = generateJSON; exports.generateText = generateText; exports.getAttributes = getAttributes; exports.getAttributesFromExtensions = getAttributesFromExtensions; exports.getChangedRanges = getChangedRanges; exports.getDebugJSON = getDebugJSON; exports.getExtensionField = getExtensionField; exports.getHTMLFromFragment = getHTMLFromFragment; exports.getMarkAttributes = getMarkAttributes; exports.getMarkRange = getMarkRange; exports.getMarkType = getMarkType; exports.getMarksBetween = getMarksBetween; exports.getNodeAtPosition = getNodeAtPosition; exports.getNodeAttributes = getNodeAttributes; exports.getNodeType = getNodeType; exports.getRenderedAttributes = getRenderedAttributes; exports.getSchema = getSchema; exports.getSchemaByResolvedExtensions = getSchemaByResolvedExtensions; exports.getSchemaTypeByName = getSchemaTypeByName; exports.getSchemaTypeNameByName = getSchemaTypeNameByName; exports.getSplittedAttributes = getSplittedAttributes; exports.getText = getText; exports.getTextBetween = getTextBetween; exports.getTextContentFromNodes = getTextContentFromNodes; exports.getTextSerializersFromSchema = getTextSerializersFromSchema; exports.injectExtensionAttributesToParseRule = injectExtensionAttributesToParseRule; exports.inputRulesPlugin = inputRulesPlugin; exports.isActive = isActive; exports.isAtEndOfNode = isAtEndOfNode; exports.isAtStartOfNode = isAtStartOfNode; exports.isEmptyObject = isEmptyObject; exports.isExtensionRulesEnabled = isExtensionRulesEnabled; exports.isFunction = isFunction; exports.isList = isList; exports.isMacOS = isMacOS; exports.isMarkActive = isMarkActive; exports.isNodeActive = isNodeActive; exports.isNodeEmpty = isNodeEmpty; exports.isNodeSelection = isNodeSelection; exports.isNumber = isNumber; exports.isPlainObject = isPlainObject; exports.isRegExp = isRegExp; exports.isString = isString; exports.isTextSelection = isTextSelection; exports.isiOS = isiOS; exports.markInputRule = markInputRule; exports.markPasteRule = markPasteRule; exports.mergeAttributes = mergeAttributes; exports.mergeDeep = mergeDeep; exports.minMax = minMax; exports.nodeInputRule = nodeInputRule; exports.nodePasteRule = nodePasteRule; exports.objectIncludes = objectIncludes; exports.pasteRulesPlugin = pasteRulesPlugin; exports.posToDOMRect = posToDOMRect; exports.removeDuplicates = removeDuplicates; exports.resolveFocusPosition = resolveFocusPosition; exports.selectionToInsertionEnd = selectionToInsertionEnd; exports.splitExtensions = splitExtensions; exports.textInputRule = textInputRule; exports.textPasteRule = textPasteRule; exports.textblockTypeInputRule = textblockTypeInputRule; exports.wrappingInputRule = wrappingInputRule; //# sourceMappingURL=index.cjs.map