import { inputRules, wrappingInputRule, textblockTypeInputRule, InputRule, } from 'prosemirror-inputrules'; import { MarkType, Schema } from 'prosemirror-model'; import { Plugin } from 'prosemirror-state'; function escapeRegex(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** True when the cursor sits in code (block or mark) — no autocorrect there. */ function isInCodeContext(state: import('prosemirror-state').EditorState): boolean { const { $from } = state.selection; if ($from.parent.type.spec.code) return true; const code = state.schema.marks.code; return !!(code && code.isInSet(state.storedMarks || $from.marks())); } /** * String-replacement rule (the library stringHandler plus a code-context * guard): when the regex has a capture group, only the group is replaced and * the surrounding matched text is preserved. */ function replaceRule(regexp: RegExp, replacement: string): InputRule { return new InputRule(regexp, (state, match, start, end) => { if (isInCodeContext(state)) return null; let insert = replacement; let from = start; if (match.length > 1 && match[1] !== undefined) { const offset = match[0].lastIndexOf(match[1]); insert += match[0].slice(offset + match[1].length); from += offset; const cutOff = from - end; if (cutOff > 0) { insert = match[0].slice(offset - cutOff, offset) + insert; from = end; } } return state.tr.insertText(insert, from, end); }); } /** * CKEditor-style text transformations: symbols, fractions, dashes, ellipsis * and smart quotes. All skipped inside code and revertable with Backspace. */ function textTransformRules(): InputRule[] { return [ // Symbols. replaceRule(/\(c\)$/i, '©'), replaceRule(/\(r\)$/i, '®'), replaceRule(/\(tm\)$/i, '™'), // Fractions (word-bounded, completed by a space). replaceRule(/(?:^|\s)(1\/2)\s$/, '½'), replaceRule(/(?:^|\s)(1\/4)\s$/, '¼'), replaceRule(/(?:^|\s)(3\/4)\s$/, '¾'), // Dashes: " --- " → em, " -- " → en. The em rule can't be shadowed by // the en rule because the en rule requires whitespace before "--". replaceRule(/\s(---)\s$/, '—'), replaceRule(/\s(--)\s$/, '–'), // Ellipsis. replaceRule(/\.\.\.$/, '…'), // Smart quotes: opening after start/space/bracket, closing elsewhere. replaceRule(/(?:^|[\s{[(<'"‘“])(")$/, '“'), replaceRule(/"$/, '”'), replaceRule(/(?:^|[\s{[(<'"‘“])(')$/, '‘'), replaceRule(/'$/, '’'), ]; } export interface InputRulesOptions { /** Include the autocorrect/typography rules. Defaults to true. */ textTransform?: boolean; } /** * Markdown mark shortcut: typing `text` applies the mark and * strips the delimiters. The matched range (delimiters and all) is replaced * with the marked content, so the final typed delimiter never lands. */ function markInputRule(delim: string, markType: MarkType): InputRule { const escaped = escapeRegex(delim); const inner = escapeRegex(delim[0]); const regexp = new RegExp( `(?:^|\\s)${escaped}([^${inner}\\s](?:[^${inner}]*[^${inner}\\s])?)${escaped}$` ); return new InputRule(regexp, (state, match, start, end) => { const content = match[1]; const delimStart = start + match[0].indexOf(delim); const tr = state.tr; tr.delete(delimStart, end); tr.insertText(content, delimStart); tr.addMark(delimStart, delimStart + content.length, markType.create()); tr.removeStoredMark(markType); return tr; }); } /** * Autolink: typing a space after a bare URL (`https://…`, `http://…` or * `www.…`) converts it into a link. Trailing punctuation stays outside the * link, code spans and existing links are left alone, and Backspace * (undoInputRule) reverts the conversion like any other input rule. */ function linkifyRule(schema: Schema): InputRule { const linkType = schema.marks.link; const regexp = /(?:^|\s)((?:https?:\/\/|www\.)[^\s]*[^\s.,:;!?'")\]}])\s$/i; return new InputRule(regexp, (state, match, start, end) => { const url = match[1]; const urlStart = start + match[0].indexOf(url); const urlEnd = urlStart + url.length; if (state.doc.rangeHasMark(urlStart, urlEnd, linkType)) return null; if (state.doc.rangeHasMark(urlStart, urlEnd, schema.marks.code)) return null; const href = /^www\./i.test(url) ? `https://${url}` : url; const tr = state.tr; tr.addMark(urlStart, urlEnd, linkType.create({ href })); tr.removeStoredMark(linkType); tr.insertText(' ', end); return tr; }); } /** * Markdown-style typing shortcuts: block rules plus inline mark rules * (**bold**, *italic*, `code`, ~~strike~~), and (unless disabled) the * typography autocorrect rules. */ export function buildInputRules(schema: Schema, options: InputRulesOptions = {}): Plugin { const transforms = options.textTransform === false ? [] : textTransformRules(); return inputRules({ rules: [ ...transforms, // "# " … "###### " → heading textblockTypeInputRule( new RegExp('^(#{1,6})\\s$'), schema.nodes.heading, match => ({ level: match[1].length }) ), // "> " → blockquote wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), // "- " or "* " → bullet list wrappingInputRule(/^\s*([-*])\s$/, schema.nodes.bullet_list), // "1. " → ordered list wrappingInputRule( /^(\d+)\.\s$/, schema.nodes.ordered_list, match => ({ order: +match[1] }), (match, node) => node.childCount + node.attrs.order === +match[1] ), // "```" → code block textblockTypeInputRule(/^```$/, schema.nodes.code_block), // "[] " or "[ ] " → checklist wrappingInputRule(/^\s*\[[ ]?\]\s$/, schema.nodes.task_list), // Inline marks. Bold before italic so '**' is never read as italic; // strike (~~) before subscript (~) so a double tilde is never a single. markInputRule('**', schema.marks.bold), markInputRule('*', schema.marks.italic), markInputRule('~~', schema.marks.strike), markInputRule('~', schema.marks.subscript), markInputRule('^', schema.marks.superscript), markInputRule('`', schema.marks.code), // Bare URL + space → link. linkifyRule(schema), ], }); }