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
This commit is contained in:
Philipp Kühn 2021-10-08 15:02:09 +02:00 committed by GitHub
parent ace4964d97
commit 723b955cec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1150 additions and 383 deletions

View File

@ -5,7 +5,6 @@ prosemirror-commands
prosemirror-dropcursor prosemirror-dropcursor
prosemirror-gapcursor prosemirror-gapcursor
prosemirror-history prosemirror-history
prosemirror-inputrules
prosemirror-keymap prosemirror-keymap
prosemirror-model prosemirror-model
prosemirror-schema-list prosemirror-schema-list

View File

@ -16,7 +16,6 @@ import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph' import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text' import Text from '@tiptap/extension-text'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Bold from '@tiptap/extension-bold'
export default { export default {
components: { components: {
@ -35,14 +34,13 @@ export default {
Document, Document,
Paragraph, Paragraph,
Text, Text,
Bold,
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
}), }),
], ],
content: ` content: `
<p> <p>
Wow, this editor has support for links to the whole <strong><a href="https://en.wikipedia.org/wiki/World_Wide_Web">world wide web</a></strong>. We tested a lot of URLs and I think you can add *every URL* you want. Isnt that cool? Lets try <a href="https://statamic.com/">another one!</a> Yep, seems to work. Wow, this editor has support for links to the whole <a href="https://en.wikipedia.org/wiki/World_Wide_Web">world wide web</a>. We tested a lot of URLs and I think you can add *every URL* you want. Isnt that cool? Lets try <a href="https://statamic.com/">another one!</a> Yep, seems to work.
</p> </p>
<p> <p>
By default every link will get a \`rel="noopener noreferrer nofollow"\` attribute. Its configurable though. By default every link will get a \`rel="noopener noreferrer nofollow"\` attribute. Its configurable though.

View File

@ -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', () => { it('reverts the markdown shortcut when pressing backspace', () => {
cy.get('.ProseMirror').then(([{ editor }]) => { cy.get('.ProseMirror').then(([{ editor }]) => {
editor.commands.clearContent() editor.commands.clearContent()

View File

@ -25,7 +25,6 @@
], ],
"dependencies": { "dependencies": {
"@types/prosemirror-commands": "^1.0.4", "@types/prosemirror-commands": "^1.0.4",
"@types/prosemirror-inputrules": "^1.0.4",
"@types/prosemirror-keymap": "^1.0.4", "@types/prosemirror-keymap": "^1.0.4",
"@types/prosemirror-model": "^1.13.2", "@types/prosemirror-model": "^1.13.2",
"@types/prosemirror-schema-list": "^1.0.3", "@types/prosemirror-schema-list": "^1.0.3",
@ -33,7 +32,6 @@
"@types/prosemirror-transform": "^1.1.4", "@types/prosemirror-transform": "^1.1.4",
"@types/prosemirror-view": "^1.19.1", "@types/prosemirror-view": "^1.19.1",
"prosemirror-commands": "^1.1.11", "prosemirror-commands": "^1.1.11",
"prosemirror-inputrules": "^1.1.3",
"prosemirror-keymap": "^1.1.3", "prosemirror-keymap": "^1.1.3",
"prosemirror-model": "^1.14.3", "prosemirror-model": "^1.14.3",
"prosemirror-schema-list": "^1.1.6", "prosemirror-schema-list": "^1.1.6",

View File

@ -1,5 +1,6 @@
import { EditorState, Transaction } from 'prosemirror-state' import { Transaction } from 'prosemirror-state'
import { Editor } from './Editor' import { Editor } from './Editor'
import createChainableState from './helpers/createChainableState'
import { import {
SingleCommands, SingleCommands,
ChainedCommands, ChainedCommands,
@ -106,7 +107,10 @@ export default class CommandManager {
tr, tr,
editor, editor,
view, view,
state: this.chainableState(tr, state), state: createChainableState({
state,
transaction: tr,
}),
dispatch: shouldDispatch dispatch: shouldDispatch
? () => undefined ? () => undefined
: undefined, : undefined,
@ -124,36 +128,4 @@ export default class CommandManager {
return props 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
},
}
}
} }

View File

@ -15,6 +15,7 @@ import getText from './helpers/getText'
import isNodeEmpty from './helpers/isNodeEmpty' import isNodeEmpty from './helpers/isNodeEmpty'
import getTextSeralizersFromSchema from './helpers/getTextSeralizersFromSchema' import getTextSeralizersFromSchema from './helpers/getTextSeralizersFromSchema'
import createStyleTag from './utilities/createStyleTag' import createStyleTag from './utilities/createStyleTag'
import isFunction from './utilities/isFunction'
import CommandManager from './CommandManager' import CommandManager from './CommandManager'
import ExtensionManager from './ExtensionManager' import ExtensionManager from './ExtensionManager'
import EventEmitter from './EventEmitter' import EventEmitter from './EventEmitter'
@ -184,7 +185,7 @@ export class Editor extends EventEmitter<EditorEvents> {
* @param handlePlugins Control how to merge the plugin into the existing plugins. * @param handlePlugins Control how to merge the plugin into the existing plugins.
*/ */
public registerPlugin(plugin: Plugin, handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[]): void { 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) ? handlePlugins(plugin, this.state.plugins)
: [...this.state.plugins, plugin] : [...this.state.plugins, plugin]

View File

@ -1,5 +1,6 @@
import { Plugin, Transaction } from 'prosemirror-state' import { Plugin, Transaction } from 'prosemirror-state'
import { InputRule } from 'prosemirror-inputrules' import { InputRule } from './InputRule'
import { PasteRule } from './PasteRule'
import { Editor } from './Editor' import { Editor } from './Editor'
import { Node } from './Node' import { Node } from './Node'
import { Mark } from './Mark' import { Mark } from './Mark'
@ -81,7 +82,7 @@ declare module '@tiptap/core' {
options: Options, options: Options,
editor: Editor, editor: Editor,
parent: ParentConfig<ExtensionConfig<Options>>['addPasteRules'], parent: ParentConfig<ExtensionConfig<Options>>['addPasteRules'],
}) => Plugin[], }) => PasteRule[],
/** /**
* ProseMirror plugins * ProseMirror plugins

View File

@ -1,6 +1,7 @@
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import { Schema, Node as ProsemirrorNode } from 'prosemirror-model' 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 { EditorView, Decoration } from 'prosemirror-view'
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import { Editor } from './Editor' 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. // so it feels more natural to run plugins at the end of an array first.
// Thats why we have to reverse the `extensions` array and sort again // Thats why we have to reverse the `extensions` array and sort again
// based on the `priority` option. // 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 => { .map(extension => {
const context = { const context = {
name: extension.name, name: extension.name,
@ -248,12 +254,7 @@ export default class ExtensionManager {
) )
if (this.editor.options.enableInputRules && addInputRules) { if (this.editor.options.enableInputRules && addInputRules) {
const inputRules = addInputRules() inputRules.push(...addInputRules())
const inputRulePlugins = inputRules.length
? [inputRulesPlugin({ rules: inputRules })]
: []
plugins.push(...inputRulePlugins)
} }
const addPasteRules = getExtensionField<AnyConfig['addPasteRules']>( const addPasteRules = getExtensionField<AnyConfig['addPasteRules']>(
@ -263,9 +264,7 @@ export default class ExtensionManager {
) )
if (this.editor.options.enablePasteRules && addPasteRules) { if (this.editor.options.enablePasteRules && addPasteRules) {
const pasteRulePlugins = addPasteRules() pasteRules.push(...addPasteRules())
plugins.push(...pasteRulePlugins)
} }
const addProseMirrorPlugins = getExtensionField<AnyConfig['addProseMirrorPlugins']>( const addProseMirrorPlugins = getExtensionField<AnyConfig['addProseMirrorPlugins']>(
@ -283,6 +282,12 @@ export default class ExtensionManager {
return plugins return plugins
}) })
.flat() .flat()
return [
inputRulesPlugin(inputRules),
pasteRulesPlugin(pasteRules),
...allPlugins,
]
} }
get attributes() { get attributes() {

View File

@ -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<string, any>,
}
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 rules
* 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
}

View File

@ -5,7 +5,8 @@ import {
MarkType, MarkType,
} from 'prosemirror-model' } from 'prosemirror-model'
import { Plugin, Transaction } from 'prosemirror-state' 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 mergeDeep from './utilities/mergeDeep'
import { import {
Extensions, Extensions,
@ -91,7 +92,7 @@ declare module '@tiptap/core' {
editor: Editor, editor: Editor,
type: MarkType, type: MarkType,
parent: ParentConfig<MarkConfig<Options>>['addPasteRules'], parent: ParentConfig<MarkConfig<Options>>['addPasteRules'],
}) => Plugin[], }) => PasteRule[],
/** /**
* ProseMirror plugins * ProseMirror plugins
@ -281,6 +282,15 @@ declare module '@tiptap/core' {
parent: ParentConfig<MarkConfig<Options>>['spanning'], parent: ParentConfig<MarkConfig<Options>>['spanning'],
}) => MarkSpec['spanning']), }) => MarkSpec['spanning']),
/**
* Code
*/
code?: boolean | ((this: {
name: string,
options: Options,
parent: ParentConfig<MarkConfig<Options>>['code'],
}) => boolean),
/** /**
* Parse HTML * Parse HTML
*/ */

View File

@ -5,7 +5,8 @@ import {
NodeType, NodeType,
} from 'prosemirror-model' } from 'prosemirror-model'
import { Plugin, Transaction } from 'prosemirror-state' 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 mergeDeep from './utilities/mergeDeep'
import { import {
Extensions, Extensions,
@ -91,7 +92,7 @@ declare module '@tiptap/core' {
editor: Editor, editor: Editor,
type: NodeType, type: NodeType,
parent: ParentConfig<NodeConfig<Options>>['addPasteRules'], parent: ParentConfig<NodeConfig<Options>>['addPasteRules'],
}) => Plugin[], }) => PasteRule[],
/** /**
* ProseMirror plugins * ProseMirror plugins

View File

@ -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<string, any>,
}
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 rules
* 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
}

View File

@ -1,4 +1,3 @@
import { undoInputRule as originalUndoInputRule } from 'prosemirror-inputrules'
import { RawCommands } from '../types' import { RawCommands } from '../types'
declare module '@tiptap/core' { declare module '@tiptap/core' {
@ -13,5 +12,34 @@ declare module '@tiptap/core' {
} }
export const undoInputRule: RawCommands['undoInputRule'] = () => ({ state, dispatch }) => { 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
} }

View File

@ -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
},
}
}

View File

@ -108,10 +108,11 @@ export default function getSchemaByResolvedExtensions(extensions: Extensions): S
const schema: MarkSpec = cleanUpSchemaItem({ const schema: MarkSpec = cleanUpSchemaItem({
...extraMarkFields, ...extraMarkFields,
inclusive: callOrReturn(getExtensionField<NodeConfig['inclusive']>(extension, 'inclusive', context)), inclusive: callOrReturn(getExtensionField<MarkConfig['inclusive']>(extension, 'inclusive', context)),
excludes: callOrReturn(getExtensionField<NodeConfig['excludes']>(extension, 'excludes', context)), excludes: callOrReturn(getExtensionField<MarkConfig['excludes']>(extension, 'excludes', context)),
group: callOrReturn(getExtensionField<NodeConfig['group']>(extension, 'group', context)), group: callOrReturn(getExtensionField<MarkConfig['group']>(extension, 'group', context)),
spanning: callOrReturn(getExtensionField<NodeConfig['spanning']>(extension, 'spanning', context)), spanning: callOrReturn(getExtensionField<MarkConfig['spanning']>(extension, 'spanning', context)),
code: callOrReturn(getExtensionField<MarkConfig['code']>(extension, 'code', context)),
attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => { attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => {
return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }] return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }]
})), })),

View File

@ -7,11 +7,17 @@ export * from './Node'
export * from './Mark' export * from './Mark'
export * from './NodeView' export * from './NodeView'
export * from './Tracker' export * from './Tracker'
export * from './InputRule'
export * from './PasteRule'
export * from './types' export * from './types'
export { default as nodeInputRule } from './inputRules/nodeInputRule' export { default as nodeInputRule } from './inputRules/nodeInputRule'
export { default as markInputRule } from './inputRules/markInputRule' 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 markPasteRule } from './pasteRules/markPasteRule'
export { default as textPasteRule } from './pasteRules/textPasteRule'
export { default as callOrReturn } from './utilities/callOrReturn' export { default as callOrReturn } from './utilities/callOrReturn'
export { default as mergeAttributes } from './utilities/mergeAttributes' export { default as mergeAttributes } from './utilities/mergeAttributes'

View File

@ -1,28 +1,49 @@
import { InputRule } from 'prosemirror-inputrules' import { InputRule, InputRuleFinder } from '../InputRule'
import { MarkType } from 'prosemirror-model' import { MarkType } from 'prosemirror-model'
import getMarksBetween from '../helpers/getMarksBetween' import getMarksBetween from '../helpers/getMarksBetween'
import callOrReturn from '../utilities/callOrReturn'
import { ExtendedRegExpMatchArray } from '../types'
/**
* 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<string, any>
| ((match: ExtendedRegExpMatchArray) => Record<string, any>)
| false
| null
,
}) {
return new InputRule({
find: config.find,
handler: ({ state, range, match }) => {
const attributes = callOrReturn(config.getAttributes, undefined, match)
if (attributes === false || attributes === null) {
return
}
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 { tr } = state
const captureGroup = match[match.length - 1] const captureGroup = match[match.length - 1]
const fullMatch = match[0] const fullMatch = match[0]
let markEnd = end let markEnd = range.to
if (captureGroup) { if (captureGroup) {
const startSpaces = fullMatch.search(/\S/) const startSpaces = fullMatch.search(/\S/)
const textStart = start + fullMatch.indexOf(captureGroup) const textStart = range.from + fullMatch.indexOf(captureGroup)
const textEnd = textStart + captureGroup.length const textEnd = textStart + captureGroup.length
const excludedMarks = getMarksBetween(start, end, state) const excludedMarks = getMarksBetween(range.from, range.to, state)
.filter(item => { .filter(item => {
// TODO: PR to add excluded to MarkType // TODO: PR to add excluded to MarkType
// @ts-ignore // @ts-ignore
const { excluded } = item.mark.type const { excluded } = item.mark.type
return excluded.find((type: MarkType) => type.name === markType.name)
return excluded.find((type: MarkType) => type.name === config.type.name)
}) })
.filter(item => item.to > textStart) .filter(item => item.to > textStart)
@ -30,21 +51,20 @@ export default function (regexp: RegExp, markType: MarkType, getAttributes?: Fun
return null return null
} }
if (textEnd < end) { if (textEnd < range.to) {
tr.delete(textEnd, end) tr.delete(textEnd, range.to)
} }
if (textStart > start) { if (textStart > range.from) {
tr.delete(start + startSpaces, textStart) tr.delete(range.from + startSpaces, textStart)
} }
markEnd = start + startSpaces + captureGroup.length markEnd = range.from + startSpaces + captureGroup.length
tr.addMark(start + startSpaces, markEnd, markType.create(attributes)) tr.addMark(range.from + startSpaces, markEnd, config.type.create(attributes || {}))
tr.removeStoredMark(markType) tr.removeStoredMark(config.type)
} }
},
return tr
}) })
} }

View File

@ -1,16 +1,34 @@
import { InputRule } from 'prosemirror-inputrules'
import { NodeType } from 'prosemirror-model' 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) => { * Build an input rule that adds a node when the
const attributes = getAttributes instanceof Function * matched text is typed into it.
? getAttributes(match) */
: getAttributes export default function nodeInputRule(config: {
find: InputRuleFinder,
type: NodeType,
getAttributes?:
| Record<string, any>
| ((match: ExtendedRegExpMatchArray) => Record<string, any>)
| false
| null
,
}) {
return new InputRule({
find: config.find,
handler: ({ state, range, match }) => {
const attributes = callOrReturn(config.getAttributes, undefined, match) || {}
const { tr } = state const { tr } = state
const start = range.from
let end = range.to
if (match[1]) { if (match[1]) {
const offset = match[0].lastIndexOf(match[1]) const offset = match[0].lastIndexOf(match[1])
let matchStart = start + offset let matchStart = start + offset
if (matchStart > end) { if (matchStart > end) {
matchStart = end matchStart = end
} else { } else {
@ -22,11 +40,10 @@ export default function (regexp: RegExp, type: NodeType, getAttributes?: (match:
tr.insertText(lastChar, start + match[0].length - 1) tr.insertText(lastChar, start + match[0].length - 1)
// insert node from input rule // insert node from input rule
tr.replaceWith(matchStart, end, type.create(attributes)) tr.replaceWith(matchStart, end, config.type.create(attributes))
} else if (match[0]) { } else if (match[0]) {
tr.replaceWith(start, end, type.create(attributes)) tr.replaceWith(start, end, config.type.create(attributes))
} }
},
return tr
}) })
} }

View File

@ -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)
},
})
}

View File

@ -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 youll
* 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<string, any>
| ((match: ExtendedRegExpMatchArray) => Record<string, any>)
| 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)
},
})
}

View File

@ -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 youll
* 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 theres 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<string, any>
| ((match: ExtendedRegExpMatchArray) => Record<string, any>)
| 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)
}
},
})
}

View File

@ -1,76 +1,70 @@
import { Plugin, PluginKey } from 'prosemirror-state' import { PasteRule, PasteRuleFinder } from '../PasteRule'
import { Slice, Fragment, MarkType } from 'prosemirror-model' 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, type: MarkType,
getAttributes?: getAttributes?:
| Record<string, any> | Record<string, any>
| ((match: RegExpExecArray) => Record<string, any>) | ((match: ExtendedRegExpMatchArray) => Record<string, any>)
| false | false
| null | null
, ,
): Plugin { }) {
const handler = (fragment: Fragment, parent?: any) => { return new PasteRule({
const nodes: any[] = [] find: config.find,
handler: ({ state, range, match }) => {
const attributes = callOrReturn(config.getAttributes, undefined, match)
fragment.forEach(child => { if (attributes === false || attributes === null) {
if (child.isText && child.text) { return
const { text } = child
let pos = 0
let match
// 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)
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 (!attrs && attrs !== undefined) {
continue
} }
// adding text before markdown to nodes const { tr } = state
if (matchStart > 0) { const captureGroup = match[match.length - 1]
nodes.push(child.cut(pos, matchStart)) const fullMatch = match[0]
} let markEnd = range.to
// adding the markdown part to nodes if (captureGroup) {
nodes.push(child const startSpaces = fullMatch.search(/\S/)
.cut(textStart, textEnd) const textStart = range.from + fullMatch.indexOf(captureGroup)
.mark(type.create(attrs).addToSet(child.marks))) const textEnd = textStart + captureGroup.length
pos = matchEnd 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 rest of text to nodes return excluded.find((type: MarkType) => type.name === config.type.name)
if (pos < text.length) {
nodes.push(child.cut(pos))
}
} else {
nodes.push(child.copy(handler(child.content, child)))
}
}) })
.filter(item => item.to > textStart)
return Fragment.fromArray(nodes) if (excludedMarks.length) {
return null
} }
return new Plugin({ if (textEnd < range.to) {
key: new PluginKey('markPasteRule'), tr.delete(textEnd, range.to)
props: { }
transformPasted: slice => {
return new Slice(handler(slice.content), slice.openStart, slice.openEnd) 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)
}
}, },
}) })
} }

View File

@ -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)
},
})
}

View File

@ -229,3 +229,7 @@ export type TextSerializer = (props: {
parent: ProseMirrorNode, parent: ProseMirrorNode,
index: number, index: number,
}) => string }) => string
export type ExtendedRegExpMatchArray = RegExpMatchArray & {
data?: Record<string, any>,
}

View File

@ -1,4 +1,5 @@
import { MaybeReturnType } from '../types' import { MaybeReturnType } from '../types'
import isFunction from './isFunction'
/** /**
* Optionally calls `value` as a function. * Optionally calls `value` as a function.
@ -8,7 +9,7 @@ import { MaybeReturnType } from '../types'
* @param props Optional props to pass to function. * @param props Optional props to pass to function.
*/ */
export default function callOrReturn<T>(value: T, context: any = undefined, ...props: any[]): MaybeReturnType<T> { export default function callOrReturn<T>(value: T, context: any = undefined, ...props: any[]): MaybeReturnType<T> {
if (typeof value === 'function') { if (isFunction(value)) {
if (context) { if (context) {
return value.bind(context)(...props) return value.bind(context)(...props)
} }

View File

@ -1,5 +1,5 @@
export default function isClass(item: any): boolean { export default function isClass(value: any): boolean {
if (item.constructor?.toString().substring(0, 5) !== 'class') { if (value.constructor?.toString().substring(0, 5) !== 'class') {
return false return false
} }

View File

@ -1,3 +1,3 @@
export default function isEmptyObject(object = {}): boolean { export default function isEmptyObject(value = {}): boolean {
return Object.keys(object).length === 0 && object.constructor === Object return Object.keys(value).length === 0 && value.constructor === Object
} }

View File

@ -0,0 +1,3 @@
export default function isObject(value: any): value is Function {
return typeof value === 'function'
}

View File

@ -1,10 +1,10 @@
import isClass from './isClass' import isClass from './isClass'
export default function isObject(item: any): boolean { export default function isObject(value: any): boolean {
return ( return (
item value
&& typeof item === 'object' && typeof value === 'object'
&& !Array.isArray(item) && !Array.isArray(value)
&& !isClass(item) && !isClass(value)
) )
} }

View File

@ -1,10 +1,10 @@
// see: https://github.com/mesqueeb/is-what/blob/88d6e4ca92fb2baab6003c54e02eedf4e729e5ab/src/index.ts // see: https://github.com/mesqueeb/is-what/blob/88d6e4ca92fb2baab6003c54e02eedf4e729e5ab/src/index.ts
function getType(payload: any): string { function getType(value: any): string {
return Object.prototype.toString.call(payload).slice(8, -1) return Object.prototype.toString.call(value).slice(8, -1)
} }
export default function isPlainObject(payload: any): payload is Record<string, any> { export default function isPlainObject(value: any): value is Record<string, any> {
if (getType(payload) !== 'Object') return false if (getType(value) !== 'Object') return false
return payload.constructor === Object && Object.getPrototypeOf(payload) === Object.prototype return value.constructor === Object && Object.getPrototypeOf(value) === Object.prototype
} }

View File

@ -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]' return Object.prototype.toString.call(value) === '[object RegExp]'
} }

View File

@ -0,0 +1,3 @@
export default function isString(value: any): value is string {
return typeof value === 'string'
}

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core' import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export interface BlockquoteOptions { export interface BlockquoteOptions {
HTMLAttributes: Record<string, any>, HTMLAttributes: Record<string, any>,
@ -72,7 +71,10 @@ export const Blockquote = Node.create<BlockquoteOptions>({
addInputRules() { addInputRules() {
return [ return [
wrappingInputRule(inputRegex, this.type), wrappingInputRule({
find: inputRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -82,15 +82,27 @@ export const Bold = Mark.create<BoldOptions>({
addInputRules() { addInputRules() {
return [ return [
markInputRule(starInputRegex, this.type), markInputRule({
markInputRule(underscoreInputRegex, this.type), find: starInputRegex,
type: this.type,
}),
markInputRule({
find: underscoreInputRegex,
type: this.type,
}),
] ]
}, },
addPasteRules() { addPasteRules() {
return [ return [
markPasteRule(starPasteRegex, this.type), markPasteRule({
markPasteRule(underscorePasteRegex, this.type), find: starPasteRegex,
type: this.type,
}),
markPasteRule({
find: underscorePasteRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core' import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export interface BulletListOptions { export interface BulletListOptions {
HTMLAttributes: Record<string, any>, HTMLAttributes: Record<string, any>,
@ -55,7 +54,10 @@ export const BulletList = Node.create<BulletListOptions>({
addInputRules() { addInputRules() {
return [ return [
wrappingInputRule(inputRegex, this.type), wrappingInputRule({
find: inputRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,5 +1,4 @@
import { Node } from '@tiptap/core' import { Node, textblockTypeInputRule } from '@tiptap/core'
import { textblockTypeInputRule } from 'prosemirror-inputrules'
export interface CodeBlockOptions { export interface CodeBlockOptions {
languageClassPrefix: string, languageClassPrefix: string,
@ -21,8 +20,8 @@ declare module '@tiptap/core' {
} }
} }
export const backtickInputRegex = /^```(?<language>[a-z]*)? $/ export const backtickInputRegex = /^```(?<language>[a-z]*)?[\s\n]$/
export const tildeInputRegex = /^~~~(?<language>[a-z]*)? $/ export const tildeInputRegex = /^~~~(?<language>[a-z]*)?[\s\n]$/
export const CodeBlock = Node.create<CodeBlockOptions>({ export const CodeBlock = Node.create<CodeBlockOptions>({
name: 'codeBlock', name: 'codeBlock',
@ -121,8 +120,16 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
addInputRules() { addInputRules() {
return [ return [
textblockTypeInputRule(backtickInputRegex, this.type, ({ groups }: any) => groups), textblockTypeInputRule({
textblockTypeInputRule(tildeInputRegex, this.type, ({ groups }: any) => groups), find: backtickInputRegex,
type: this.type,
getAttributes: ({ groups }) => groups,
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: ({ groups }) => groups,
}),
] ]
}, },
}) })

View File

@ -40,6 +40,8 @@ export const Code = Mark.create<CodeOptions>({
excludes: '_', excludes: '_',
code: true,
parseHTML() { parseHTML() {
return [ return [
{ tag: 'code' }, { tag: 'code' },
@ -72,13 +74,19 @@ export const Code = Mark.create<CodeOptions>({
addInputRules() { addInputRules() {
return [ return [
markInputRule(inputRegex, this.type), markInputRule({
find: inputRegex,
type: this.type,
}),
] ]
}, },
addPasteRules() { addPasteRules() {
return [ return [
markPasteRule(pasteRegex, this.type), markPasteRule({
find: pasteRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core' import { Node, mergeAttributes, textblockTypeInputRule } from '@tiptap/core'
import { textblockTypeInputRule } from 'prosemirror-inputrules'
type Level = 1 | 2 | 3 | 4 | 5 | 6 type Level = 1 | 2 | 3 | 4 | 5 | 6
@ -93,7 +92,13 @@ export const Heading = Node.create<HeadingOptions>({
addInputRules() { addInputRules() {
return this.options.levels.map(level => { 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,
},
})
}) })
}, },
}) })

View File

@ -97,13 +97,19 @@ export const Highlight = Mark.create<HighlightOptions>({
addInputRules() { addInputRules() {
return [ return [
markInputRule(inputRegex, this.type), markInputRule({
find: inputRegex,
type: this.type,
}),
] ]
}, },
addPasteRules() { addPasteRules() {
return [ return [
markPasteRule(pasteRegex, this.type), markPasteRule({
find: pasteRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -92,7 +92,10 @@ export const HorizontalRule = Node.create<HorizontalRuleOptions>({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule(/^(?:---|—-|___\s|\*\*\*\s)$/, this.type), nodeInputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -79,10 +79,14 @@ export const Image = Node.create<ImageOptions>({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule(inputRegex, this.type, match => { nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: match => {
const [, alt, src, title] = match const [, alt, src, title] = match
return { src, alt, title } return { src, alt, title }
},
}), }),
] ]
}, },

View File

@ -81,15 +81,27 @@ export const Italic = Mark.create<ItalicOptions>({
addInputRules() { addInputRules() {
return [ return [
markInputRule(starInputRegex, this.type), markInputRule({
markInputRule(underscoreInputRegex, this.type), find: starInputRegex,
type: this.type,
}),
markInputRule({
find: underscoreInputRegex,
type: this.type,
}),
] ]
}, },
addPasteRules() { addPasteRules() {
return [ return [
markPasteRule(starPasteRegex, this.type), markPasteRule({
markPasteRule(underscorePasteRegex, this.type), find: starPasteRegex,
type: this.type,
}),
markPasteRule({
find: underscorePasteRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -24,6 +24,7 @@
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": { "dependencies": {
"linkifyjs": "^3.0.1",
"prosemirror-state": "^1.3.4" "prosemirror-state": "^1.3.4"
}, },
"repository": { "repository": {

View File

@ -4,6 +4,7 @@ import {
mergeAttributes, mergeAttributes,
} from '@tiptap/core' } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state' import { Plugin, PluginKey } from 'prosemirror-state'
import { find } from 'linkifyjs'
export interface LinkOptions { 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<LinkOptions>({ export const Link = Mark.create<LinkOptions>({
name: 'link', name: 'link',
@ -102,7 +93,19 @@ export const Link = Mark.create<LinkOptions>({
addPasteRules() { addPasteRules() {
return [ 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<LinkOptions>({
textContent += node.textContent textContent += node.textContent
}) })
if (!textContent || !textContent.match(pasteRegexExact)) { const link = find(textContent)
.find(item => item.isLink && item.value === textContent)
if (!textContent || !link) {
return false return false
} }
this.editor.commands.setMark(this.type, { this.editor.commands.setMark(this.type, {
href: textContent, href: link.href,
}) })
return true return true

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core' import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export interface OrderedListOptions { export interface OrderedListOptions {
HTMLAttributes: Record<string, any>, HTMLAttributes: Record<string, any>,
@ -74,12 +73,12 @@ export const OrderedList = Node.create<OrderedListOptions>({
addInputRules() { addInputRules() {
return [ return [
wrappingInputRule( wrappingInputRule({
inputRegex, find: inputRegex,
this.type, type: this.type,
match => ({ start: +match[1] }), getAttributes: match => ({ start: +match[1] }),
(match, node) => node.childCount + node.attrs.start === +match[1], joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1],
), }),
] ]
}, },
}) })

View File

@ -83,13 +83,19 @@ export const Strike = Mark.create<StrikeOptions>({
addInputRules() { addInputRules() {
return [ return [
markInputRule(inputRegex, this.type), markInputRule({
find: inputRegex,
type: this.type,
}),
] ]
}, },
addPasteRules() { addPasteRules() {
return [ return [
markPasteRule(pasteRegex, this.type), markPasteRule({
find: pasteRegex,
type: this.type,
}),
] ]
}, },
}) })

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core' import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export interface TaskItemOptions { export interface TaskItemOptions {
nested: boolean, nested: boolean,
@ -146,13 +145,13 @@ export const TaskItem = Node.create<TaskItemOptions>({
addInputRules() { addInputRules() {
return [ return [
wrappingInputRule( wrappingInputRule({
inputRegex, find: inputRegex,
this.type, type: this.type,
match => ({ getAttributes: match => ({
checked: match[match.length - 1] === 'x', checked: match[match.length - 1] === 'x',
}), }),
), }),
] ]
}, },
}) })

View File

@ -23,9 +23,6 @@
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.0.0-beta.1" "@tiptap/core": "^2.0.0-beta.1"
}, },
"dependencies": {
"prosemirror-inputrules": "^1.1.3"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",

View File

@ -1,29 +1,109 @@
import { Extension } from '@tiptap/core' import { Extension, textInputRule } from '@tiptap/core'
import {
emDash,
ellipsis,
openDoubleQuote,
closeDoubleQuote,
openSingleQuote,
closeSingleQuote,
InputRule,
} from 'prosemirror-inputrules'
export const leftArrow = new InputRule(/<-$/, '←') export const emDash = textInputRule({
export const rightArrow = new InputRule(/->$/, '→') find: /--$/,
export const copyright = new InputRule(/\(c\)$/, '©') replace: '—',
export const trademark = new InputRule(/\(tm\)$/, '™') })
export const registeredTrademark = new InputRule(/\(r\)$/, '®')
export const oneHalf = new InputRule(/1\/2$/, '½') export const ellipsis = textInputRule({
export const plusMinus = new InputRule(/\+\/-$/, '±') find: /\.\.\.$/,
export const notEqual = new InputRule(/!=$/, '≠') replace: '…',
export const laquo = new InputRule(/<<$/, '«') })
export const raquo = new InputRule(/>>$/, '»')
export const multiplication = new InputRule(/\d+\s?([*x])\s?\d+$/, '×') export const openDoubleQuote = textInputRule({
export const superscriptTwo = new InputRule(/\^2$/, '²') find: /(?:^|[\s{[(<'"\u2018\u201C])(")$/,
export const superscriptThree = new InputRule(/\^3$/, '³') replace: '“',
export const oneQuarter = new InputRule(/1\/4$/, '¼') })
export const threeQuarters = new InputRule(/3\/4$/, '¾')
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({ export const Typography = Extension.create({
name: 'typography', name: 'typography',

View File

@ -1,51 +0,0 @@
/// <reference types="cypress" />
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 doesnt match url: ${url}`, {
// every second test fails, but the second try succeeds
retries: {
runMode: 2,
openMode: 2,
},
}, () => {
expect(url).to.not.match(pasteRegex)
})
})
})

View File

@ -2100,14 +2100,6 @@
"@types/prosemirror-model" "*" "@types/prosemirror-model" "*"
"@types/prosemirror-state" "*" "@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": "@types/prosemirror-keymap@^1.0.4":
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/prosemirror-keymap/-/prosemirror-keymap-1.0.4.tgz#f73c79810e8d0e0a20d153d84f998f02e5afbc0c" 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" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= 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: listr2@^3.8.3:
version "3.12.2" version "3.12.2"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.12.2.tgz#2d55cc627111603ad4768a9e87c9c7bb9b49997e" 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" prosemirror-transform "^1.0.0"
rope-sequence "^1.3.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: prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2, prosemirror-keymap@^1.1.3:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.4.tgz#8b481bf8389a5ac40d38dbd67ec3da2c7eac6a6d" resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.4.tgz#8b481bf8389a5ac40d38dbd67ec3da2c7eac6a6d"