import { keymap } from 'prosemirror-keymap'; import { baseKeymap, chainCommands, exitCode } from 'prosemirror-commands'; import { undo, redo } from 'prosemirror-history'; import { undoInputRule } from 'prosemirror-inputrules'; import { splitListItem, liftListItem, sinkListItem, } from 'prosemirror-schema-list'; import { Command, Plugin } from 'prosemirror-state'; import { Schema } from 'prosemirror-model'; import { getCommand } from './commands'; import { plainTextSlice } from './plugins/paste'; const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|[oa]d)/.test(navigator.platform); export interface KeymapOptions { /** Invoked on Mod-k so the host can open its link editor UI. */ onLinkShortcut?: () => void; /** Invoked on Mod-f so the host can open its find & replace bar. */ onFindShortcut?: () => void; /** Invoked on Mod-/ so the host can open its shortcut help dialog. */ onShortcutHelp?: () => void; } /** * Editor keymap. Returned as two plugins: feature bindings first, then the * ProseMirror base keymap as fallback. */ export function buildKeymapPlugins(schema: Schema, options: KeymapOptions = {}): Plugin[] { const keys: Record = {}; keys['Mod-z'] = undo; keys['Shift-Mod-z'] = redo; if (!isMac) keys['Mod-y'] = redo; keys['Backspace'] = undoInputRule; keys['Mod-b'] = (state, dispatch, view) => getCommand(state, 'bold')!(state, dispatch, view); keys['Mod-i'] = (state, dispatch, view) => getCommand(state, 'italic')!(state, dispatch, view); keys['Mod-u'] = (state, dispatch, view) => getCommand(state, 'underline')!(state, dispatch, view); keys['Shift-Mod-x'] = (state, dispatch, view) => getCommand(state, 'strike')!(state, dispatch, view); // Google Docs convention: Ctrl+, subscript, Ctrl+. superscript. keys['Mod-,'] = (state, dispatch, view) => getCommand(state, 'subscript')!(state, dispatch, view); keys['Mod-.'] = (state, dispatch, view) => getCommand(state, 'superscript')!(state, dispatch, view); // Paste without formatting. Reads the clipboard directly so the result is // plain text regardless of the persistent toggle; falls back to the // browser default when the async Clipboard API is unavailable. keys['Shift-Mod-v'] = (_state, _dispatch, view) => { if (!view || typeof navigator === 'undefined' || !navigator.clipboard?.readText) { return false; } navigator.clipboard .readText() .then(text => { if (!text || !view) return; const slice = plainTextSlice(view.state.schema, text); view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView()); }) .catch(() => { /* clipboard denied — silently no-op */ }); return true; }; keys['Shift-Ctrl-0'] = (state, dispatch, view) => getCommand(state, 'paragraph')!(state, dispatch, view); for (let level = 1; level <= 6; level += 1) { keys[`Shift-Ctrl-${level}`] = (state, dispatch, view) => getCommand(state, 'heading', { level })!(state, dispatch, view); } keys['Shift-Mod-7'] = (state, dispatch, view) => getCommand(state, 'orderedList')!(state, dispatch, view); keys['Shift-Mod-8'] = (state, dispatch, view) => getCommand(state, 'bulletList')!(state, dispatch, view); keys['Shift-Mod-9'] = (state, dispatch, view) => getCommand(state, 'blockquote')!(state, dispatch, view); keys['Mod-Alt-c'] = (state, dispatch, view) => getCommand(state, 'codeBlock')!(state, dispatch, view); // Google Docs convention: Mod-] / Mod-[ indent and outdent. keys['Mod-]'] = (state, dispatch, view) => getCommand(state, 'indent')!(state, dispatch, view); keys['Mod-['] = (state, dispatch, view) => getCommand(state, 'outdent')!(state, dispatch, view); // New task items always start unchecked. keys['Enter'] = chainCommands( splitListItem(schema.nodes.task_item, { checked: false }), splitListItem(schema.nodes.list_item) ); keys['Tab'] = chainCommands( sinkListItem(schema.nodes.task_item), sinkListItem(schema.nodes.list_item) ); keys['Shift-Tab'] = chainCommands( liftListItem(schema.nodes.task_item), liftListItem(schema.nodes.list_item) ); // Hard break; exitCode first so breaks escape code marks cleanly. const br: Command = chainCommands(exitCode, (state, dispatch) => { if (dispatch) { dispatch( state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView() ); } return true; }); keys['Shift-Enter'] = br; keys['Mod-Enter'] = br; if (options.onLinkShortcut) { const open = options.onLinkShortcut; keys['Mod-k'] = () => { open(); return true; }; } if (options.onFindShortcut) { const openFind = options.onFindShortcut; keys['Mod-f'] = () => { openFind(); return true; }; } if (options.onShortcutHelp) { const openHelp = options.onShortcutHelp; keys['Mod-/'] = () => { openHelp(); return true; }; } return [keymap(keys), keymap(baseKeymap)]; }