import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { Transform } from "prosemirror-transform"; import { EditorView } from "prosemirror-view"; import { EditorSchema } from "../schema"; import { PluginDescriptor } from "../util/PluginDescriptor"; const MAX_MATCH = 500; /** * A fork of prosemirror-inputrules. */ export class TextShortcut { public readonly match: RegExp; public readonly handler: ( state: EditorState, match: string[], start: number, end: number, insertText: string ) => Transaction | null | undefined; constructor(match: TextShortcut["match"], handler: TextShortcut["handler"] | string) { this.match = match; this.handler = typeof handler == "string" ? stringHandler(handler) : handler; } } function stringHandler(text: string): TextShortcut["handler"] { return (state, match, start, end) => { let insert = text; const [matchText, matchCapture] = match as [string, string | undefined]; if (matchCapture !== undefined) { const offset = matchText.lastIndexOf(matchCapture); insert += matchText.slice(offset + matchCapture.length); start += offset; const cutOff = start - end; if (cutOff > 0) { insert = matchText.slice(offset - cutOff, offset) + insert; start = end; } } const marks = state.doc.resolve(start).marks(); return state.tr.replaceWith(start, end, state.schema.text(insert, marks)); }; } interface PluginState { undoable: { transform: Transform; from: number; to: number; text: string; } | null; } const { key, getPluginState, getPluginStateOrThrow, setPluginState } = new PluginDescriptor("TextShortcutPlugin"); export class TextShortcutPlugin extends Plugin { private readonly textShortcuts: TextShortcut[]; constructor(textShortcuts: TextShortcut[]) { super({ key, state: { init(): PluginState { return { undoable: null }; }, apply(tr, curr: PluginState): PluginState { const nextPluginState = getPluginState(tr); return nextPluginState !== null ? nextPluginState : tr.selectionSet || tr.docChanged ? { undoable: null } : curr; } }, props: { handleTextInput: (view, from, to, text) => { return this.applyTextShortcuts(view, from, to, text); } } }); this.textShortcuts = textShortcuts; } private readonly applyTextShortcuts = (view: EditorView, from: number, to: number, insertText: string) => { const state = view.state; const $from = state.doc.resolve(from); if ($from.parent.type.spec.code === true) { return false; } const textBefore = $from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, undefined, "\ufffc") + insertText; for (const textShortcut of this.textShortcuts) { const match = textShortcut.match.exec(textBefore); const tr = match !== null ? textShortcut.handler(state, match, from - (match[0].length - insertText.length), to, insertText) : null; if (tr != null) { setPluginState({ state: view.state, tr }, { undoable: { transform: tr, from, to, text: insertText } }); view.dispatch(tr); return true; } } return false; }; } // This is a command that will undo a text shortcut, if applying such a rule was // the last thing that the user did. export function undoTextShortcut(state: EditorState, dispatch?: (transaction: Transaction) => void): boolean { const { undoable } = getPluginStateOrThrow(state); if (undoable !== null) { if (dispatch !== undefined) { const tr = state.tr; const toUndo = undoable.transform; for (let j = toUndo.steps.length - 1; j >= 0; j--) { tr.step(toUndo.steps[j].invert(toUndo.docs[j])); } const marks = tr.doc.resolve(undoable.from).marks(); dispatch(tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks))); } return true; } return false; }