mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-12-21 07:18:05 +08:00
f387ad3dd4
* chore:(core): migrate to tsup * chore: migrate blockquote and bold to tsup * chore: migrated bubble-menu and bullet-list to tsup * chore: migrated more packages to tsup * chore: migrate code and character extensions to tsup * chore: update package.json to simplify build for all packages * chore: move all packages to tsup as a build process * chore: change ci build task * feat(pm): add prosemirror meta package * rfix: resolve issues with build paths & export mappings * docs: update documentation to include notes for @tiptap/pm * chore(pm): update tsconfig * chore(packages): update packages * fix(pm): add package export infos & fix dependencies * chore(general): start moving to pm package as deps * chore: move to tiptap pm package internally * fix(demos): fix demos working with new pm package * fix(tables): fix tables package * fix(tables): fix tables package * chore(demos): pinned typescript version * chore: remove unnecessary tsconfig * chore: fix netlify build * fix(demos): fix package resolving for pm packages * fix(tests): fix package resolving for pm packages * fix(tests): fix package resolving for pm packages * chore(tests): fix tests not running correctly after pm package * chore(pm): add files to files array * chore: update build workflow * chore(tests): increase timeout time back to 12s * chore(docs): update docs * chore(docs): update installation guides & pm information to docs * chore(docs): add link to prosemirror docs * fix(vue-3): add missing build step * chore(docs): comment out cdn link * chore(docs): remove semicolons from docs * chore(docs): remove unnecessary installation note * chore(docs): remove unnecessary installation note
261 lines
5.3 KiB
TypeScript
261 lines
5.3 KiB
TypeScript
import { EditorState, Plugin, TextSelection } from '@tiptap/pm/state'
|
||
|
||
import { CommandManager } from './CommandManager'
|
||
import { Editor } from './Editor'
|
||
import { createChainableState } from './helpers/createChainableState'
|
||
import { getTextContentFromNodes } from './helpers/getTextContentFromNodes'
|
||
import {
|
||
CanCommands,
|
||
ChainedCommands,
|
||
ExtendedRegExpMatchArray,
|
||
Range,
|
||
SingleCommands,
|
||
} from './types'
|
||
import { isRegExp } from './utilities/isRegExp'
|
||
|
||
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
|
||
commands: SingleCommands
|
||
chain: () => ChainedCommands
|
||
can: () => CanCommands
|
||
}) => void | null
|
||
|
||
constructor(config: {
|
||
find: InputRuleFinder
|
||
handler: (props: {
|
||
state: EditorState
|
||
range: Range
|
||
match: ExtendedRegExpMatchArray
|
||
commands: SingleCommands
|
||
chain: () => ChainedCommands
|
||
can: () => CanCommands
|
||
}) => void | null
|
||
}) {
|
||
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 = [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: {
|
||
editor: Editor
|
||
from: number
|
||
to: number
|
||
text: string
|
||
rules: InputRule[]
|
||
plugin: Plugin
|
||
}): boolean {
|
||
const {
|
||
editor, from, to, text, rules, plugin,
|
||
} = config
|
||
const { view } = editor
|
||
|
||
if (view.composing) {
|
||
return false
|
||
}
|
||
|
||
const $from = view.state.doc.resolve(from)
|
||
|
||
if (
|
||
// check for code node
|
||
$from.parent.type.spec.code
|
||
// check for code mark
|
||
|| !!($from.nodeBefore || $from.nodeAfter)?.marks.find(mark => mark.type.spec.code)
|
||
) {
|
||
return false
|
||
}
|
||
|
||
let matched = false
|
||
|
||
const textBefore = getTextContentFromNodes($from) + text
|
||
|
||
rules.forEach(rule => {
|
||
if (matched) {
|
||
return
|
||
}
|
||
|
||
const match = inputRuleMatcherHandler(textBefore, rule.find)
|
||
|
||
if (!match) {
|
||
return
|
||
}
|
||
|
||
const tr = view.state.tr
|
||
const state = createChainableState({
|
||
state: view.state,
|
||
transaction: tr,
|
||
})
|
||
const range = {
|
||
from: from - (match[0].length - text.length),
|
||
to,
|
||
}
|
||
|
||
const { commands, chain, can } = new CommandManager({
|
||
editor,
|
||
state,
|
||
})
|
||
|
||
const handler = rule.handler({
|
||
state,
|
||
range,
|
||
match,
|
||
commands,
|
||
chain,
|
||
can,
|
||
})
|
||
|
||
// stop if there are no changes
|
||
if (handler === null || !tr.steps.length) {
|
||
return
|
||
}
|
||
|
||
// store transform as meta data
|
||
// so we can undo input rules within the `undoInputRules` command
|
||
tr.setMeta(plugin, {
|
||
transform: tr,
|
||
from,
|
||
to,
|
||
text,
|
||
})
|
||
|
||
view.dispatch(tr)
|
||
matched = true
|
||
})
|
||
|
||
return matched
|
||
}
|
||
|
||
/**
|
||
* Create an input rules plugin. When enabled, it will cause text
|
||
* input that matches any of the given rules to trigger the rule’s
|
||
* action.
|
||
*/
|
||
export function inputRulesPlugin(props: { editor: Editor; rules: InputRule[] }): Plugin {
|
||
const { editor, rules } = props
|
||
const plugin = new Plugin({
|
||
state: {
|
||
init() {
|
||
return null
|
||
},
|
||
apply(tr, prev) {
|
||
const stored = tr.getMeta(plugin)
|
||
|
||
if (stored) {
|
||
return stored
|
||
}
|
||
|
||
return tr.selectionSet || tr.docChanged ? null : prev
|
||
},
|
||
},
|
||
|
||
props: {
|
||
handleTextInput(view, from, to, text) {
|
||
return run({
|
||
editor,
|
||
from,
|
||
to,
|
||
text,
|
||
rules,
|
||
plugin,
|
||
})
|
||
},
|
||
|
||
handleDOMEvents: {
|
||
compositionend: view => {
|
||
setTimeout(() => {
|
||
const { $cursor } = view.state.selection as TextSelection
|
||
|
||
if ($cursor) {
|
||
run({
|
||
editor,
|
||
from: $cursor.pos,
|
||
to: $cursor.pos,
|
||
text: '',
|
||
rules,
|
||
plugin,
|
||
})
|
||
}
|
||
})
|
||
|
||
return false
|
||
},
|
||
},
|
||
|
||
// add support for input rules to trigger on enter
|
||
// this is useful for example for code blocks
|
||
handleKeyDown(view, event) {
|
||
if (event.key !== 'Enter') {
|
||
return false
|
||
}
|
||
|
||
const { $cursor } = view.state.selection as TextSelection
|
||
|
||
if ($cursor) {
|
||
return run({
|
||
editor,
|
||
from: $cursor.pos,
|
||
to: $cursor.pos,
|
||
text: '\n',
|
||
rules,
|
||
plugin,
|
||
})
|
||
}
|
||
|
||
return false
|
||
},
|
||
},
|
||
|
||
// @ts-ignore
|
||
isInputRules: true,
|
||
}) as Plugin
|
||
|
||
return plugin
|
||
}
|