UNPKG

tiptap-extensions

Version:

Extensions for tiptap

2,076 lines (1,793 loc) 46.6 kB
/*! * tiptap-extensions v1.35.1 * (c) 2021 überdosis GbR (limited liability) * @license MIT */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var tiptap = require('tiptap'); var tiptapCommands = require('tiptap-commands'); var low = _interopDefault(require('lowlight/lib/core')); var prosemirrorView = require('prosemirror-view'); var tiptapUtils = require('tiptap-utils'); var prosemirrorModel = require('prosemirror-model'); var prosemirrorState = require('prosemirror-state'); var prosemirrorTables = require('prosemirror-tables'); var prosemirrorTransform = require('prosemirror-transform'); var prosemirrorCollab = require('prosemirror-collab'); var prosemirrorHistory = require('prosemirror-history'); class Blockquote extends tiptap.Node { get name() { return 'blockquote'; } get schema() { return { content: 'block*', group: 'block', defining: true, draggable: false, parseDOM: [{ tag: 'blockquote' }], toDOM: () => ['blockquote', 0] }; } commands({ type }) { return () => tiptapCommands.toggleWrap(type); } keys({ type }) { return { 'Ctrl->': tiptapCommands.toggleWrap(type) }; } inputRules({ type }) { return [tiptapCommands.wrappingInputRule(/^\s*>\s$/, type)]; } } class BulletList extends tiptap.Node { get name() { return 'bullet_list'; } get schema() { return { content: 'list_item+', group: 'block', parseDOM: [{ tag: 'ul' }], toDOM: () => ['ul', 0] }; } commands({ type, schema }) { return () => tiptapCommands.toggleList(type, schema.nodes.list_item); } keys({ type, schema }) { return { 'Shift-Ctrl-8': tiptapCommands.toggleList(type, schema.nodes.list_item) }; } inputRules({ type }) { return [tiptapCommands.wrappingInputRule(/^\s*([-+*])\s$/, type)]; } } class CodeBlock extends tiptap.Node { get name() { return 'code_block'; } get schema() { return { content: 'text*', marks: '', group: 'block', code: true, defining: true, draggable: false, parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }], toDOM: () => ['pre', ['code', 0]] }; } commands({ type, schema }) { return () => tiptapCommands.toggleBlockType(type, schema.nodes.paragraph); } keys({ type }) { return { 'Shift-Ctrl-\\': tiptapCommands.setBlockType(type) }; } inputRules({ type }) { return [tiptapCommands.textblockTypeInputRule(/^```$/, type)]; } } function getDecorations({ doc, name }) { const decorations = []; const blocks = tiptapUtils.findBlockNodes(doc).filter(item => item.node.type.name === name); const flatten = list => list.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []); function parseNodes(nodes, className = []) { return nodes.map(node => { const classes = [...className, ...(node.properties ? node.properties.className : [])]; if (node.children) { return parseNodes(node.children, classes); } return { text: node.value, classes }; }); } blocks.forEach(block => { let startPos = block.pos + 1; const nodes = low.highlightAuto(block.node.textContent).value; flatten(parseNodes(nodes)).map(node => { const from = startPos; const to = from + node.text.length; startPos = to; return { ...node, from, to }; }).forEach(node => { const decoration = prosemirrorView.Decoration.inline(node.from, node.to, { class: node.classes.join(' ') }); decorations.push(decoration); }); }); return prosemirrorView.DecorationSet.create(doc, decorations); } function HighlightPlugin({ name }) { return new tiptap.Plugin({ name: new tiptap.PluginKey('highlight'), state: { init: (_, { doc }) => getDecorations({ doc, name }), apply: (transaction, decorationSet, oldState, newState) => { // TODO: find way to cache decorations // https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493 const oldNodeName = oldState.selection.$head.parent.type.name; const newNodeName = newState.selection.$head.parent.type.name; const oldNodes = tiptapUtils.findBlockNodes(oldState.doc).filter(item => item.node.type.name === name); const newNodes = tiptapUtils.findBlockNodes(newState.doc).filter(item => item.node.type.name === name); // Apply decorations if selection includes named node, or transaction changes named node. if (transaction.docChanged && ([oldNodeName, newNodeName].includes(name) || newNodes.length !== oldNodes.length)) { return getDecorations({ doc: transaction.doc, name }); } return decorationSet.map(transaction.mapping, transaction.doc); } }, props: { decorations(state) { return this.getState(state); } } }); } class CodeBlockHighlight extends tiptap.Node { constructor(options = {}) { super(options); try { Object.entries(this.options.languages).forEach(([name, mapping]) => { low.registerLanguage(name, mapping); }); } catch (err) { throw new Error('Invalid syntax highlight definitions: define at least one highlight.js language mapping'); } } get name() { return 'code_block'; } get defaultOptions() { return { languages: {} }; } get schema() { return { content: 'text*', marks: '', group: 'block', code: true, defining: true, draggable: false, parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }], toDOM: () => ['pre', ['code', 0]] }; } commands({ type, schema }) { return () => tiptapCommands.toggleBlockType(type, schema.nodes.paragraph); } keys({ type }) { return { 'Shift-Ctrl-\\': tiptapCommands.setBlockType(type) }; } inputRules({ type }) { return [tiptapCommands.textblockTypeInputRule(/^```$/, type)]; } get plugins() { return [HighlightPlugin({ name: this.name })]; } } class HardBreak extends tiptap.Node { get name() { return 'hard_break'; } get schema() { return { inline: true, group: 'inline', selectable: false, parseDOM: [{ tag: 'br' }], toDOM: () => ['br'] }; } commands({ type }) { return () => tiptapCommands.chainCommands(tiptapCommands.exitCode, (state, dispatch) => { dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); return true; }); } keys({ type }) { const command = tiptapCommands.chainCommands(tiptapCommands.exitCode, (state, dispatch) => { dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); return true; }); return { 'Mod-Enter': command, 'Shift-Enter': command }; } } class Heading extends tiptap.Node { get name() { return 'heading'; } get defaultOptions() { return { levels: [1, 2, 3, 4, 5, 6] }; } get schema() { return { attrs: { level: { default: 1 } }, content: 'inline*', group: 'block', defining: true, draggable: false, parseDOM: this.options.levels.map(level => ({ tag: `h${level}`, attrs: { level } })), toDOM: node => [`h${node.attrs.level}`, 0] }; } commands({ type, schema }) { return attrs => tiptapCommands.toggleBlockType(type, schema.nodes.paragraph, attrs); } keys({ type }) { return this.options.levels.reduce((items, level) => ({ ...items, ...{ [`Shift-Ctrl-${level}`]: tiptapCommands.setBlockType(type, { level }) } }), {}); } inputRules({ type }) { return this.options.levels.map(level => tiptapCommands.textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), type, () => ({ level }))); } } class HorizontalRule extends tiptap.Node { get name() { return 'horizontal_rule'; } get schema() { return { group: 'block', parseDOM: [{ tag: 'hr' }], toDOM: () => ['hr'] }; } commands({ type }) { return () => (state, dispatch) => dispatch(state.tr.replaceSelectionWith(type.create())); } inputRules({ type }) { return [tiptapCommands.nodeInputRule(/^(?:---|___\s|\*\*\*\s)$/, type)]; } } /** * Matches following attributes in Markdown-typed image: [, alt, src, title] * * Example: * ![Lorem](image.jpg) -> [, "Lorem", "image.jpg"] * ![](image.jpg "Ipsum") -> [, "", "image.jpg", "Ipsum"] * ![Lorem](image.jpg "Ipsum") -> [, "Lorem", "image.jpg", "Ipsum"] */ const IMAGE_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; class Image extends tiptap.Node { get name() { return 'image'; } get schema() { return { inline: true, attrs: { src: {}, alt: { default: null }, title: { default: null } }, group: 'inline', draggable: true, parseDOM: [{ tag: 'img[src]', getAttrs: dom => ({ src: dom.getAttribute('src'), title: dom.getAttribute('title'), alt: dom.getAttribute('alt') }) }], toDOM: node => ['img', node.attrs] }; } commands({ type }) { return attrs => (state, dispatch) => { const { selection } = state; const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos; const node = type.create(attrs); const transaction = state.tr.insert(position, node); dispatch(transaction); }; } inputRules({ type }) { return [tiptapCommands.nodeInputRule(IMAGE_INPUT_REGEX, type, match => { const [, alt, src, title] = match; return { src, alt, title }; })]; } get plugins() { return [new tiptap.Plugin({ props: { handleDOMEvents: { drop(view, event) { const hasFiles = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length; if (!hasFiles) { return; } const images = Array.from(event.dataTransfer.files).filter(file => /image/i.test(file.type)); if (images.length === 0) { return; } event.preventDefault(); const { schema } = view.state; const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); images.forEach(image => { const reader = new FileReader(); reader.onload = readerEvent => { const node = schema.nodes.image.create({ src: readerEvent.target.result }); const transaction = view.state.tr.insert(coordinates.pos, node); view.dispatch(transaction); }; reader.readAsDataURL(image); }); } } } })]; } } class ListItem extends tiptap.Node { get name() { return 'list_item'; } get schema() { return { content: 'paragraph block*', defining: true, draggable: false, parseDOM: [{ tag: 'li' }], toDOM: () => ['li', 0] }; } keys({ type }) { return { Enter: tiptapCommands.splitListItem(type), Tab: tiptapCommands.sinkListItem(type), 'Shift-Tab': tiptapCommands.liftListItem(type) }; } } function getTextBetween(node, from, to, blockSeparator, inlineSeparator, leafText = '\0') { let text = ''; let blockSeparated = true; let inlineNode = null; node.content.nodesBetween(from, to, (innerNode, pos) => { if (innerNode.isText) { if (inlineNode) { inlineNode = null; return; } text += innerNode.text.slice(Math.max(from, pos) - pos, to - pos); blockSeparated = !blockSeparator; } else if (innerNode.isLeaf && leafText) { text += leafText; blockSeparated = !blockSeparator; } else if (innerNode.isInline && !innerNode.isLeaf) { text += inlineSeparator; if (innerNode.textContent) { text += innerNode.textContent; inlineNode = innerNode; } text += inlineSeparator; blockSeparated = !blockSeparated; } else if (!blockSeparated && innerNode.isBlock) { text += blockSeparator; blockSeparated = true; } }, 0); return text; } // Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags. function triggerCharacter({ char = '@', allowSpaces = false, startOfLine = false }) { return $position => { // cancel if top level node if ($position.depth <= 0) { return false; } // Matching expressions used for later const escapedChar = `\\${char}`; const suffix = new RegExp(`\\s${escapedChar}$`); const prefix = startOfLine ? '^' : ''; const regexp = allowSpaces ? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${escapedChar}|$)`, 'gm') : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm'); // Lookup the boundaries of the current node const textFrom = $position.before(); const textTo = $position.end(); const text = getTextBetween($position.doc, textFrom, textTo, '\0', '\0'); let match = regexp.exec(text); let position; while (match !== null) { // JavaScript doesn't have lookbehinds; this hacks a check that first character is " " // or the line beginning const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index); if (/^[\s\0]?$/.test(matchPrefix)) { // The absolute position of the match in the document const from = match.index + $position.start(); let to = from + match[0].length; // Edge case handling; if spaces are allowed and we're directly in between // two triggers if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) { match[0] += ' '; to += 1; } // If the $position is located within the matched substring, return that range if (from < $position.pos && to >= $position.pos) { position = { range: { from, to }, query: match[0].slice(char.length), text: match[0] }; } } match = regexp.exec(text); } return position; }; } function SuggestionsPlugin({ matcher = { char: '@', allowSpaces: false, startOfLine: false }, appendText = null, suggestionClass = 'suggestion', command = () => false, items = [], onEnter = () => false, onChange = () => false, onExit = () => false, onKeyDown = () => false, onFilter = (searchItems, query) => { if (!query) { return searchItems; } return searchItems.filter(item => JSON.stringify(item).toLowerCase().includes(query.toLowerCase())); } }) { return new prosemirrorState.Plugin({ key: new prosemirrorState.PluginKey('suggestions'), view() { return { update: async (view, prevState) => { const prev = this.key.getState(prevState); const next = this.key.getState(view.state); // See how the state changed const moved = prev.active && next.active && prev.range.from !== next.range.from; const started = !prev.active && next.active; const stopped = prev.active && !next.active; const changed = !started && !stopped && prev.query !== next.query; const handleStart = started || moved; const handleChange = changed && !moved; const handleExit = stopped || moved; // Cancel when suggestion isn't active if (!handleStart && !handleChange && !handleExit) { return; } const state = handleExit ? prev : next; const decorationNode = document.querySelector(`[data-decoration-id="${state.decorationId}"]`); // build a virtual node for popper.js or tippy.js // this can be used for building popups without a DOM node const virtualNode = decorationNode ? { getBoundingClientRect() { return decorationNode.getBoundingClientRect(); }, clientWidth: decorationNode.clientWidth, clientHeight: decorationNode.clientHeight } : null; const props = { view, range: state.range, query: state.query, text: state.text, decorationNode, virtualNode, items: handleChange || handleStart ? await onFilter(Array.isArray(items) ? items : await items(), state.query) : [], command: ({ range, attrs }) => { command({ range, attrs, schema: view.state.schema })(view.state, view.dispatch, view); if (appendText) { tiptapCommands.insertText(appendText)(view.state, view.dispatch, view); } } }; // Trigger the hooks when necessary if (handleExit) { onExit(props); } if (handleChange) { onChange(props); } if (handleStart) { onEnter(props); } } }; }, state: { // Initialize the plugin's internal state. init() { return { active: false, range: {}, query: null, text: null }; }, // Apply changes to the plugin state from a view transaction. apply(tr, prev) { const { selection } = tr; const next = { ...prev }; // We can only be suggesting if there is no selection if (selection.from === selection.to) { // Reset active state if we just left the previous suggestion range if (selection.from < prev.range.from || selection.from > prev.range.to) { next.active = false; } // Try to match against where our cursor currently is const $position = selection.$from; const match = triggerCharacter(matcher)($position); const decorationId = (Math.random() + 1).toString(36).substr(2, 5); // If we found a match, update the current state to show it if (match) { next.active = true; next.decorationId = prev.decorationId ? prev.decorationId : decorationId; next.range = match.range; next.query = match.query; next.text = match.text; } else { next.active = false; } } else { next.active = false; } // Make sure to empty the range if suggestion is inactive if (!next.active) { next.decorationId = null; next.range = {}; next.query = null; next.text = null; } return next; } }, props: { // Call the keydown hook if suggestion is active. handleKeyDown(view, event) { const { active, range } = this.getState(view.state); if (!active) return false; return onKeyDown({ view, event, range }); }, // Setup decorator on the currently active suggestion. decorations(editorState) { const { active, range, decorationId } = this.getState(editorState); if (!active) return null; return prosemirrorView.DecorationSet.create(editorState.doc, [prosemirrorView.Decoration.inline(range.from, range.to, { nodeName: 'span', class: suggestionClass, 'data-decoration-id': decorationId })]); } } }); } class Mention extends tiptap.Node { get name() { return 'mention'; } get defaultOptions() { return { matcher: { char: '@', allowSpaces: false, startOfLine: false }, mentionClass: 'mention', suggestionClass: 'mention-suggestion' }; } getLabel(dom) { return dom.innerText.split(this.options.matcher.char).join(''); } createFragment(schema, label) { return prosemirrorModel.Fragment.fromJSON(schema, [{ type: 'text', text: `${this.options.matcher.char}${label}` }]); } insertMention(range, attrs, schema) { const nodeType = schema.nodes[this.name]; const nodeFragment = this.createFragment(schema, attrs.label); return tiptapCommands.replaceText(range, nodeType, attrs, nodeFragment); } get schema() { return { attrs: { id: {}, label: {} }, group: 'inline', inline: true, content: 'text*', selectable: false, atom: true, toDOM: node => ['span', { class: this.options.mentionClass, 'data-mention-id': node.attrs.id }, `${this.options.matcher.char}${node.attrs.label}`], parseDOM: [{ tag: 'span[data-mention-id]', getAttrs: dom => { const id = dom.getAttribute('data-mention-id'); const label = this.getLabel(dom); return { id, label }; }, getContent: (dom, schema) => { const label = this.getLabel(dom); return this.createFragment(schema, label); } }] }; } commands({ schema }) { return attrs => this.insertMention(null, attrs, schema); } get plugins() { return [SuggestionsPlugin({ command: ({ range, attrs, schema }) => this.insertMention(range, attrs, schema), appendText: ' ', matcher: this.options.matcher, items: this.options.items, onEnter: this.options.onEnter, onChange: this.options.onChange, onExit: this.options.onExit, onKeyDown: this.options.onKeyDown, onFilter: this.options.onFilter, suggestionClass: this.options.suggestionClass })]; } } class OrderedList extends tiptap.Node { get name() { return 'ordered_list'; } get schema() { return { attrs: { order: { default: 1 } }, content: 'list_item+', group: 'block', parseDOM: [{ tag: 'ol', getAttrs: dom => ({ order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1 }) }], toDOM: node => node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0] }; } commands({ type, schema }) { return () => tiptapCommands.toggleList(type, schema.nodes.list_item); } keys({ type, schema }) { return { 'Shift-Ctrl-9': tiptapCommands.toggleList(type, schema.nodes.list_item) }; } inputRules({ type }) { return [tiptapCommands.wrappingInputRule(/^(\d+)\.\s$/, type, match => ({ order: +match[1] }), (match, node) => node.childCount + node.attrs.order === +match[1])]; } } var TableNodes = prosemirrorTables.tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: { background: { default: null, getFromDOM(dom) { return dom.style.backgroundColor || null; }, setDOMAttr(value, attrs) { if (value) { const style = { style: `${attrs.style || ''}background-color: ${value};` }; Object.assign(attrs, style); } } } } }); class Table extends tiptap.Node { get name() { return 'table'; } get defaultOptions() { return { resizable: false }; } get schema() { return TableNodes.table; } commands({ schema }) { return { createTable: ({ rowsCount, colsCount, withHeaderRow }) => (state, dispatch) => { const offset = state.tr.selection.anchor + 1; const nodes = tiptapUtils.createTable(schema, rowsCount, colsCount, withHeaderRow); const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView(); const resolvedPos = tr.doc.resolve(offset); tr.setSelection(prosemirrorState.TextSelection.near(resolvedPos)); dispatch(tr); }, addColumnBefore: () => prosemirrorTables.addColumnBefore, addColumnAfter: () => prosemirrorTables.addColumnAfter, deleteColumn: () => prosemirrorTables.deleteColumn, addRowBefore: () => prosemirrorTables.addRowBefore, addRowAfter: () => prosemirrorTables.addRowAfter, deleteRow: () => prosemirrorTables.deleteRow, deleteTable: () => prosemirrorTables.deleteTable, toggleCellMerge: () => (state, dispatch) => { if (prosemirrorTables.mergeCells(state, dispatch)) { return; } prosemirrorTables.splitCell(state, dispatch); }, mergeCells: () => prosemirrorTables.mergeCells, splitCell: () => prosemirrorTables.splitCell, toggleHeaderColumn: () => prosemirrorTables.toggleHeaderColumn, toggleHeaderRow: () => prosemirrorTables.toggleHeaderRow, toggleHeaderCell: () => prosemirrorTables.toggleHeaderCell, setCellAttr: ({ name, value }) => prosemirrorTables.setCellAttr(name, value), fixTables: () => prosemirrorTables.fixTables }; } keys() { return { Tab: prosemirrorTables.goToNextCell(1), 'Shift-Tab': prosemirrorTables.goToNextCell(-1) }; } get plugins() { return [...(this.options.resizable ? [prosemirrorTables.columnResizing()] : []), prosemirrorTables.tableEditing()]; } } class TableHeader extends tiptap.Node { get name() { return 'table_header'; } get schema() { return TableNodes.table_header; } } class TableCell extends tiptap.Node { get name() { return 'table_cell'; } get schema() { return TableNodes.table_cell; } } class TableRow extends tiptap.Node { get name() { return 'table_row'; } get schema() { return TableNodes.table_row; } } class TodoItem extends tiptap.Node { get name() { return 'todo_item'; } get defaultOptions() { return { nested: false }; } get view() { return { props: ['node', 'updateAttrs', 'view'], methods: { onChange() { this.updateAttrs({ done: !this.node.attrs.done }); } }, template: ` <li :data-type="node.type.name" :data-done="node.attrs.done.toString()" data-drag-handle> <span class="todo-checkbox" contenteditable="false" @click="onChange"></span> <div class="todo-content" ref="content" :contenteditable="view.editable.toString()"></div> </li> ` }; } get schema() { return { attrs: { done: { default: false } }, draggable: true, content: this.options.nested ? '(paragraph|todo_list)+' : 'paragraph+', toDOM: node => { const { done } = node.attrs; return ['li', { 'data-type': this.name, 'data-done': done.toString() }, ['span', { class: 'todo-checkbox', contenteditable: 'false' }], ['div', { class: 'todo-content' }, 0]]; }, parseDOM: [{ priority: 51, tag: `[data-type="${this.name}"]`, getAttrs: dom => ({ done: dom.getAttribute('data-done') === 'true' }) }] }; } keys({ type }) { return { Enter: tiptapCommands.splitToDefaultListItem(type), Tab: this.options.nested ? tiptapCommands.sinkListItem(type) : () => {}, 'Shift-Tab': tiptapCommands.liftListItem(type) }; } } class TodoList extends tiptap.Node { get name() { return 'todo_list'; } get schema() { return { group: 'block', content: 'todo_item+', toDOM: () => ['ul', { 'data-type': this.name }, 0], parseDOM: [{ priority: 51, tag: `[data-type="${this.name}"]` }] }; } commands({ type, schema }) { return () => tiptapCommands.toggleList(type, schema.nodes.todo_item); } inputRules({ type }) { return [tiptapCommands.wrappingInputRule(/^\s*(\[ \])\s$/, type)]; } } class Bold extends tiptap.Mark { get name() { return 'bold'; } get schema() { return { parseDOM: [{ tag: 'strong' }, { tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null }, { style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }], toDOM: () => ['strong', 0] }; } keys({ type }) { return { 'Mod-b': tiptapCommands.toggleMark(type) }; } commands({ type }) { return () => tiptapCommands.toggleMark(type); } inputRules({ type }) { return [tiptapCommands.markInputRule(/(?:\*\*|__)([^*_]+)(?:\*\*|__)$/, type)]; } pasteRules({ type }) { return [tiptapCommands.markPasteRule(/(?:\*\*|__)([^*_]+)(?:\*\*|__)/g, type)]; } } class Code extends tiptap.Mark { get name() { return 'code'; } get schema() { return { excludes: '_', parseDOM: [{ tag: 'code' }], toDOM: () => ['code', 0] }; } keys({ type }) { return { 'Mod-`': tiptapCommands.toggleMark(type) }; } commands({ type }) { return () => tiptapCommands.toggleMark(type); } inputRules({ type }) { return [tiptapCommands.markInputRule(/(?:`)([^`]+)(?:`)$/, type)]; } pasteRules({ type }) { return [tiptapCommands.markPasteRule(/(?:`)([^`]+)(?:`)/g, type)]; } } class Italic extends tiptap.Mark { get name() { return 'italic'; } get schema() { return { parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], toDOM: () => ['em', 0] }; } keys({ type }) { return { 'Mod-i': tiptapCommands.toggleMark(type) }; } commands({ type }) { return () => tiptapCommands.toggleMark(type); } inputRules({ type }) { return [tiptapCommands.markInputRule(/(?:^|[^_])(_([^_]+)_)$/, type), tiptapCommands.markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, type)]; } pasteRules({ type }) { return [tiptapCommands.markPasteRule(/_([^_]+)_/g, type), tiptapCommands.markPasteRule(/\*([^*]+)\*/g, type)]; } } class Link extends tiptap.Mark { get name() { return 'link'; } get defaultOptions() { return { openOnClick: true, target: null }; } get schema() { return { attrs: { href: { default: null }, target: { default: null } }, inclusive: false, parseDOM: [{ tag: 'a[href]', getAttrs: dom => ({ href: dom.getAttribute('href'), target: dom.getAttribute('target') }) }], toDOM: node => ['a', { ...node.attrs, rel: 'noopener noreferrer nofollow', target: node.attrs.target || this.options.target }, 0] }; } commands({ type }) { return attrs => { if (attrs.href) { return tiptapCommands.updateMark(type, attrs); } return tiptapCommands.removeMark(type); }; } pasteRules({ type }) { return [tiptapCommands.pasteRule(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b([-a-zA-Z0-9@:%_+.~#?&//=,()!]*)/gi, type, url => ({ href: url }))]; } get plugins() { if (!this.options.openOnClick) { return []; } return [new tiptap.Plugin({ props: { handleClick: (view, pos, event) => { const { schema } = view.state; const attrs = tiptapUtils.getMarkAttrs(view.state, schema.marks.link); if (attrs.href && event.target instanceof HTMLAnchorElement) { event.stopPropagation(); window.open(attrs.href, attrs.target); } } } })]; } } class Strike extends tiptap.Mark { get name() { return 'strike'; } get schema() { return { parseDOM: [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration', getAttrs: value => value === 'line-through' }], toDOM: () => ['s', 0] }; } keys({ type }) { return { 'Mod-d': tiptapCommands.toggleMark(type) }; } commands({ type }) { return () => tiptapCommands.toggleMark(type); } inputRules({ type }) { return [tiptapCommands.markInputRule(/~([^~]+)~$/, type)]; } pasteRules({ type }) { return [tiptapCommands.markPasteRule(/~([^~]+)~/g, type)]; } } class Underline extends tiptap.Mark { get name() { return 'underline'; } get schema() { return { parseDOM: [{ tag: 'u' }, { style: 'text-decoration', getAttrs: value => value === 'underline' }], toDOM: () => ['u', 0] }; } keys({ type }) { return { 'Mod-u': tiptapCommands.toggleMark(type) }; } commands({ type }) { return () => tiptapCommands.toggleMark(type); } } class Collaboration extends tiptap.Extension { get name() { return 'collaboration'; } init() { this.getSendableSteps = this.debounce(state => { const sendable = prosemirrorCollab.sendableSteps(state); if (sendable) { this.options.onSendable({ editor: this.editor, sendable: { version: sendable.version, steps: sendable.steps.map(step => step.toJSON()), clientID: sendable.clientID } }); } }, this.options.debounce); this.editor.on('transaction', ({ state }) => { this.getSendableSteps(state); }); } get defaultOptions() { return { version: 0, clientID: Math.floor(Math.random() * 0xFFFFFFFF), debounce: 250, onSendable: () => {}, update: ({ steps, version }) => { const { state, view, schema } = this.editor; if (prosemirrorCollab.getVersion(state) > version) { return; } view.dispatch(prosemirrorCollab.receiveTransaction(state, steps.map(item => prosemirrorTransform.Step.fromJSON(schema, item.step)), steps.map(item => item.clientID))); } }; } get plugins() { return [prosemirrorCollab.collab({ version: this.options.version, clientID: this.options.clientID })]; } debounce(fn, delay) { let timeout; return function (...args) { if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fn(...args); timeout = null; }, delay); }; } } class Focus extends tiptap.Extension { get name() { return 'focus'; } get defaultOptions() { return { className: 'has-focus', nested: false }; } get plugins() { return [new tiptap.Plugin({ props: { decorations: ({ doc, plugins, selection }) => { const editablePlugin = plugins.find(plugin => plugin.key.startsWith('editable$')); const editable = editablePlugin.props.editable(); const active = editable && this.options.className; const { focused } = this.editor; const { anchor } = selection; const decorations = []; if (!active || !focused) { return false; } doc.descendants((node, pos) => { const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; if (hasAnchor && !node.isText) { const decoration = prosemirrorView.Decoration.node(pos, pos + node.nodeSize, { class: this.options.className }); decorations.push(decoration); } return this.options.nested; }); return prosemirrorView.DecorationSet.create(doc, decorations); } } })]; } } class History extends tiptap.Extension { get name() { return 'history'; } get defaultOptions() { return { depth: '', newGroupDelay: '' }; } keys() { const keymap = { 'Mod-z': prosemirrorHistory.undo, 'Mod-y': prosemirrorHistory.redo, 'Shift-Mod-z': prosemirrorHistory.redo, // Russian language 'Mod-я': prosemirrorHistory.undo, 'Shift-Mod-я': prosemirrorHistory.redo }; return keymap; } get plugins() { return [prosemirrorHistory.history({ depth: this.options.depth, newGroupDelay: this.options.newGroupDelay })]; } commands() { return { undo: () => prosemirrorHistory.undo, redo: () => prosemirrorHistory.redo, undoDepth: () => prosemirrorHistory.undoDepth, redoDepth: () => prosemirrorHistory.redoDepth }; } } class Placeholder extends tiptap.Extension { get name() { return 'placeholder'; } get defaultOptions() { return { emptyEditorClass: 'is-editor-empty', emptyNodeClass: 'is-empty', emptyNodeText: 'Write something …', showOnlyWhenEditable: true, showOnlyCurrent: true }; } get plugins() { return [new tiptap.Plugin({ props: { decorations: ({ doc, plugins, selection }) => { const editablePlugin = plugins.find(plugin => plugin.key.startsWith('editable$')); const editable = editablePlugin.props.editable(); const active = editable || !this.options.showOnlyWhenEditable; const { anchor } = selection; const decorations = []; const isEditorEmpty = doc.textContent.length === 0; if (!active) { return false; } doc.descendants((node, pos) => { const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; const isNodeEmpty = node.content.size === 0; if ((hasAnchor || !this.options.showOnlyCurrent) && isNodeEmpty) { const classes = [this.options.emptyNodeClass]; if (isEditorEmpty) { classes.push(this.options.emptyEditorClass); } const decoration = prosemirrorView.Decoration.node(pos, pos + node.nodeSize, { class: classes.join(' '), 'data-empty-text': typeof this.options.emptyNodeText === 'function' ? this.options.emptyNodeText(node) : this.options.emptyNodeText }); decorations.push(decoration); } return false; }); return prosemirrorView.DecorationSet.create(doc, decorations); } } })]; } } class Search extends tiptap.Extension { constructor(options = {}) { super(options); this.results = []; this.searchTerm = null; this._updating = false; } get name() { return 'search'; } get defaultOptions() { return { autoSelectNext: true, findClass: 'find', searching: false, caseSensitive: false, disableRegex: true, alwaysSearch: false }; } commands() { return { find: attrs => this.find(attrs), replace: attrs => this.replace(attrs), replaceAll: attrs => this.replaceAll(attrs), clearSearch: () => this.clear() }; } get findRegExp() { return RegExp(this.searchTerm, !this.options.caseSensitive ? 'gui' : 'gu'); } get decorations() { return this.results.map(deco => prosemirrorView.Decoration.inline(deco.from, deco.to, { class: this.options.findClass })); } _search(doc) { this.results = []; const mergedTextNodes = []; let index = 0; if (!this.searchTerm) { return; } doc.descendants((node, pos) => { if (node.isText) { if (mergedTextNodes[index]) { mergedTextNodes[index] = { text: mergedTextNodes[index].text + node.text, pos: mergedTextNodes[index].pos }; } else { mergedTextNodes[index] = { text: node.text, pos }; } } else { index += 1; } }); mergedTextNodes.forEach(({ text, pos }) => { const search = this.findRegExp; let m; // eslint-disable-next-line no-cond-assign while (m = search.exec(text)) { if (m[0] === '') { break; } this.results.push({ from: pos + m.index, to: pos + m.index + m[0].length }); } }); } replace(replace) { return (state, dispatch) => { const firstResult = this.results[0]; if (!firstResult) { return; } const { from, to } = this.results[0]; dispatch(state.tr.insertText(replace, from, to)); this.editor.commands.find(this.searchTerm); }; } rebaseNextResult(replace, index, lastOffset = 0) { const nextIndex = index + 1; if (!this.results[nextIndex]) { return null; } const { from: currentFrom, to: currentTo } = this.results[index]; const offset = currentTo - currentFrom - replace.length + lastOffset; const { from, to } = this.results[nextIndex]; this.results[nextIndex] = { to: to - offset, from: from - offset }; return offset; } replaceAll(replace) { return ({ tr }, dispatch) => { let offset; if (!this.results.length) { return; } this.results.forEach(({ from, to }, index) => { tr.insertText(replace, from, to); offset = this.rebaseNextResult(replace, index, offset); }); dispatch(tr); this.editor.commands.find(this.searchTerm); }; } find(searchTerm) { return (state, dispatch) => { this.searchTerm = this.options.disableRegex ? searchTerm.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') : searchTerm; this.updateView(state, dispatch); }; } clear() { return (state, dispatch) => { this.searchTerm = null; this.updateView(state, dispatch); }; } updateView({ tr }, dispatch) { this._updating = true; dispatch(tr); this._updating = false; } createDeco(doc) { this._search(doc); return this.decorations ? prosemirrorView.DecorationSet.create(doc, this.decorations) : []; } get plugins() { return [new tiptap.Plugin({ state: { init() { return prosemirrorView.DecorationSet.empty; }, apply: (tr, old) => { if (this._updating || this.options.searching || tr.docChanged && this.options.alwaysSearch) { return this.createDeco(tr.doc); } if (tr.docChanged) { return old.map(tr.mapping, tr.doc); } return old; } }, props: { decorations(state) { return this.getState(state); } } })]; } } class TrailingNode extends tiptap.Extension { get name() { return 'trailing_node'; } get defaultOptions() { return { node: 'paragraph', notAfter: ['paragraph'] }; } get plugins() { const plugin = new tiptap.PluginKey(this.name); const disabledNodes = Object.entries(this.editor.schema.nodes).map(([, value]) => value).filter(node => this.options.notAfter.includes(node.name)); return [new tiptap.Plugin({ key: plugin, view: () => ({ update: view => { const { state } = view; const insertNodeAtEnd = plugin.getState(state); if (!insertNodeAtEnd) { return; } const { doc, schema, tr } = state; const type = schema.nodes[this.options.node]; const transaction = tr.insert(doc.content.size, type.create()); view.dispatch(transaction); } }), state: { init: (_, state) => { const lastNode = state.tr.doc.lastChild; return !tiptapUtils.nodeEqualsType({ node: lastNode, types: disabledNodes }); }, apply: (tr, value) => { if (!tr.docChanged) { return value; } const lastNode = tr.doc.lastChild; return !tiptapUtils.nodeEqualsType({ node: lastNode, types: disabledNodes }); } } })]; } } exports.Blockquote = Blockquote; exports.Bold = Bold; exports.BulletList = BulletList; exports.Code = Code; exports.CodeBlock = CodeBlock; exports.CodeBlockHighlight = CodeBlockHighlight; exports.Collaboration = Collaboration; exports.Focus = Focus; exports.HardBreak = HardBreak; exports.Heading = Heading; exports.Highlight = HighlightPlugin; exports.History = History; exports.HorizontalRule = HorizontalRule; exports.Image = Image; exports.Italic = Italic; exports.Link = Link; exports.ListItem = ListItem; exports.Mention = Mention; exports.OrderedList = OrderedList; exports.Placeholder = Placeholder; exports.Search = Search; exports.Strike = Strike; exports.Suggestions = SuggestionsPlugin; exports.Table = Table; exports.TableCell = TableCell; exports.TableHeader = TableHeader; exports.TableRow = TableRow; exports.TodoItem = TodoItem; exports.TodoList = TodoList; exports.TrailingNode = TrailingNode; exports.Underline = Underline;