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