From 723b955cecc5c92c8aad897ce16c60fb62976571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20K=C3=BChn?= Date: Fri, 8 Oct 2021 15:02:09 +0200 Subject: [PATCH] feat: Integrate input rules and paste rules into the core (#1997) * refactoring * improve link regex * WIP: add new markPasteRule und linkify to image mark * move copy of inputrule to core * trigger codeblock inputrule on enter * refactoring * add regex match to markpasterulematch * refactoring * improve link regex * WIP: add new markPasteRule und linkify to image mark * move copy of inputrule to core * trigger codeblock inputrule on enter * refactoring * add regex match to markpasterulematch * update linkify * wip * wip * log * wip * remove debug code * wip * wip * wip * wip * wip * wip * wip * wip * rename matcher * add data to ExtendedRegExpMatchArray * remove logging * add code option to marks, prevent inputrules in code mark * remove link regex * fix codeblock inputrule on enter * refactoring * refactoring * refactoring * refactoring * fix position bug * add test * export InputRule and PasteRule * clean up link demo * fix types --- demos/includeDependencies.txt | 1 - demos/src/Marks/Link/Vue/index.vue | 4 +- demos/src/Nodes/CodeBlock/Vue/index.spec.js | 11 + packages/core/package.json | 2 - packages/core/src/CommandManager.ts | 40 +-- packages/core/src/Editor.ts | 3 +- packages/core/src/Extension.ts | 5 +- packages/core/src/ExtensionManager.ts | 27 +- packages/core/src/InputRule.ts | 245 ++++++++++++++++++ packages/core/src/Mark.ts | 14 +- packages/core/src/Node.ts | 5 +- packages/core/src/PasteRule.ts | 177 +++++++++++++ packages/core/src/commands/undoInputRule.ts | 32 ++- .../core/src/helpers/createChainableState.ts | 37 +++ .../helpers/getSchemaByResolvedExtensions.ts | 9 +- packages/core/src/index.ts | 6 + packages/core/src/inputRules/markInputRule.ts | 96 ++++--- packages/core/src/inputRules/nodeInputRule.ts | 65 +++-- packages/core/src/inputRules/textInputRule.ts | 35 +++ .../src/inputRules/textblockTypeInputRule.ts | 37 +++ .../core/src/inputRules/wrappingInputRule.ts | 59 +++++ packages/core/src/pasteRules/markPasteRule.ts | 102 ++++---- packages/core/src/pasteRules/textPasteRule.ts | 35 +++ packages/core/src/types.ts | 4 + packages/core/src/utilities/callOrReturn.ts | 3 +- packages/core/src/utilities/isClass.ts | 4 +- packages/core/src/utilities/isEmptyObject.ts | 4 +- packages/core/src/utilities/isFunction.ts | 3 + packages/core/src/utilities/isObject.ts | 10 +- packages/core/src/utilities/isPlainObject.ts | 10 +- packages/core/src/utilities/isRegExp.ts | 2 +- packages/core/src/utilities/isString.ts | 3 + packages/extension-blockquote/package.json | 3 - .../extension-blockquote/src/blockquote.ts | 8 +- packages/extension-bold/src/bold.ts | 20 +- packages/extension-bullet-list/package.json | 3 - .../extension-bullet-list/src/bullet-list.ts | 8 +- packages/extension-code-block/package.json | 3 - .../extension-code-block/src/code-block.ts | 19 +- packages/extension-code/src/code.ts | 12 +- packages/extension-heading/package.json | 3 - packages/extension-heading/src/heading.ts | 11 +- packages/extension-highlight/src/highlight.ts | 10 +- .../src/horizontal-rule.ts | 5 +- packages/extension-image/src/image.ts | 10 +- packages/extension-italic/src/italic.ts | 20 +- packages/extension-link/package.json | 1 + packages/extension-link/src/link.ts | 32 ++- packages/extension-ordered-list/package.json | 3 - .../src/ordered-list.ts | 15 +- packages/extension-strike/src/strike.ts | 10 +- packages/extension-task-item/package.json | 3 - packages/extension-task-item/src/task-item.ts | 13 +- packages/extension-typography/package.json | 3 - .../extension-typography/src/typography.ts | 132 ++++++++-- .../integration/extensions/link.spec.ts | 51 ---- yarn.lock | 21 +- 57 files changed, 1138 insertions(+), 371 deletions(-) create mode 100644 packages/core/src/InputRule.ts create mode 100644 packages/core/src/PasteRule.ts create mode 100644 packages/core/src/helpers/createChainableState.ts create mode 100644 packages/core/src/inputRules/textInputRule.ts create mode 100644 packages/core/src/inputRules/textblockTypeInputRule.ts create mode 100644 packages/core/src/inputRules/wrappingInputRule.ts create mode 100644 packages/core/src/pasteRules/textPasteRule.ts create mode 100644 packages/core/src/utilities/isFunction.ts create mode 100644 packages/core/src/utilities/isString.ts delete mode 100644 tests/cypress/integration/extensions/link.spec.ts diff --git a/demos/includeDependencies.txt b/demos/includeDependencies.txt index ec9d28a934..342c8ed59d 100644 --- a/demos/includeDependencies.txt +++ b/demos/includeDependencies.txt @@ -5,7 +5,6 @@ prosemirror-commands prosemirror-dropcursor prosemirror-gapcursor prosemirror-history -prosemirror-inputrules prosemirror-keymap prosemirror-model prosemirror-schema-list diff --git a/demos/src/Marks/Link/Vue/index.vue b/demos/src/Marks/Link/Vue/index.vue index e604e00629..a9a5fffd26 100644 --- a/demos/src/Marks/Link/Vue/index.vue +++ b/demos/src/Marks/Link/Vue/index.vue @@ -16,7 +16,6 @@ import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' import Link from '@tiptap/extension-link' -import Bold from '@tiptap/extension-bold' export default { components: { @@ -35,14 +34,13 @@ export default { Document, Paragraph, Text, - Bold, Link.configure({ openOnClick: false, }), ], content: `

- Wow, this editor has support for links to the whole world wide web. We tested a lot of URLs and I think you can add *every URL* you want. Isn’t that cool? Let’s try another one! Yep, seems to work. + Wow, this editor has support for links to the whole world wide web. We tested a lot of URLs and I think you can add *every URL* you want. Isn’t that cool? Let’s try another one! Yep, seems to work.

By default every link will get a \`rel="noopener noreferrer nofollow"\` attribute. It’s configurable though. diff --git a/demos/src/Nodes/CodeBlock/Vue/index.spec.js b/demos/src/Nodes/CodeBlock/Vue/index.spec.js index 9f202ff7bd..0c626edd94 100644 --- a/demos/src/Nodes/CodeBlock/Vue/index.spec.js +++ b/demos/src/Nodes/CodeBlock/Vue/index.spec.js @@ -126,6 +126,17 @@ context('/src/Nodes/CodeBlock/Vue/', () => { }) }) + it('should make a code block from backtick markdown shortcuts followed by enter', () => { + cy.get('.ProseMirror').then(([{ editor }]) => { + editor.commands.clearContent() + + cy.get('.ProseMirror') + .type('```{enter}Code') + .find('pre>code') + .should('contain', 'Code') + }) + }) + it('reverts the markdown shortcut when pressing backspace', () => { cy.get('.ProseMirror').then(([{ editor }]) => { editor.commands.clearContent() diff --git a/packages/core/package.json b/packages/core/package.json index f355098143..b75e397c11 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,6 @@ ], "dependencies": { "@types/prosemirror-commands": "^1.0.4", - "@types/prosemirror-inputrules": "^1.0.4", "@types/prosemirror-keymap": "^1.0.4", "@types/prosemirror-model": "^1.13.2", "@types/prosemirror-schema-list": "^1.0.3", @@ -33,7 +32,6 @@ "@types/prosemirror-transform": "^1.1.4", "@types/prosemirror-view": "^1.19.1", "prosemirror-commands": "^1.1.11", - "prosemirror-inputrules": "^1.1.3", "prosemirror-keymap": "^1.1.3", "prosemirror-model": "^1.14.3", "prosemirror-schema-list": "^1.1.6", diff --git a/packages/core/src/CommandManager.ts b/packages/core/src/CommandManager.ts index 6a19e7a830..cff6c00a87 100644 --- a/packages/core/src/CommandManager.ts +++ b/packages/core/src/CommandManager.ts @@ -1,5 +1,6 @@ -import { EditorState, Transaction } from 'prosemirror-state' +import { Transaction } from 'prosemirror-state' import { Editor } from './Editor' +import createChainableState from './helpers/createChainableState' import { SingleCommands, ChainedCommands, @@ -106,7 +107,10 @@ export default class CommandManager { tr, editor, view, - state: this.chainableState(tr, state), + state: createChainableState({ + state, + transaction: tr, + }), dispatch: shouldDispatch ? () => undefined : undefined, @@ -124,36 +128,4 @@ export default class CommandManager { return props } - public chainableState(tr: Transaction, state: EditorState): EditorState { - let { selection } = tr - let { doc } = tr - let { storedMarks } = tr - - return { - ...state, - schema: state.schema, - plugins: state.plugins, - apply: state.apply.bind(state), - applyTransaction: state.applyTransaction.bind(state), - 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 = tr.selection - doc = tr.doc - storedMarks = tr.storedMarks - - return tr - }, - } - } - } diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index dfa947dce2..1af4279514 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -15,6 +15,7 @@ import getText from './helpers/getText' import isNodeEmpty from './helpers/isNodeEmpty' import getTextSeralizersFromSchema from './helpers/getTextSeralizersFromSchema' import createStyleTag from './utilities/createStyleTag' +import isFunction from './utilities/isFunction' import CommandManager from './CommandManager' import ExtensionManager from './ExtensionManager' import EventEmitter from './EventEmitter' @@ -184,7 +185,7 @@ export class Editor extends EventEmitter { * @param handlePlugins Control how to merge the plugin into the existing plugins. */ public registerPlugin(plugin: Plugin, handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[]): void { - const plugins = typeof handlePlugins === 'function' + const plugins = isFunction(handlePlugins) ? handlePlugins(plugin, this.state.plugins) : [...this.state.plugins, plugin] diff --git a/packages/core/src/Extension.ts b/packages/core/src/Extension.ts index e30fe36c66..55ce6f21bc 100644 --- a/packages/core/src/Extension.ts +++ b/packages/core/src/Extension.ts @@ -1,5 +1,6 @@ import { Plugin, Transaction } from 'prosemirror-state' -import { InputRule } from 'prosemirror-inputrules' +import { InputRule } from './InputRule' +import { PasteRule } from './PasteRule' import { Editor } from './Editor' import { Node } from './Node' import { Mark } from './Mark' @@ -81,7 +82,7 @@ declare module '@tiptap/core' { options: Options, editor: Editor, parent: ParentConfig>['addPasteRules'], - }) => Plugin[], + }) => PasteRule[], /** * ProseMirror plugins diff --git a/packages/core/src/ExtensionManager.ts b/packages/core/src/ExtensionManager.ts index 53d861cf25..66c20c4e85 100644 --- a/packages/core/src/ExtensionManager.ts +++ b/packages/core/src/ExtensionManager.ts @@ -1,6 +1,7 @@ import { keymap } from 'prosemirror-keymap' import { Schema, Node as ProsemirrorNode } from 'prosemirror-model' -import { inputRules as inputRulesPlugin } from 'prosemirror-inputrules' +import { inputRulesPlugin } from './InputRule' +import { pasteRulesPlugin } from './PasteRule' import { EditorView, Decoration } from 'prosemirror-view' import { Plugin } from 'prosemirror-state' import { Editor } from './Editor' @@ -210,7 +211,12 @@ export default class ExtensionManager { // 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. - return ExtensionManager.sort([...this.extensions].reverse()) + const extensions = ExtensionManager.sort([...this.extensions].reverse()) + + const inputRules: any[] = [] + const pasteRules: any[] = [] + + const allPlugins = extensions .map(extension => { const context = { name: extension.name, @@ -248,12 +254,7 @@ export default class ExtensionManager { ) if (this.editor.options.enableInputRules && addInputRules) { - const inputRules = addInputRules() - const inputRulePlugins = inputRules.length - ? [inputRulesPlugin({ rules: inputRules })] - : [] - - plugins.push(...inputRulePlugins) + inputRules.push(...addInputRules()) } const addPasteRules = getExtensionField( @@ -263,9 +264,7 @@ export default class ExtensionManager { ) if (this.editor.options.enablePasteRules && addPasteRules) { - const pasteRulePlugins = addPasteRules() - - plugins.push(...pasteRulePlugins) + pasteRules.push(...addPasteRules()) } const addProseMirrorPlugins = getExtensionField( @@ -283,6 +282,12 @@ export default class ExtensionManager { return plugins }) .flat() + + return [ + inputRulesPlugin(inputRules), + pasteRulesPlugin(pasteRules), + ...allPlugins, + ] } get attributes() { diff --git a/packages/core/src/InputRule.ts b/packages/core/src/InputRule.ts new file mode 100644 index 0000000000..0f6933e093 --- /dev/null +++ b/packages/core/src/InputRule.ts @@ -0,0 +1,245 @@ +import { EditorView } from 'prosemirror-view' +import { EditorState, Plugin, TextSelection } from 'prosemirror-state' +import createChainableState from './helpers/createChainableState' +import isRegExp from './utilities/isRegExp' +import { Range, ExtendedRegExpMatchArray } from './types' + +export type InputRuleMatch = { + index: number, + text: string, + replaceWith?: string, + match?: RegExpMatchArray, + data?: Record, +} + +export type InputRuleFinder = + | RegExp + | ((text: string) => InputRuleMatch | null) + +export class InputRule { + find: InputRuleFinder + + handler: (props: { + state: EditorState, + range: Range, + match: ExtendedRegExpMatchArray, + }) => void + + constructor(config: { + find: InputRuleFinder, + handler: (props: { + state: EditorState, + range: Range, + match: ExtendedRegExpMatchArray, + }) => void, + }) { + this.find = config.find + this.handler = config.handler + } +} + +const inputRuleMatcherHandler = (text: string, find: InputRuleFinder): ExtendedRegExpMatchArray | null => { + if (isRegExp(find)) { + return find.exec(text) + } + + const inputRuleMatch = find(text) + + if (!inputRuleMatch) { + return null + } + + const result: ExtendedRegExpMatchArray = [] + + result.push(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(config: { + view: EditorView, + from: number, + to: number, + text: string, + rules: InputRule[], + plugin: Plugin, +}): any { + const { + view, + from, + to, + text, + rules, + plugin, + } = config + + 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 + || !!($from.nodeBefore || $from.nodeAfter)?.marks.find(mark => mark.type.spec.code) + ) { + return false + } + + let matched = false + const maxMatch = 500 + const textBefore = $from.parent.textBetween( + Math.max(0, $from.parentOffset - maxMatch), + $from.parentOffset, + undefined, + '\ufffc', + ) + 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, + } + + rule.handler({ + state, + range, + match, + }) + + // stop if there are no changes + if (!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. + */ +export function inputRulesPlugin(rules: InputRule[]): Plugin { + const plugin = new Plugin({ + state: { + init() { + return null + }, + apply(tr, prev) { + const stored = tr.getMeta(this) + + if (stored) { + return stored + } + + return tr.selectionSet || tr.docChanged + ? null + : prev + }, + }, + + props: { + handleTextInput(view, from, to, text) { + return run({ + view, + from, + to, + text, + rules, + plugin, + }) + }, + + handleDOMEvents: { + compositionend: view => { + setTimeout(() => { + const { $cursor } = view.state.selection as TextSelection + + if ($cursor) { + run({ + view, + 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 as TextSelection + + if ($cursor) { + return run({ + view, + from: $cursor.pos, + to: $cursor.pos, + text: '\n', + rules, + plugin, + }) + } + + return false + }, + }, + + // @ts-ignore + isInputRules: true, + }) as Plugin + + return plugin +} diff --git a/packages/core/src/Mark.ts b/packages/core/src/Mark.ts index 02dc59b949..8c8a2cbf7f 100644 --- a/packages/core/src/Mark.ts +++ b/packages/core/src/Mark.ts @@ -5,7 +5,8 @@ import { MarkType, } from 'prosemirror-model' import { Plugin, Transaction } from 'prosemirror-state' -import { InputRule } from 'prosemirror-inputrules' +import { InputRule } from './InputRule' +import { PasteRule } from './PasteRule' import mergeDeep from './utilities/mergeDeep' import { Extensions, @@ -91,7 +92,7 @@ declare module '@tiptap/core' { editor: Editor, type: MarkType, parent: ParentConfig>['addPasteRules'], - }) => Plugin[], + }) => PasteRule[], /** * ProseMirror plugins @@ -281,6 +282,15 @@ declare module '@tiptap/core' { parent: ParentConfig>['spanning'], }) => MarkSpec['spanning']), + /** + * Code + */ + code?: boolean | ((this: { + name: string, + options: Options, + parent: ParentConfig>['code'], + }) => boolean), + /** * Parse HTML */ diff --git a/packages/core/src/Node.ts b/packages/core/src/Node.ts index ce90159210..ec0cfd8aaf 100644 --- a/packages/core/src/Node.ts +++ b/packages/core/src/Node.ts @@ -5,7 +5,8 @@ import { NodeType, } from 'prosemirror-model' import { Plugin, Transaction } from 'prosemirror-state' -import { InputRule } from 'prosemirror-inputrules' +import { InputRule } from './InputRule' +import { PasteRule } from './PasteRule' import mergeDeep from './utilities/mergeDeep' import { Extensions, @@ -91,7 +92,7 @@ declare module '@tiptap/core' { editor: Editor, type: NodeType, parent: ParentConfig>['addPasteRules'], - }) => Plugin[], + }) => PasteRule[], /** * ProseMirror plugins diff --git a/packages/core/src/PasteRule.ts b/packages/core/src/PasteRule.ts new file mode 100644 index 0000000000..13e16b67de --- /dev/null +++ b/packages/core/src/PasteRule.ts @@ -0,0 +1,177 @@ +import { EditorState, Plugin } from 'prosemirror-state' +import createChainableState from './helpers/createChainableState' +import isRegExp from './utilities/isRegExp' +import { Range, ExtendedRegExpMatchArray } from './types' + +export type PasteRuleMatch = { + index: number, + text: string, + replaceWith?: string, + match?: RegExpMatchArray, + data?: Record, +} + +export type PasteRuleFinder = + | RegExp + | ((text: string) => PasteRuleMatch[] | null | undefined) + +export class PasteRule { + find: PasteRuleFinder + + handler: (props: { + state: EditorState, + range: Range, + match: ExtendedRegExpMatchArray, + }) => void + + constructor(config: { + find: PasteRuleFinder, + handler: (props: { + state: EditorState, + range: Range, + match: ExtendedRegExpMatchArray, + }) => void, + }) { + this.find = config.find + this.handler = config.handler + } +} + +const pasteRuleMatcherHandler = (text: string, find: PasteRuleFinder): ExtendedRegExpMatchArray[] => { + if (isRegExp(find)) { + return [...text.matchAll(find)] + } + + const matches = find(text) + + if (!matches) { + return [] + } + + return matches.map(pasteRuleMatch => { + const result: ExtendedRegExpMatchArray = [] + + result.push(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: { + state: EditorState, + from: number, + to: number, + rules: PasteRule[], + plugin: Plugin, +}): any { + const { + state, + from, + to, + rules, + } = config + + 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', + ) + + rules.forEach(rule => { + const matches = pasteRuleMatcherHandler(textToMatch, rule.find) + + matches.forEach(match => { + if (match.index === undefined) { + return + } + + const start = resolvedFrom + match.index + const end = start + match[0].length + const range = { + from: state.tr.mapping.map(start), + to: state.tr.mapping.map(end), + } + + rule.handler({ + state, + range, + match, + }) + }) + }) + }, from) +} + +/** + * 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. + */ +export function pasteRulesPlugin(rules: PasteRule[]): Plugin { + const plugin = new Plugin({ + appendTransaction: (transactions, oldState, state) => { + const transaction = transactions[0] + + // stop if there is not a paste event + if (!transaction.getMeta('paste')) { + return + } + + // stop if there is no changed range + const { doc, before } = transaction + const from = before.content.findDiffStart(doc.content) + const to = before.content.findDiffEnd(doc.content) + + if (!from || !to) { + return + } + + // build a chainable state + // so we can use a single transaction for all paste rules + const tr = state.tr + const chainableState = createChainableState({ + state, + transaction: tr, + }) + + run({ + state: chainableState, + from, + to: to.b, + rules, + plugin, + }) + + // stop if there are no changes + if (!tr.steps.length) { + return + } + + return tr + }, + + // @ts-ignore + isPasteRules: true, + }) + + return plugin +} diff --git a/packages/core/src/commands/undoInputRule.ts b/packages/core/src/commands/undoInputRule.ts index f7a7f602a9..6d9ca42688 100644 --- a/packages/core/src/commands/undoInputRule.ts +++ b/packages/core/src/commands/undoInputRule.ts @@ -1,4 +1,3 @@ -import { undoInputRule as originalUndoInputRule } from 'prosemirror-inputrules' import { RawCommands } from '../types' declare module '@tiptap/core' { @@ -13,5 +12,34 @@ declare module '@tiptap/core' { } export const undoInputRule: RawCommands['undoInputRule'] = () => ({ state, dispatch }) => { - return originalUndoInputRule(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 } diff --git a/packages/core/src/helpers/createChainableState.ts b/packages/core/src/helpers/createChainableState.ts new file mode 100644 index 0000000000..7e6a668c60 --- /dev/null +++ b/packages/core/src/helpers/createChainableState.ts @@ -0,0 +1,37 @@ +import { EditorState, Transaction } from 'prosemirror-state' + +export default function createChainableState(config: { + transaction: Transaction, + state: EditorState, +}): EditorState { + const { state, transaction } = config + let { selection } = transaction + let { doc } = transaction + let { storedMarks } = transaction + + return { + ...state, + schema: state.schema, + plugins: state.plugins, + apply: state.apply.bind(state), + applyTransaction: state.applyTransaction.bind(state), + 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 + }, + } +} diff --git a/packages/core/src/helpers/getSchemaByResolvedExtensions.ts b/packages/core/src/helpers/getSchemaByResolvedExtensions.ts index fb1d9f21ba..2ef0063964 100644 --- a/packages/core/src/helpers/getSchemaByResolvedExtensions.ts +++ b/packages/core/src/helpers/getSchemaByResolvedExtensions.ts @@ -108,10 +108,11 @@ export default function getSchemaByResolvedExtensions(extensions: Extensions): S const schema: MarkSpec = 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)), + 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 => { return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }] })), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbb4f12fe7..424a28b294 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,11 +7,17 @@ export * from './Node' export * from './Mark' export * from './NodeView' export * from './Tracker' +export * from './InputRule' +export * from './PasteRule' export * from './types' export { default as nodeInputRule } from './inputRules/nodeInputRule' export { default as markInputRule } from './inputRules/markInputRule' +export { default as textblockTypeInputRule } from './inputRules/textblockTypeInputRule' +export { default as textInputRule } from './inputRules/textInputRule' +export { default as wrappingInputRule } from './inputRules/wrappingInputRule' export { default as markPasteRule } from './pasteRules/markPasteRule' +export { default as textPasteRule } from './pasteRules/textPasteRule' export { default as callOrReturn } from './utilities/callOrReturn' export { default as mergeAttributes } from './utilities/mergeAttributes' diff --git a/packages/core/src/inputRules/markInputRule.ts b/packages/core/src/inputRules/markInputRule.ts index 516951caff..9e5c8e5aa6 100644 --- a/packages/core/src/inputRules/markInputRule.ts +++ b/packages/core/src/inputRules/markInputRule.ts @@ -1,50 +1,70 @@ -import { InputRule } from 'prosemirror-inputrules' +import { InputRule, InputRuleFinder } from '../InputRule' import { MarkType } from 'prosemirror-model' import getMarksBetween from '../helpers/getMarksBetween' +import callOrReturn from '../utilities/callOrReturn' +import { ExtendedRegExpMatchArray } from '../types' -export default function (regexp: RegExp, markType: MarkType, getAttributes?: Function): InputRule { - return new InputRule(regexp, (state, match, start, end) => { - const attributes = getAttributes instanceof Function - ? getAttributes(match) - : getAttributes - const { tr } = state - const captureGroup = match[match.length - 1] - const fullMatch = match[0] - let markEnd = end - - if (captureGroup) { - const startSpaces = fullMatch.search(/\S/) - const textStart = start + fullMatch.indexOf(captureGroup) - const textEnd = textStart + captureGroup.length - - const excludedMarks = getMarksBetween(start, end, state) - .filter(item => { - // TODO: PR to add excluded to MarkType - // @ts-ignore - const { excluded } = item.mark.type - return excluded.find((type: MarkType) => type.name === markType.name) - }) - .filter(item => item.to > textStart) - - if (excludedMarks.length) { - return null - } +/** + * Build an input rule that adds a mark when the + * matched text is typed into it. + */ +export default function markInputRule(config: { + find: InputRuleFinder, + type: MarkType, + getAttributes?: + | Record + | ((match: ExtendedRegExpMatchArray) => Record) + | false + | null + , +}) { + return new InputRule({ + find: config.find, + handler: ({ state, range, match }) => { + const attributes = callOrReturn(config.getAttributes, undefined, match) - if (textEnd < end) { - tr.delete(textEnd, end) + if (attributes === false || attributes === null) { + return } - if (textStart > start) { - tr.delete(start + startSpaces, textStart) - } + 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) + .filter(item => { + // TODO: PR to add excluded to MarkType + // @ts-ignore + const { excluded } = item.mark.type - markEnd = start + startSpaces + captureGroup.length + return excluded.find((type: MarkType) => type.name === config.type.name) + }) + .filter(item => item.to > textStart) - tr.addMark(start + startSpaces, markEnd, markType.create(attributes)) + if (excludedMarks.length) { + return null + } - tr.removeStoredMark(markType) - } + if (textEnd < range.to) { + tr.delete(textEnd, range.to) + } - return tr + 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) + } + }, }) } diff --git a/packages/core/src/inputRules/nodeInputRule.ts b/packages/core/src/inputRules/nodeInputRule.ts index 49ab3543c0..3b6b410c91 100644 --- a/packages/core/src/inputRules/nodeInputRule.ts +++ b/packages/core/src/inputRules/nodeInputRule.ts @@ -1,32 +1,49 @@ -import { InputRule } from 'prosemirror-inputrules' import { NodeType } from 'prosemirror-model' +import { InputRule, InputRuleFinder } from '../InputRule' +import { ExtendedRegExpMatchArray } from '../types' +import callOrReturn from '../utilities/callOrReturn' -export default function (regexp: RegExp, type: NodeType, getAttributes?: (match: any) => any): InputRule { - return new InputRule(regexp, (state, match, start, end) => { - const attributes = getAttributes instanceof Function - ? getAttributes(match) - : getAttributes - const { tr } = state +/** + * Build an input rule that adds a node when the + * matched text is typed into it. + */ +export default function nodeInputRule(config: { + find: InputRuleFinder, + type: NodeType, + getAttributes?: + | Record + | ((match: ExtendedRegExpMatchArray) => Record) + | false + | null + , +}) { + 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 - 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 - } + if (match[1]) { + const offset = match[0].lastIndexOf(match[1]) + let matchStart = start + offset - // insert last typed character - const lastChar = match[0][match[0].length - 1] - tr.insertText(lastChar, start + match[0].length - 1) + if (matchStart > end) { + matchStart = end + } else { + end = matchStart + match[1].length + } - // insert node from input rule - tr.replaceWith(matchStart, end, type.create(attributes)) - } else if (match[0]) { - tr.replaceWith(start, end, type.create(attributes)) - } + // insert last typed character + const lastChar = match[0][match[0].length - 1] + tr.insertText(lastChar, start + match[0].length - 1) - return tr + // insert node from input rule + tr.replaceWith(matchStart, end, config.type.create(attributes)) + } else if (match[0]) { + tr.replaceWith(start, end, config.type.create(attributes)) + } + }, }) } diff --git a/packages/core/src/inputRules/textInputRule.ts b/packages/core/src/inputRules/textInputRule.ts new file mode 100644 index 0000000000..6e21bea09a --- /dev/null +++ b/packages/core/src/inputRules/textInputRule.ts @@ -0,0 +1,35 @@ +import { InputRule, InputRuleFinder } from '../InputRule' + +/** + * Build an input rule that replaces text when the + * matched text is typed into it. + */ +export default function textInputRule(config: { + find: InputRuleFinder, + replace: string, +}) { + 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) + }, + }) +} diff --git a/packages/core/src/inputRules/textblockTypeInputRule.ts b/packages/core/src/inputRules/textblockTypeInputRule.ts new file mode 100644 index 0000000000..9f08f1894c --- /dev/null +++ b/packages/core/src/inputRules/textblockTypeInputRule.ts @@ -0,0 +1,37 @@ +import { InputRule, InputRuleFinder } from '../InputRule' +import { NodeType } from 'prosemirror-model' +import { ExtendedRegExpMatchArray } from '../types' +import callOrReturn from '../utilities/callOrReturn' + +/** + * 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. + */ +export default function textblockTypeInputRule(config: { + find: InputRuleFinder, + type: NodeType, + getAttributes?: + | Record + | ((match: ExtendedRegExpMatchArray) => Record) + | false + | null + , +}) { + 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) + }, + }) +} diff --git a/packages/core/src/inputRules/wrappingInputRule.ts b/packages/core/src/inputRules/wrappingInputRule.ts new file mode 100644 index 0000000000..79560f61c3 --- /dev/null +++ b/packages/core/src/inputRules/wrappingInputRule.ts @@ -0,0 +1,59 @@ +import { InputRule, InputRuleFinder } from '../InputRule' +import { NodeType, Node as ProseMirrorNode } from 'prosemirror-model' +import { findWrapping, canJoin } from 'prosemirror-transform' +import { ExtendedRegExpMatchArray } from '../types' +import callOrReturn from '../utilities/callOrReturn' + +/** + * 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. + */ +export default function wrappingInputRule(config: { + find: InputRuleFinder, + type: NodeType, + getAttributes?: + | Record + | ((match: ExtendedRegExpMatchArray) => Record) + | false + | null + , + joinPredicate?: (match: ExtendedRegExpMatchArray, node: ProseMirrorNode) => boolean, +}) { + return new InputRule({ + find: config.find, + handler: ({ state, range, match }) => { + 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 && findWrapping(blockRange, config.type, attributes) + + if (!wrapping) { + return null + } + + tr.wrap(blockRange, wrapping) + + const before = tr.doc.resolve(range.from - 1).nodeBefore + + if ( + before + && before.type === config.type + && canJoin(tr.doc, range.from - 1) + && (!config.joinPredicate || config.joinPredicate(match, before)) + ) { + tr.join(range.from - 1) + } + }, + }) +} diff --git a/packages/core/src/pasteRules/markPasteRule.ts b/packages/core/src/pasteRules/markPasteRule.ts index 3e11ef1e4c..5deeedd230 100644 --- a/packages/core/src/pasteRules/markPasteRule.ts +++ b/packages/core/src/pasteRules/markPasteRule.ts @@ -1,76 +1,70 @@ -import { Plugin, PluginKey } from 'prosemirror-state' -import { Slice, Fragment, MarkType } from 'prosemirror-model' +import { PasteRule, PasteRuleFinder } from '../PasteRule' +import { MarkType } from 'prosemirror-model' +import getMarksBetween from '../helpers/getMarksBetween' +import callOrReturn from '../utilities/callOrReturn' +import { ExtendedRegExpMatchArray } from '../types' -export default function ( - regexp: RegExp, +/** + * Build an paste rule that adds a mark when the + * matched text is pasted into it. + */ +export default function markPasteRule(config: { + find: PasteRuleFinder, type: MarkType, getAttributes?: | Record - | ((match: RegExpExecArray) => Record) + | ((match: ExtendedRegExpMatchArray) => Record) | false | null , -): Plugin { - const handler = (fragment: Fragment, parent?: any) => { - const nodes: any[] = [] +}) { + return new PasteRule({ + find: config.find, + handler: ({ state, range, match }) => { + const attributes = callOrReturn(config.getAttributes, undefined, match) - fragment.forEach(child => { - if (child.isText && child.text) { - const { text } = child - let pos = 0 - let match + if (attributes === false || attributes === null) { + return + } - // eslint-disable-next-line - while ((match = regexp.exec(text)) !== null) { - const outerMatch = Math.max(match.length - 2, 0) - const innerMatch = Math.max(match.length - 1, 0) + const { tr } = state + const captureGroup = match[match.length - 1] + const fullMatch = match[0] + let markEnd = range.to - if (parent?.type.allowsMarkType(type)) { - const start = match.index - const matchStart = start + match[0].indexOf(match[outerMatch]) - const matchEnd = matchStart + match[outerMatch].length - const textStart = matchStart + match[outerMatch].lastIndexOf(match[innerMatch]) - const textEnd = textStart + match[innerMatch].length - const attrs = getAttributes instanceof Function - ? getAttributes(match) - : getAttributes + if (captureGroup) { + const startSpaces = fullMatch.search(/\S/) + const textStart = range.from + fullMatch.indexOf(captureGroup) + const textEnd = textStart + captureGroup.length - if (!attrs && attrs !== undefined) { - continue - } + const excludedMarks = getMarksBetween(range.from, range.to, state) + .filter(item => { + // TODO: PR to add excluded to MarkType + // @ts-ignore + const { excluded } = item.mark.type - // adding text before markdown to nodes - if (matchStart > 0) { - nodes.push(child.cut(pos, matchStart)) - } + return excluded.find((type: MarkType) => type.name === config.type.name) + }) + .filter(item => item.to > textStart) - // adding the markdown part to nodes - nodes.push(child - .cut(textStart, textEnd) - .mark(type.create(attrs).addToSet(child.marks))) + if (excludedMarks.length) { + return null + } - pos = matchEnd - } + if (textEnd < range.to) { + tr.delete(textEnd, range.to) } - // adding rest of text to nodes - if (pos < text.length) { - nodes.push(child.cut(pos)) + if (textStart > range.from) { + tr.delete(range.from + startSpaces, textStart) } - } else { - nodes.push(child.copy(handler(child.content, child))) - } - }) - return Fragment.fromArray(nodes) - } + markEnd = range.from + startSpaces + captureGroup.length - return new Plugin({ - key: new PluginKey('markPasteRule'), - props: { - transformPasted: slice => { - return new Slice(handler(slice.content), slice.openStart, slice.openEnd) - }, + tr.addMark(range.from + startSpaces, markEnd, config.type.create(attributes || {})) + + tr.removeStoredMark(config.type) + } }, }) } diff --git a/packages/core/src/pasteRules/textPasteRule.ts b/packages/core/src/pasteRules/textPasteRule.ts new file mode 100644 index 0000000000..199baf4848 --- /dev/null +++ b/packages/core/src/pasteRules/textPasteRule.ts @@ -0,0 +1,35 @@ +import { PasteRule, PasteRuleFinder } from '../PasteRule' + +/** + * Build an paste rule that replaces text when the + * matched text is pasted into it. + */ +export default function textPasteRule(config: { + find: PasteRuleFinder, + replace: string, +}) { + 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) + }, + }) +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a0d6f1290c..deeb3a9723 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -229,3 +229,7 @@ export type TextSerializer = (props: { parent: ProseMirrorNode, index: number, }) => string + +export type ExtendedRegExpMatchArray = RegExpMatchArray & { + data?: Record, +} diff --git a/packages/core/src/utilities/callOrReturn.ts b/packages/core/src/utilities/callOrReturn.ts index a87ee60fbf..b479492744 100644 --- a/packages/core/src/utilities/callOrReturn.ts +++ b/packages/core/src/utilities/callOrReturn.ts @@ -1,4 +1,5 @@ import { MaybeReturnType } from '../types' +import isFunction from './isFunction' /** * Optionally calls `value` as a function. @@ -8,7 +9,7 @@ import { MaybeReturnType } from '../types' * @param props Optional props to pass to function. */ export default function callOrReturn(value: T, context: any = undefined, ...props: any[]): MaybeReturnType { - if (typeof value === 'function') { + if (isFunction(value)) { if (context) { return value.bind(context)(...props) } diff --git a/packages/core/src/utilities/isClass.ts b/packages/core/src/utilities/isClass.ts index a35ba2e74e..d3d1f74786 100644 --- a/packages/core/src/utilities/isClass.ts +++ b/packages/core/src/utilities/isClass.ts @@ -1,5 +1,5 @@ -export default function isClass(item: any): boolean { - if (item.constructor?.toString().substring(0, 5) !== 'class') { +export default function isClass(value: any): boolean { + if (value.constructor?.toString().substring(0, 5) !== 'class') { return false } diff --git a/packages/core/src/utilities/isEmptyObject.ts b/packages/core/src/utilities/isEmptyObject.ts index 4af643191a..da956393ff 100644 --- a/packages/core/src/utilities/isEmptyObject.ts +++ b/packages/core/src/utilities/isEmptyObject.ts @@ -1,3 +1,3 @@ -export default function isEmptyObject(object = {}): boolean { - return Object.keys(object).length === 0 && object.constructor === Object +export default function isEmptyObject(value = {}): boolean { + return Object.keys(value).length === 0 && value.constructor === Object } diff --git a/packages/core/src/utilities/isFunction.ts b/packages/core/src/utilities/isFunction.ts new file mode 100644 index 0000000000..1a08f66545 --- /dev/null +++ b/packages/core/src/utilities/isFunction.ts @@ -0,0 +1,3 @@ +export default function isObject(value: any): value is Function { + return typeof value === 'function' +} diff --git a/packages/core/src/utilities/isObject.ts b/packages/core/src/utilities/isObject.ts index 2de052f309..f89f1c3654 100644 --- a/packages/core/src/utilities/isObject.ts +++ b/packages/core/src/utilities/isObject.ts @@ -1,10 +1,10 @@ import isClass from './isClass' -export default function isObject(item: any): boolean { +export default function isObject(value: any): boolean { return ( - item - && typeof item === 'object' - && !Array.isArray(item) - && !isClass(item) + value + && typeof value === 'object' + && !Array.isArray(value) + && !isClass(value) ) } diff --git a/packages/core/src/utilities/isPlainObject.ts b/packages/core/src/utilities/isPlainObject.ts index 9c440725c1..febff25b54 100644 --- a/packages/core/src/utilities/isPlainObject.ts +++ b/packages/core/src/utilities/isPlainObject.ts @@ -1,10 +1,10 @@ // see: https://github.com/mesqueeb/is-what/blob/88d6e4ca92fb2baab6003c54e02eedf4e729e5ab/src/index.ts -function getType(payload: any): string { - return Object.prototype.toString.call(payload).slice(8, -1) +function getType(value: any): string { + return Object.prototype.toString.call(value).slice(8, -1) } -export default function isPlainObject(payload: any): payload is Record { - if (getType(payload) !== 'Object') return false - return payload.constructor === Object && Object.getPrototypeOf(payload) === Object.prototype +export default function isPlainObject(value: any): value is Record { + if (getType(value) !== 'Object') return false + return value.constructor === Object && Object.getPrototypeOf(value) === Object.prototype } diff --git a/packages/core/src/utilities/isRegExp.ts b/packages/core/src/utilities/isRegExp.ts index a18be7d9f3..57feabef86 100644 --- a/packages/core/src/utilities/isRegExp.ts +++ b/packages/core/src/utilities/isRegExp.ts @@ -1,3 +1,3 @@ -export default function isRegExp(value: any): boolean { +export default function isRegExp(value: any): value is RegExp { return Object.prototype.toString.call(value) === '[object RegExp]' } diff --git a/packages/core/src/utilities/isString.ts b/packages/core/src/utilities/isString.ts new file mode 100644 index 0000000000..b3ba58871a --- /dev/null +++ b/packages/core/src/utilities/isString.ts @@ -0,0 +1,3 @@ +export default function isString(value: any): value is string { + return typeof value === 'string' +} diff --git a/packages/extension-blockquote/package.json b/packages/extension-blockquote/package.json index 63600920db..a5bf4e1e93 100644 --- a/packages/extension-blockquote/package.json +++ b/packages/extension-blockquote/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-blockquote/src/blockquote.ts b/packages/extension-blockquote/src/blockquote.ts index 16b8988904..e4c339b3fc 100644 --- a/packages/extension-blockquote/src/blockquote.ts +++ b/packages/extension-blockquote/src/blockquote.ts @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core' -import { wrappingInputRule } from 'prosemirror-inputrules' +import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core' export interface BlockquoteOptions { HTMLAttributes: Record, @@ -72,7 +71,10 @@ export const Blockquote = Node.create({ addInputRules() { return [ - wrappingInputRule(inputRegex, this.type), + wrappingInputRule({ + find: inputRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-bold/src/bold.ts b/packages/extension-bold/src/bold.ts index a39e244397..50c0098e73 100644 --- a/packages/extension-bold/src/bold.ts +++ b/packages/extension-bold/src/bold.ts @@ -82,15 +82,27 @@ export const Bold = Mark.create({ addInputRules() { return [ - markInputRule(starInputRegex, this.type), - markInputRule(underscoreInputRegex, this.type), + markInputRule({ + find: starInputRegex, + type: this.type, + }), + markInputRule({ + find: underscoreInputRegex, + type: this.type, + }), ] }, addPasteRules() { return [ - markPasteRule(starPasteRegex, this.type), - markPasteRule(underscorePasteRegex, this.type), + markPasteRule({ + find: starPasteRegex, + type: this.type, + }), + markPasteRule({ + find: underscorePasteRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-bullet-list/package.json b/packages/extension-bullet-list/package.json index a7bd2031ca..6b9e45e5f4 100644 --- a/packages/extension-bullet-list/package.json +++ b/packages/extension-bullet-list/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-bullet-list/src/bullet-list.ts b/packages/extension-bullet-list/src/bullet-list.ts index 8c3c432f04..eb2144351a 100644 --- a/packages/extension-bullet-list/src/bullet-list.ts +++ b/packages/extension-bullet-list/src/bullet-list.ts @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core' -import { wrappingInputRule } from 'prosemirror-inputrules' +import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core' export interface BulletListOptions { HTMLAttributes: Record, @@ -55,7 +54,10 @@ export const BulletList = Node.create({ addInputRules() { return [ - wrappingInputRule(inputRegex, this.type), + wrappingInputRule({ + find: inputRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-code-block/package.json b/packages/extension-code-block/package.json index 24213b74a5..d82e9fdee2 100644 --- a/packages/extension-code-block/package.json +++ b/packages/extension-code-block/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-code-block/src/code-block.ts b/packages/extension-code-block/src/code-block.ts index e590043e45..125cba0a49 100644 --- a/packages/extension-code-block/src/code-block.ts +++ b/packages/extension-code-block/src/code-block.ts @@ -1,5 +1,4 @@ -import { Node } from '@tiptap/core' -import { textblockTypeInputRule } from 'prosemirror-inputrules' +import { Node, textblockTypeInputRule } from '@tiptap/core' export interface CodeBlockOptions { languageClassPrefix: string, @@ -21,8 +20,8 @@ declare module '@tiptap/core' { } } -export const backtickInputRegex = /^```(?[a-z]*)? $/ -export const tildeInputRegex = /^~~~(?[a-z]*)? $/ +export const backtickInputRegex = /^```(?[a-z]*)?[\s\n]$/ +export const tildeInputRegex = /^~~~(?[a-z]*)?[\s\n]$/ export const CodeBlock = Node.create({ name: 'codeBlock', @@ -121,8 +120,16 @@ export const CodeBlock = Node.create({ addInputRules() { return [ - textblockTypeInputRule(backtickInputRegex, this.type, ({ groups }: any) => groups), - textblockTypeInputRule(tildeInputRegex, this.type, ({ groups }: any) => groups), + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes: ({ groups }) => groups, + }), + textblockTypeInputRule({ + find: tildeInputRegex, + type: this.type, + getAttributes: ({ groups }) => groups, + }), ] }, }) diff --git a/packages/extension-code/src/code.ts b/packages/extension-code/src/code.ts index 8710661221..12b84b7590 100644 --- a/packages/extension-code/src/code.ts +++ b/packages/extension-code/src/code.ts @@ -40,6 +40,8 @@ export const Code = Mark.create({ excludes: '_', + code: true, + parseHTML() { return [ { tag: 'code' }, @@ -72,13 +74,19 @@ export const Code = Mark.create({ addInputRules() { return [ - markInputRule(inputRegex, this.type), + markInputRule({ + find: inputRegex, + type: this.type, + }), ] }, addPasteRules() { return [ - markPasteRule(pasteRegex, this.type), + markPasteRule({ + find: pasteRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-heading/package.json b/packages/extension-heading/package.json index bdb5e2e3bc..8888bdd066 100644 --- a/packages/extension-heading/package.json +++ b/packages/extension-heading/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-heading/src/heading.ts b/packages/extension-heading/src/heading.ts index a0f493ec0d..9d84e1a4eb 100644 --- a/packages/extension-heading/src/heading.ts +++ b/packages/extension-heading/src/heading.ts @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core' -import { textblockTypeInputRule } from 'prosemirror-inputrules' +import { Node, mergeAttributes, textblockTypeInputRule } from '@tiptap/core' type Level = 1 | 2 | 3 | 4 | 5 | 6 @@ -93,7 +92,13 @@ export const Heading = Node.create({ addInputRules() { return this.options.levels.map(level => { - return textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), this.type, { level }) + return textblockTypeInputRule({ + find: new RegExp(`^(#{1,${level}})\\s$`), + type: this.type, + getAttributes: { + level, + }, + }) }) }, }) diff --git a/packages/extension-highlight/src/highlight.ts b/packages/extension-highlight/src/highlight.ts index 84d90cae02..b37e5fc0a0 100644 --- a/packages/extension-highlight/src/highlight.ts +++ b/packages/extension-highlight/src/highlight.ts @@ -97,13 +97,19 @@ export const Highlight = Mark.create({ addInputRules() { return [ - markInputRule(inputRegex, this.type), + markInputRule({ + find: inputRegex, + type: this.type, + }), ] }, addPasteRules() { return [ - markPasteRule(pasteRegex, this.type), + markPasteRule({ + find: pasteRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-horizontal-rule/src/horizontal-rule.ts b/packages/extension-horizontal-rule/src/horizontal-rule.ts index 03ebd40fc1..57d938f239 100644 --- a/packages/extension-horizontal-rule/src/horizontal-rule.ts +++ b/packages/extension-horizontal-rule/src/horizontal-rule.ts @@ -92,7 +92,10 @@ export const HorizontalRule = Node.create({ addInputRules() { return [ - nodeInputRule(/^(?:---|—-|___\s|\*\*\*\s)$/, this.type), + nodeInputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + type: this.type, + }), ] }, }) diff --git a/packages/extension-image/src/image.ts b/packages/extension-image/src/image.ts index 7bfe871b45..66e01791c0 100644 --- a/packages/extension-image/src/image.ts +++ b/packages/extension-image/src/image.ts @@ -79,10 +79,14 @@ export const Image = Node.create({ addInputRules() { return [ - nodeInputRule(inputRegex, this.type, match => { - const [, alt, src, title] = match + nodeInputRule({ + find: inputRegex, + type: this.type, + getAttributes: match => { + const [, alt, src, title] = match - return { src, alt, title } + return { src, alt, title } + }, }), ] }, diff --git a/packages/extension-italic/src/italic.ts b/packages/extension-italic/src/italic.ts index 9cc818cfb4..0bb1260fb8 100644 --- a/packages/extension-italic/src/italic.ts +++ b/packages/extension-italic/src/italic.ts @@ -81,15 +81,27 @@ export const Italic = Mark.create({ addInputRules() { return [ - markInputRule(starInputRegex, this.type), - markInputRule(underscoreInputRegex, this.type), + markInputRule({ + find: starInputRegex, + type: this.type, + }), + markInputRule({ + find: underscoreInputRegex, + type: this.type, + }), ] }, addPasteRules() { return [ - markPasteRule(starPasteRegex, this.type), - markPasteRule(underscorePasteRegex, this.type), + markPasteRule({ + find: starPasteRegex, + type: this.type, + }), + markPasteRule({ + find: underscorePasteRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-link/package.json b/packages/extension-link/package.json index 7c1f4dc314..7c2af38ea0 100644 --- a/packages/extension-link/package.json +++ b/packages/extension-link/package.json @@ -24,6 +24,7 @@ "@tiptap/core": "^2.0.0-beta.1" }, "dependencies": { + "linkifyjs": "^3.0.1", "prosemirror-state": "^1.3.4" }, "repository": { diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts index f906bd0290..25dc49801c 100644 --- a/packages/extension-link/src/link.ts +++ b/packages/extension-link/src/link.ts @@ -4,6 +4,7 @@ import { mergeAttributes, } from '@tiptap/core' import { Plugin, PluginKey } from 'prosemirror-state' +import { find } from 'linkifyjs' export interface LinkOptions { /** @@ -39,16 +40,6 @@ declare module '@tiptap/core' { } } -/** - * A regex that matches any string that contains a link - */ -export const pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi - -/** - * A regex that matches an url - */ -export const pasteRegexExact = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)$/gi - export const Link = Mark.create({ name: 'link', @@ -102,7 +93,19 @@ export const Link = Mark.create({ addPasteRules() { return [ - markPasteRule(pasteRegex, this.type, match => ({ href: match[0] })), + markPasteRule({ + find: text => find(text) + .filter(link => link.isLink) + .map(link => ({ + text: link.value, + index: link.start, + data: link, + })), + type: this.type, + getAttributes: match => ({ + href: match.data?.href, + }), + }), ] }, @@ -151,12 +154,15 @@ export const Link = Mark.create({ textContent += node.textContent }) - if (!textContent || !textContent.match(pasteRegexExact)) { + const link = find(textContent) + .find(item => item.isLink && item.value === textContent) + + if (!textContent || !link) { return false } this.editor.commands.setMark(this.type, { - href: textContent, + href: link.href, }) return true diff --git a/packages/extension-ordered-list/package.json b/packages/extension-ordered-list/package.json index 61eb8684d5..3bf260df24 100644 --- a/packages/extension-ordered-list/package.json +++ b/packages/extension-ordered-list/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-ordered-list/src/ordered-list.ts b/packages/extension-ordered-list/src/ordered-list.ts index d3205eb37b..6ba22f5e43 100644 --- a/packages/extension-ordered-list/src/ordered-list.ts +++ b/packages/extension-ordered-list/src/ordered-list.ts @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core' -import { wrappingInputRule } from 'prosemirror-inputrules' +import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core' export interface OrderedListOptions { HTMLAttributes: Record, @@ -74,12 +73,12 @@ export const OrderedList = Node.create({ addInputRules() { return [ - wrappingInputRule( - inputRegex, - this.type, - match => ({ start: +match[1] }), - (match, node) => node.childCount + node.attrs.start === +match[1], - ), + wrappingInputRule({ + find: inputRegex, + type: this.type, + getAttributes: match => ({ start: +match[1] }), + joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1], + }), ] }, }) diff --git a/packages/extension-strike/src/strike.ts b/packages/extension-strike/src/strike.ts index 879ad047d0..cd09ef865f 100644 --- a/packages/extension-strike/src/strike.ts +++ b/packages/extension-strike/src/strike.ts @@ -83,13 +83,19 @@ export const Strike = Mark.create({ addInputRules() { return [ - markInputRule(inputRegex, this.type), + markInputRule({ + find: inputRegex, + type: this.type, + }), ] }, addPasteRules() { return [ - markPasteRule(pasteRegex, this.type), + markPasteRule({ + find: pasteRegex, + type: this.type, + }), ] }, }) diff --git a/packages/extension-task-item/package.json b/packages/extension-task-item/package.json index b1091cfc7a..dddeb2f04a 100644 --- a/packages/extension-task-item/package.json +++ b/packages/extension-task-item/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-task-item/src/task-item.ts b/packages/extension-task-item/src/task-item.ts index c20668c4ca..9221465cb5 100644 --- a/packages/extension-task-item/src/task-item.ts +++ b/packages/extension-task-item/src/task-item.ts @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core' -import { wrappingInputRule } from 'prosemirror-inputrules' +import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core' export interface TaskItemOptions { nested: boolean, @@ -146,13 +145,13 @@ export const TaskItem = Node.create({ addInputRules() { return [ - wrappingInputRule( - inputRegex, - this.type, - match => ({ + wrappingInputRule({ + find: inputRegex, + type: this.type, + getAttributes: match => ({ checked: match[match.length - 1] === 'x', }), - ), + }), ] }, }) diff --git a/packages/extension-typography/package.json b/packages/extension-typography/package.json index cc91e89d57..b00689f353 100644 --- a/packages/extension-typography/package.json +++ b/packages/extension-typography/package.json @@ -23,9 +23,6 @@ "peerDependencies": { "@tiptap/core": "^2.0.0-beta.1" }, - "dependencies": { - "prosemirror-inputrules": "^1.1.3" - }, "repository": { "type": "git", "url": "https://github.com/ueberdosis/tiptap", diff --git a/packages/extension-typography/src/typography.ts b/packages/extension-typography/src/typography.ts index 60867618d3..bae235cfa7 100644 --- a/packages/extension-typography/src/typography.ts +++ b/packages/extension-typography/src/typography.ts @@ -1,29 +1,109 @@ -import { Extension } from '@tiptap/core' -import { - emDash, - ellipsis, - openDoubleQuote, - closeDoubleQuote, - openSingleQuote, - closeSingleQuote, - InputRule, -} from 'prosemirror-inputrules' - -export const leftArrow = new InputRule(/<-$/, '←') -export const rightArrow = new InputRule(/->$/, '→') -export const copyright = new InputRule(/\(c\)$/, '©') -export const trademark = new InputRule(/\(tm\)$/, '™') -export const registeredTrademark = new InputRule(/\(r\)$/, '®') -export const oneHalf = new InputRule(/1\/2$/, '½') -export const plusMinus = new InputRule(/\+\/-$/, '±') -export const notEqual = new InputRule(/!=$/, '≠') -export const laquo = new InputRule(/<<$/, '«') -export const raquo = new InputRule(/>>$/, '»') -export const multiplication = new InputRule(/\d+\s?([*x])\s?\d+$/, '×') -export const superscriptTwo = new InputRule(/\^2$/, '²') -export const superscriptThree = new InputRule(/\^3$/, '³') -export const oneQuarter = new InputRule(/1\/4$/, '¼') -export const threeQuarters = new InputRule(/3\/4$/, '¾') +import { Extension, textInputRule } from '@tiptap/core' + +export const emDash = textInputRule({ + find: /--$/, + replace: '—', +}) + +export const ellipsis = textInputRule({ + find: /\.\.\.$/, + replace: '…', +}) + +export const openDoubleQuote = textInputRule({ + find: /(?:^|[\s{[(<'"\u2018\u201C])(")$/, + replace: '“', +}) + +export const closeDoubleQuote = textInputRule({ + find: /"$/, + replace: '”', +}) + +export const openSingleQuote = textInputRule({ + find: /(?:^|[\s{[(<'"\u2018\u201C])(')$/, + replace: '‘', +}) + +export const closeSingleQuote = textInputRule({ + find: /'$/, + replace: '’', +}) + +export const leftArrow = textInputRule({ + find: /<-$/, + replace: '←', +}) + +export const rightArrow = textInputRule({ + find: /->$/, + replace: '→', +}) + +export const copyright = textInputRule({ + find: /\(c\)$/, + replace: '©', +}) + +export const trademark = textInputRule({ + find: /\(tm\)$/, + replace: '™', +}) + +export const registeredTrademark = textInputRule({ + find: /\(r\)$/, + replace: '®', +}) + +export const oneHalf = textInputRule({ + find: /1\/2$/, + replace: '½', +}) + +export const plusMinus = textInputRule({ + find: /\+\/-$/, + replace: '±', +}) + +export const notEqual = textInputRule({ + find: /!=$/, + replace: '≠', +}) + +export const laquo = textInputRule({ + find: /<<$/, + replace: '«', +}) + +export const raquo = textInputRule({ + find: />>$/, + replace: '»', +}) + +export const multiplication = textInputRule({ + find: /\d+\s?([*x])\s?\d+$/, + replace: '×', +}) + +export const superscriptTwo = textInputRule({ + find: /\^2$/, + replace: '²', +}) + +export const superscriptThree = textInputRule({ + find: /\^3$/, + replace: '³', +}) + +export const oneQuarter = textInputRule({ + find: /1\/4$/, + replace: '¼', +}) + +export const threeQuarters = textInputRule({ + find: /3\/4$/, + replace: '¾', +}) export const Typography = Extension.create({ name: 'typography', diff --git a/tests/cypress/integration/extensions/link.spec.ts b/tests/cypress/integration/extensions/link.spec.ts deleted file mode 100644 index a72c527f1b..0000000000 --- a/tests/cypress/integration/extensions/link.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -/// - -import { pasteRegex } from '@tiptap/extension-link' - -describe('link paste rules', () => { - const validUrls = [ - 'https://example.com', - 'https://example.com/with-path', - 'http://example.com/with-http', - 'https://www.example.com/with-www', - 'https://www.example.com/with-numbers-123', - 'https://www.example.com/with-parameters?var=true', - 'https://www.example.com/with-multiple-parameters?var=true&foo=bar', - 'https://www.example.com/with-spaces?var=true&foo=bar+3', - 'https://www.example.com/with,comma', - 'https://www.example.com/with(brackets)', - 'https://www.example.com/with!exclamation!marks', - 'http://thelongestdomainnameintheworldandthensomeandthensomemoreandmore.com/', - 'https://example.longtopleveldomain', - 'https://example-with-dashes.com', - ] - - validUrls.forEach(url => { - it(`paste regex matches url: ${url}`, { - // every second test fails, but the second try succeeds - retries: { - runMode: 2, - openMode: 2, - }, - }, () => { - // TODO: Check the regex capture group to see *what* is matched - expect(url).to.match(pasteRegex) - }) - }) - - const invalidUrls = [ - 'ftp://www.example.com', - ] - - invalidUrls.forEach(url => { - it(`paste regex doesn’t match url: ${url}`, { - // every second test fails, but the second try succeeds - retries: { - runMode: 2, - openMode: 2, - }, - }, () => { - expect(url).to.not.match(pasteRegex) - }) - }) -}) diff --git a/yarn.lock b/yarn.lock index db76700fcc..51d43385b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2100,14 +2100,6 @@ "@types/prosemirror-model" "*" "@types/prosemirror-state" "*" -"@types/prosemirror-inputrules@^1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@types/prosemirror-inputrules/-/prosemirror-inputrules-1.0.4.tgz#4cb75054d954aa0f6f42099be05eb6c0e6958bae" - integrity sha512-lJIMpOjO47SYozQybUkpV6QmfuQt7GZKHtVrvS+mR5UekA8NMC5HRIVMyaIauJLWhKU6oaNjpVaXdw41kh165g== - dependencies: - "@types/prosemirror-model" "*" - "@types/prosemirror-state" "*" - "@types/prosemirror-keymap@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/prosemirror-keymap/-/prosemirror-keymap-1.0.4.tgz#f73c79810e8d0e0a20d153d84f998f02e5afbc0c" @@ -5922,6 +5914,11 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= +linkifyjs@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-3.0.1.tgz#fda7b8d399eceef6fd7427f8a6e5d4f962ae74ed" + integrity sha512-HwXVwdNH1wESBfo2sH7Bkl+ywzbGA3+uJEfhquCyi/bMCa49bFUvd/re1NT1Lox/5jdnpQXzI9O/jykit71idg== + listr2@^3.8.3: version "3.12.2" resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.12.2.tgz#2d55cc627111603ad4768a9e87c9c7bb9b49997e" @@ -7163,14 +7160,6 @@ prosemirror-history@^1.2.0: prosemirror-transform "^1.0.0" rope-sequence "^1.3.0" -prosemirror-inputrules@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz#93f9199ca02473259c30d7e352e4c14022d54638" - integrity sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw== - dependencies: - prosemirror-state "^1.0.0" - prosemirror-transform "^1.0.0" - prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2, prosemirror-keymap@^1.1.3: version "1.1.4" resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.4.tgz#8b481bf8389a5ac40d38dbd67ec3da2c7eac6a6d"