2023-02-03 00:37:33 +08:00
|
|
|
|
import { EditorState, Plugin, TextSelection } from '@tiptap/pm/state'
|
2022-05-20 22:31:08 +08:00
|
|
|
|
|
2023-07-01 03:03:49 +08:00
|
|
|
|
import { CommandManager } from './CommandManager.js'
|
|
|
|
|
import { Editor } from './Editor.js'
|
|
|
|
|
import { createChainableState } from './helpers/createChainableState.js'
|
|
|
|
|
import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'
|
2021-10-14 17:56:40 +08:00
|
|
|
|
import {
|
2022-05-20 22:31:08 +08:00
|
|
|
|
CanCommands,
|
|
|
|
|
ChainedCommands,
|
2021-10-14 17:56:40 +08:00
|
|
|
|
ExtendedRegExpMatchArray,
|
2022-05-20 22:31:08 +08:00
|
|
|
|
Range,
|
2021-10-14 17:56:40 +08:00
|
|
|
|
SingleCommands,
|
2023-07-01 03:03:49 +08:00
|
|
|
|
} from './types.js'
|
|
|
|
|
import { isRegExp } from './utilities/isRegExp.js'
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
export type InputRuleMatch = {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
index: number
|
|
|
|
|
text: string
|
|
|
|
|
replaceWith?: string
|
|
|
|
|
match?: RegExpMatchArray
|
|
|
|
|
data?: Record<string, any>
|
2021-10-08 21:02:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
export type InputRuleFinder = RegExp | ((text: string) => InputRuleMatch | null)
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
export class InputRule {
|
|
|
|
|
find: InputRuleFinder
|
|
|
|
|
|
|
|
|
|
handler: (props: {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
state: EditorState
|
|
|
|
|
range: Range
|
|
|
|
|
match: ExtendedRegExpMatchArray
|
|
|
|
|
commands: SingleCommands
|
|
|
|
|
chain: () => ChainedCommands
|
|
|
|
|
can: () => CanCommands
|
2022-01-10 21:55:53 +08:00
|
|
|
|
}) => void | null
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
constructor(config: {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
find: InputRuleFinder
|
2021-10-08 21:02:09 +08:00
|
|
|
|
handler: (props: {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
state: EditorState
|
|
|
|
|
range: Range
|
|
|
|
|
match: ExtendedRegExpMatchArray
|
|
|
|
|
commands: SingleCommands
|
|
|
|
|
chain: () => ChainedCommands
|
|
|
|
|
can: () => CanCommands
|
|
|
|
|
}) => void | null
|
2021-10-08 21:02:09 +08:00
|
|
|
|
}) {
|
|
|
|
|
this.find = config.find
|
|
|
|
|
this.handler = config.handler
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
const inputRuleMatcherHandler = (
|
|
|
|
|
text: string,
|
|
|
|
|
find: InputRuleFinder,
|
|
|
|
|
): ExtendedRegExpMatchArray | null => {
|
2021-10-08 21:02:09 +08:00
|
|
|
|
if (isRegExp(find)) {
|
|
|
|
|
return find.exec(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inputRuleMatch = find(text)
|
|
|
|
|
|
|
|
|
|
if (!inputRuleMatch) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-25 17:19:51 +08:00
|
|
|
|
const result: ExtendedRegExpMatchArray = [inputRuleMatch.text]
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
result.index = inputRuleMatch.index
|
|
|
|
|
result.input = text
|
|
|
|
|
result.data = inputRuleMatch.data
|
|
|
|
|
|
|
|
|
|
if (inputRuleMatch.replaceWith) {
|
|
|
|
|
if (!inputRuleMatch.text.includes(inputRuleMatch.replaceWith)) {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
console.warn(
|
|
|
|
|
'[tiptap warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".',
|
|
|
|
|
)
|
2021-10-08 21:02:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.push(inputRuleMatch.replaceWith)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function run(config: {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
editor: Editor
|
|
|
|
|
from: number
|
|
|
|
|
to: number
|
|
|
|
|
text: string
|
|
|
|
|
rules: InputRule[]
|
|
|
|
|
plugin: Plugin
|
2022-01-10 21:43:56 +08:00
|
|
|
|
}): boolean {
|
2021-10-08 21:02:09 +08:00
|
|
|
|
const {
|
2023-02-03 00:37:33 +08:00
|
|
|
|
editor, from, to, text, rules, plugin,
|
2021-10-08 21:02:09 +08:00
|
|
|
|
} = config
|
2021-10-14 17:56:40 +08:00
|
|
|
|
const { view } = editor
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
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
|
2022-05-20 22:31:08 +08:00
|
|
|
|
|
2022-05-20 23:10:30 +08:00
|
|
|
|
const textBefore = getTextContentFromNodes($from) + text
|
2022-05-20 22:31:08 +08:00
|
|
|
|
|
2021-10-08 21:02:09 +08:00
|
|
|
|
rules.forEach(rule => {
|
|
|
|
|
if (matched) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-20 23:10:30 +08:00
|
|
|
|
const match = inputRuleMatcherHandler(textBefore, rule.find)
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-14 17:56:40 +08:00
|
|
|
|
const { commands, chain, can } = new CommandManager({
|
|
|
|
|
editor,
|
|
|
|
|
state,
|
|
|
|
|
})
|
|
|
|
|
|
2022-01-10 21:43:56 +08:00
|
|
|
|
const handler = rule.handler({
|
2021-10-08 21:02:09 +08:00
|
|
|
|
state,
|
|
|
|
|
range,
|
|
|
|
|
match,
|
2021-10-14 17:56:40 +08:00
|
|
|
|
commands,
|
|
|
|
|
chain,
|
|
|
|
|
can,
|
2021-10-08 21:02:09 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// stop if there are no changes
|
2022-01-10 21:55:53 +08:00
|
|
|
|
if (handler === null || !tr.steps.length) {
|
2021-10-08 21:02:09 +08:00
|
|
|
|
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.
|
|
|
|
|
*/
|
2023-02-03 00:37:33 +08:00
|
|
|
|
export function inputRulesPlugin(props: { editor: Editor; rules: InputRule[] }): Plugin {
|
2021-10-14 17:56:40 +08:00
|
|
|
|
const { editor, rules } = props
|
2021-10-08 21:02:09 +08:00
|
|
|
|
const plugin = new Plugin({
|
|
|
|
|
state: {
|
|
|
|
|
init() {
|
|
|
|
|
return null
|
|
|
|
|
},
|
|
|
|
|
apply(tr, prev) {
|
2022-06-20 17:45:37 +08:00
|
|
|
|
const stored = tr.getMeta(plugin)
|
2021-10-08 21:02:09 +08:00
|
|
|
|
|
|
|
|
|
if (stored) {
|
|
|
|
|
return stored
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-09 19:04:16 +08:00
|
|
|
|
// if InputRule is triggered by insertContent()
|
|
|
|
|
const simulatedInputMeta = tr.getMeta('applyInputRules')
|
|
|
|
|
const isSimulatedInput = !!simulatedInputMeta
|
|
|
|
|
|
|
|
|
|
if (isSimulatedInput) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const { from, text } = simulatedInputMeta
|
|
|
|
|
const to = from + text.length
|
|
|
|
|
|
|
|
|
|
run({
|
|
|
|
|
editor,
|
|
|
|
|
from,
|
|
|
|
|
to,
|
|
|
|
|
text,
|
|
|
|
|
rules,
|
|
|
|
|
plugin,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-03 00:37:33 +08:00
|
|
|
|
return tr.selectionSet || tr.docChanged ? null : prev
|
2021-10-08 21:02:09 +08:00
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
props: {
|
|
|
|
|
handleDOMEvents: {
|
|
|
|
|
compositionend: view => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const { $cursor } = view.state.selection as TextSelection
|
|
|
|
|
|
|
|
|
|
if ($cursor) {
|
|
|
|
|
run({
|
2021-10-14 17:56:40 +08:00
|
|
|
|
editor,
|
2021-10-08 21:02:09 +08:00
|
|
|
|
from: $cursor.pos,
|
|
|
|
|
to: $cursor.pos,
|
|
|
|
|
text: '',
|
|
|
|
|
rules,
|
|
|
|
|
plugin,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
},
|
2024-08-22 18:34:27 +08:00
|
|
|
|
|
|
|
|
|
keyup(view, event) {
|
|
|
|
|
if (event.key.length === 1) {
|
|
|
|
|
return run({
|
|
|
|
|
editor,
|
|
|
|
|
from: view.state.selection.from,
|
|
|
|
|
to: view.state.selection.from,
|
|
|
|
|
text: '',
|
|
|
|
|
rules,
|
|
|
|
|
plugin,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
},
|
2021-10-08 21:02:09 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 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({
|
2021-10-14 17:56:40 +08:00
|
|
|
|
editor,
|
2021-10-08 21:02:09 +08:00
|
|
|
|
from: $cursor.pos,
|
|
|
|
|
to: $cursor.pos,
|
|
|
|
|
text: '\n',
|
|
|
|
|
rules,
|
|
|
|
|
plugin,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
isInputRules: true,
|
|
|
|
|
}) as Plugin
|
|
|
|
|
|
|
|
|
|
return plugin
|
|
|
|
|
}
|