// Everything the keyboard does inside a document: the chords, and the typing rules that turn // markdown as you type it into the thing it means. // // Both live here rather than on the generated node extensions in extensions.ts, because those are // mechanically derived from the schema in src/model/schema.ts and have no behaviour of their own. // This is the one file to read to know what a key does. // // The typing rules are not a markdown syntax mode. Nothing they produce leaves syntax on screen: // "## " becomes a heading and the hashes are gone, "**bold**" becomes bold and the stars are gone. // That is the same WYSIWYG promise the rest of the editor makes, arrived at from the keyboard. // // Cmd+K is deliberately absent. src/keys/bindings.ts binds it globally to the command palette from // a capture-phase listener, so a link shortcut on that chord would never see the key. Links are a // toolbar control. import { Extension, InputRule, markInputRule, nodeInputRule, textblockTypeInputRule, wrappingInputRule, } from "@tiptap/core"; import type { Editor } from "@tiptap/core"; import type { NodeType } from "@tiptap/pm/model"; import { isInTable } from "@tiptap/pm/tables"; import { HEADING_LEVELS } from "../model/schema"; import type { MarkName } from "../model/schema"; import { breakable, change, fits } from "./fits"; const STAR_BOLD = /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/; const UNDERSCORE_BOLD = /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/; const STAR_ITALIC = /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/; const UNDERSCORE_ITALIC = /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/; const STRIKETHROUGH = /(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))$/; const CODE = /(^|[^`])`([^`]+)`(?!`)$/; const BULLET = /^\s*([-+*])\s$/; const ORDERED = /^(\d+)\.\s$/; const QUOTE = /^\s*>\s$/; const FENCE = /^```([a-zA-Z0-9_+-]+)?[\s\n]$/; const RULE = /^(?:---|\*\*\*|___)\s$/; const HEADING = /^(#{1,6})\s$/; const CHECKBOX = /^\s*\[([ xX])\]\s$/; /** * "- [ ] " inside a list. The bullet rule has already fired on the dash by the time the box is * typed, so this converts the item that is now there rather than wrapping a paragraph. */ function checkboxRule(editor: Editor): InputRule { return new InputRule({ find: CHECKBOX, handler: ({ state, range, match, chain }) => { const checked = match[1].toLowerCase() === "x"; const taskItem = editor.schema.nodes.taskItem; const taskList = editor.schema.nodes.taskList; const $from = state.doc.resolve(range.from); for (let depth = $from.depth; depth > 0; depth -= 1) { const item = $from.node(depth); if (item.type.name !== "listItem" && item.type.name !== "taskItem") continue; const itemPos = $from.before(depth); const list = $from.node(depth - 1); const listPos = $from.before(depth - 1); chain() .deleteRange(range) .command(({ tr }) => { tr.setNodeMarkup(itemPos, taskItem, { checked }); // A list of one becomes a task list outright; a list that still holds plain items // stays what it is, which the schema allows and the bridge writes back as a mixed // list rather than reformatting the items that were not touched. if (list.type.name === "bulletList" && list.childCount === 1) { tr.setNodeMarkup(listPos, taskList, list.attrs); } return true; }) .run(); return; } chain().deleteRange(range).wrapInList(taskList).run(); }, }); } /** * "--- " as a horizontal rule, and as three characters of text where a rule cannot go. * * The third way a node gets placed in this document, after the toolbar and the clipboard, and the * one the guard in fits.ts had not been wired into. The other typing rules ask a question of their * own before they fire, because wrapping and changing a block type are operations ProseMirror * refuses outright when the result would not fit; a node inserted next to the caret is not, so this * one fired anywhere and `tr.insert` did what it always does with a block that has nowhere to go. * Typing "--- " in a table cell cut the table in two around the rule and left a row of empty cells * behind where the text had been. * * Declining is returning null, which is how an InputRule says the match was not for it: the run * loop drops the transaction and the characters stay as the characters that were typed. So "--- " * in a cell is the text "--- ", which is what it is in GFM anyway, and in a list item or a callout * it is still the rule it has always been. */ function ruleInputRule(type: NodeType): InputRule { const rule = nodeInputRule({ find: RULE, type }); return new InputRule({ find: RULE, handler: (props) => { if (!fits(props.state.doc.resolve(props.range.from), type)) return null; return rule.handler(props); }, }); } export const Shortcuts = Extension.create({ name: "shortcuts", addKeyboardShortcuts() { const editor = this.editor; const mark = (name: MarkName) => () => editor.commands.toggleMark(name); // Every chord that changes what a block is goes through the same guard the toolbar's own // conversions go through, and for the same reason: a chord is a command like any other, and // the file does not care which of the two the user reached for. Mod-Alt-2 in a raw block // rewrote the user's html as an escaped heading, and Mod-Alt-0 in a toggle deleted the toggle // and its title, both of them while the toolbar items beside them were being fixed. const headings = Object.fromEntries( HEADING_LEVELS.map((level) => [ `Mod-Alt-${level}`, () => change(editor, "convert", (chain) => chain.toggleNode("heading", "paragraph", { level })), ]), ); return { ...headings, "Mod-b": mark("strong"), "Mod-i": mark("em"), "Mod-e": mark("code"), "Mod-Shift-x": mark("strikethrough"), "Mod-Alt-0": () => change(editor, "convert", (chain) => chain.setNode("paragraph")), "Mod-Shift-7": () => change(editor, "wrap", (chain) => chain.toggleList("orderedList", "listItem")), "Mod-Shift-8": () => change(editor, "wrap", (chain) => chain.toggleList("bulletList", "listItem")), "Mod-Shift-9": () => change(editor, "wrap", (chain) => chain.toggleList("taskList", "taskItem")), "Mod-Shift-b": () => change(editor, "wrap", (chain) => chain.toggleWrap("blockquote")), "Mod-Alt-c": () => change(editor, "convert", (chain) => chain.toggleNode("codeBlock", "paragraph")), // Falls through to the core keymap's exit-code binding when the cursor is not in a task. "Mod-Enter": () => editor.commands.command(({ state, tr, dispatch }) => { const { $from } = state.selection; for (let depth = $from.depth; depth > 0; depth -= 1) { const item = $from.node(depth); if (item.type.name !== "taskItem") continue; if (dispatch) { tr.setNodeMarkup($from.before(depth), undefined, { ...item.attrs, checked: !item.attrs.checked, }); } return true; } return false; }), // A fence and a raw block take the newline as the newline they are holding; everywhere else // this is a `
`, and the guard is which of those the file can hold. A cell cannot: GFM // gives a cell one line, so the serializer writes a break inside one as a space and the next // open of the file has no break in it. Nothing was lost, and an editor drawing a line the // file swallows is still an editor showing a save that did not happen. "Shift-Enter": () => { if (editor.commands.newlineInCode()) return true; // Claimed rather than declined, which is the difference between refusing and letting // somebody else do it: a shortcut that answers false leaves the keydown to the browser, // and a browser handed Shift+Enter in a contenteditable puts a
in by itself. if (!breakable(editor.state)) return true; return editor.commands.insertContent({ type: "hardBreak" }); }, Enter: () => editor.commands.first(({ commands }) => [ () => commands.splitListItem("taskItem"), () => commands.splitListItem("listItem"), ]), // Tab inside a table is src/editor/blocks/tables.ts, which binds it ahead of this one and // answers with prosemirror-tables' own cell walk, claiming the key in the first and the last // cell too so that neither of these is reached from inside one. What is left here is the list // case, and the guard is still said out loud because that is an ordering rather than a rule // and these two are the direction that costs a document. A cell holds inline content and can // never hold a list of its own, so the only item either command could find to act on is the // one the table itself is nested in: a table indented under a bullet, with the whole list // lifted apart by a key the user pressed to move back one cell. Tab: () => !isInTable(editor.state) && (editor.commands.sinkListItem("taskItem") || editor.commands.sinkListItem("listItem")), "Shift-Tab": () => !isInTable(editor.state) && (editor.commands.liftListItem("taskItem") || editor.commands.liftListItem("listItem")), }; }, addInputRules() { const { schema } = this.editor; return [ textblockTypeInputRule({ find: HEADING, type: schema.nodes.heading, getAttributes: (match) => ({ level: match[1].length }), }), textblockTypeInputRule({ find: FENCE, type: schema.nodes.codeBlock, getAttributes: (match) => ({ language: match[1] ?? null, meta: null }), }), wrappingInputRule({ find: BULLET, type: schema.nodes.bulletList }), wrappingInputRule({ find: ORDERED, type: schema.nodes.orderedList, getAttributes: (match) => ({ start: Number(match[1]) }), joinPredicate: (match, node) => node.childCount + node.attrs.start === Number(match[1]), }), wrappingInputRule({ find: QUOTE, type: schema.nodes.blockquote }), ruleInputRule(schema.nodes.horizontalRule), checkboxRule(this.editor), markInputRule({ find: STAR_BOLD, type: schema.marks.strong }), markInputRule({ find: UNDERSCORE_BOLD, type: schema.marks.strong }), markInputRule({ find: STAR_ITALIC, type: schema.marks.em }), markInputRule({ find: UNDERSCORE_ITALIC, type: schema.marks.em }), markInputRule({ find: STRIKETHROUGH, type: schema.marks.strikethrough }), markInputRule({ find: CODE, type: schema.marks.code }), ]; }, });