226 lines
8.4 KiB
JavaScript
226 lines
8.4 KiB
JavaScript
|
|
import { Plugin } from 'prosemirror-state';
|
|||
|
|
import { findWrapping, canJoin } from 'prosemirror-transform';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
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.
|
|||
|
|
*/
|
|||
|
|
class InputRule {
|
|||
|
|
// :: (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](https://prosemirror.net/docs/ref/#state.Transaction) that describes the
|
|||
|
|
rule's effect, or null to indicate the input was not handled.
|
|||
|
|
*/
|
|||
|
|
constructor(
|
|||
|
|
/**
|
|||
|
|
@internal
|
|||
|
|
*/
|
|||
|
|
match, handler, options = {}) {
|
|||
|
|
this.match = match;
|
|||
|
|
this.match = match;
|
|||
|
|
this.handler = typeof handler == "string" ? stringHandler(handler) : handler;
|
|||
|
|
this.undoable = options.undoable !== false;
|
|||
|
|
this.inCode = options.inCode || false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function stringHandler(string) {
|
|||
|
|
return function (state, match, start, end) {
|
|||
|
|
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.
|
|||
|
|
*/
|
|||
|
|
function inputRules({ rules }) {
|
|||
|
|
let plugin = new Plugin({
|
|||
|
|
state: {
|
|||
|
|
init() { return null; },
|
|||
|
|
apply(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;
|
|||
|
|
if ($cursor)
|
|||
|
|
run(view, $cursor.pos, $cursor.pos, "", rules, plugin);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
isInputRules: true
|
|||
|
|
});
|
|||
|
|
return plugin;
|
|||
|
|
}
|
|||
|
|
function run(view, from, to, text, rules, plugin) {
|
|||
|
|
if (view.composing)
|
|||
|
|
return false;
|
|||
|
|
let state = view.state, $from = state.doc.resolve(from);
|
|||
|
|
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 rule = rules[i];
|
|||
|
|
if ($from.parent.type.spec.code) {
|
|||
|
|
if (!rule.inCode)
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
else if (rule.inCode === "only") {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let match = rule.match.exec(textBefore);
|
|||
|
|
let tr = match && rule.handler(state, match, from - (match[0].length - text.length), to);
|
|||
|
|
if (!tr)
|
|||
|
|
continue;
|
|||
|
|
if (rule.undoable)
|
|||
|
|
tr.setMeta(plugin, { transform: tr, from, to, text });
|
|||
|
|
view.dispatch(tr);
|
|||
|
|
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.
|
|||
|
|
*/
|
|||
|
|
const undoInputRule = (state, dispatch) => {
|
|||
|
|
let plugins = state.plugins;
|
|||
|
|
for (let i = 0; i < plugins.length; i++) {
|
|||
|
|
let plugin = plugins[i], undoable;
|
|||
|
|
if (plugin.spec.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;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Converts double dashes to an emdash.
|
|||
|
|
*/
|
|||
|
|
const emDash = new InputRule(/--$/, "—");
|
|||
|
|
/**
|
|||
|
|
Converts three dots to an ellipsis character.
|
|||
|
|
*/
|
|||
|
|
const ellipsis = new InputRule(/\.\.\.$/, "…");
|
|||
|
|
/**
|
|||
|
|
“Smart” opening double quotes.
|
|||
|
|
*/
|
|||
|
|
const openDoubleQuote = new InputRule(/(?:^|[\s\{\[\(\<'"\u2018\u201C])(")$/, "“");
|
|||
|
|
/**
|
|||
|
|
“Smart” closing double quotes.
|
|||
|
|
*/
|
|||
|
|
const closeDoubleQuote = new InputRule(/"$/, "”");
|
|||
|
|
/**
|
|||
|
|
“Smart” opening single quotes.
|
|||
|
|
*/
|
|||
|
|
const openSingleQuote = new InputRule(/(?:^|[\s\{\[\(\<'"\u2018\u201C])(')$/, "‘");
|
|||
|
|
/**
|
|||
|
|
“Smart” closing single quotes.
|
|||
|
|
*/
|
|||
|
|
const closeSingleQuote = new InputRule(/'$/, "’");
|
|||
|
|
/**
|
|||
|
|
Smart-quote related input rules.
|
|||
|
|
*/
|
|||
|
|
const smartQuotes = [openDoubleQuote, closeDoubleQuote, openSingleQuote, closeSingleQuote];
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Build an input rule for automatically wrapping a textblock when a
|
|||
|
|
given string is typed. The `regexp` argument is
|
|||
|
|
directly passed through to the `InputRule` constructor. You'll
|
|||
|
|
probably want the regexp to start with `^`, so that the pattern can
|
|||
|
|
only occur at the start of a textblock.
|
|||
|
|
|
|||
|
|
`nodeType` is the type of node to wrap in. If it needs attributes,
|
|||
|
|
you can either pass them directly, or pass a function that will
|
|||
|
|
compute them from the regular expression match.
|
|||
|
|
|
|||
|
|
By default, if there's a node with the same type above the newly
|
|||
|
|
wrapped node, the rule will try to [join](https://prosemirror.net/docs/ref/#transform.Transform.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.
|
|||
|
|
*/
|
|||
|
|
function wrappingInputRule(regexp, nodeType, getAttrs = null, joinPredicate) {
|
|||
|
|
return new InputRule(regexp, (state, match, start, end) => {
|
|||
|
|
let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
|
|||
|
|
let tr = state.tr.delete(start, end);
|
|||
|
|
let $start = tr.doc.resolve(start), range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs);
|
|||
|
|
if (!wrapping)
|
|||
|
|
return null;
|
|||
|
|
tr.wrap(range, wrapping);
|
|||
|
|
let before = tr.doc.resolve(start - 1).nodeBefore;
|
|||
|
|
if (before && before.type == nodeType && canJoin(tr.doc, start - 1) &&
|
|||
|
|
(!joinPredicate || joinPredicate(match, before)))
|
|||
|
|
tr.join(start - 1);
|
|||
|
|
return tr;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
Build an input rule that changes the type of a textblock when the
|
|||
|
|
matched text is typed into it. You'll usually want to start your
|
|||
|
|
regexp with `^` to that it is only matched at the start of a
|
|||
|
|
textblock. The optional `getAttrs` parameter can be used to compute
|
|||
|
|
the new node's attributes, and works the same as in the
|
|||
|
|
`wrappingInputRule` function.
|
|||
|
|
*/
|
|||
|
|
function textblockTypeInputRule(regexp, nodeType, getAttrs = null) {
|
|||
|
|
return new InputRule(regexp, (state, match, start, end) => {
|
|||
|
|
let $start = state.doc.resolve(start);
|
|||
|
|
let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
|
|||
|
|
if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType))
|
|||
|
|
return null;
|
|||
|
|
return state.tr
|
|||
|
|
.delete(start, end)
|
|||
|
|
.setBlockType(start, start, nodeType, attrs);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { InputRule, closeDoubleQuote, closeSingleQuote, ellipsis, emDash, inputRules, openDoubleQuote, openSingleQuote, smartQuotes, textblockTypeInputRule, undoInputRule, wrappingInputRule };
|