/
inputrules.ts
128 lines (119 loc) · 4.82 KB
/
inputrules.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import {Plugin, Transaction, EditorState, TextSelection, Command} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
/// Input rules are regular expressions describing a piece of text
/// that, when typed, causes something to happen. This might be
/// changing two dashes into an emdash, wrapping a paragraph starting
/// with `"> "` into a blockquote, or something entirely different.
export class InputRule {
/// @internal
handler: (state: EditorState, match: RegExpMatchArray, start: number, end: number) => Transaction | null
// :: (RegExp, union<string, (state: EditorState, match: [string], start: number, end: number) → ?Transaction>)
/// Create an input rule. The rule applies when the user typed
/// something and the text directly in front of the cursor matches
/// `match`, which should end with `$`.
///
/// The `handler` can be a string, in which case the matched text, or
/// the first matched group in the regexp, is replaced by that
/// string.
///
/// Or a it can be a function, which will be called with the match
/// array produced by
/// [`RegExp.exec`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec),
/// as well as the start and end of the matched range, and which can
/// return a [transaction](#state.Transaction) that describes the
/// rule's effect, or null to indicate the input was not handled.
constructor(
/// @internal
readonly match: RegExp,
handler: string | ((state: EditorState, match: RegExpMatchArray, start: number, end: number) => Transaction | null)
) {
this.match = match
this.handler = typeof handler == "string" ? stringHandler(handler) : handler
}
}
function stringHandler(string: string) {
return function(state: EditorState, match: RegExpMatchArray, start: number, end: number) {
let insert = string
if (match[1]) {
let offset = match[0].lastIndexOf(match[1])
insert += match[0].slice(offset + match[1].length)
start += offset
let cutOff = start - end
if (cutOff > 0) {
insert = match[0].slice(offset - cutOff, offset) + insert
start = end
}
}
return state.tr.insertText(insert, start, end)
}
}
const MAX_MATCH = 500
/// 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 inputRules({rules}: {rules: readonly InputRule[]}) {
let plugin: Plugin<{transform: Transaction, from: number, to: number, text: string} | null> = new Plugin({
state: {
init() { return null },
apply(this: typeof plugin, tr, prev) {
let 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(() => {
let {$cursor} = view.state.selection as TextSelection
if ($cursor) run(view, $cursor.pos, $cursor.pos, "", rules, plugin)
})
}
}
},
isInputRules: true
})
return plugin
}
function run(view: EditorView, from: number, to: number, text: string, rules: readonly InputRule[], plugin: Plugin) {
if (view.composing) return false
let state = view.state, $from = state.doc.resolve(from)
if ($from.parent.type.spec.code) return false
let textBefore = $from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset,
null, "\ufffc") + text
for (let i = 0; i < rules.length; i++) {
let match = rules[i].match.exec(textBefore)
let tr = match && rules[i].handler(state, match, from - (match[0].length - text.length), to)
if (!tr) continue
view.dispatch(tr.setMeta(plugin, {transform: tr, from, to, text}))
return true
}
return false
}
/// This is a command that will undo an input rule, if applying such a
/// rule was the last thing that the user did.
export const undoInputRule: Command = (state, dispatch) => {
let plugins = state.plugins
for (let i = 0; i < plugins.length; i++) {
let plugin = plugins[i], undoable
if ((plugin.spec as any).isInputRules && (undoable = plugin.getState(state))) {
if (dispatch) {
let tr = state.tr, toUndo = undoable.transform
for (let j = toUndo.steps.length - 1; j >= 0; j--)
tr.step(toUndo.steps[j].invert(toUndo.docs[j]))
if (undoable.text) {
let 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)
}
dispatch(tr)
}
return true
}
}
return false
}