Operand

odyssey, u hear?

gram: docs

> ./packages/core/src/prosemirror/extensions/features/BaseKeymapExtension.ts

/**
* Base Keymap Extension — wraps prosemirror-commands baseKeymap
*
* Priority: Low (150) — must be the last keymap so other extensions can override keys
*/
import {
baseKeymap,
splitBlock,
deleteSelection,
joinBackward,
joinForward,
selectAll,
selectParentNode,
} from 'prosemirror-commands';
import type { Mark, Node as PMNode, Schema } from 'prosemirror-model';
import { createExtension } from '../create';
import { textFormattingToMarks } from '../marks/markUtils';
import { Priority } from '../types';
import type { ExtensionRuntime, ExtensionContext } from '../types';
import type { Command, Transaction } from 'prosemirror-state';
import type { TextFormatting } from '../../../types/document';
import { mergeFontFamily } from '../../../utils/fontFamilyMerge';
import type { StyleResolver } from '../../styles/styleResolver';
import { paragraphAttrsFromResolvedStyle } from '../../styles/resolvedStyleAttrs';
import { getDocumentStyleResolver } from '../../plugins/documentStyles';
function chainCommands(...commands: Command[]): Command {
return (state, dispatch, view) => {
for (const cmd of commands) {
if (cmd(state, dispatch, view)) {
return true;
}
}
return false;
};
}
/**
* Backspace at the start of a paragraph clears first-line indent / hanging indent
* before joining with the previous paragraph (matches Word behavior).
*/
const clearIndentOnBackspace: Command = (state, dispatch) => {
const { $cursor } = state.selection as {
$cursor?: {
parentOffset: number;
parent: { type: { name: string }; attrs: Record<string, unknown> };
pos: number;
before: () => number;
};
};
if (!$cursor) return false;
// Only at the very start of a paragraph
if ($cursor.parentOffset !== 0) return false;
if ($cursor.parent.type.name !== 'paragraph') return false;
const attrs = $cursor.parent.attrs;
const hasFirstLine = attrs.indentFirstLine != null && (attrs.indentFirstLine as number) > 0;
const hasHanging = !!attrs.hangingIndent;
const hasIndentLeft = attrs.indentLeft != null && (attrs.indentLeft as number) > 0;
if (!hasFirstLine && !hasHanging && !hasIndentLeft) return false;
if (dispatch) {
const pos = $cursor.before();
const tr = state.tr.setNodeMarkup(pos, undefined, {
...attrs,
indentFirstLine: null,
hangingIndent: null,
indentLeft: null,
});
dispatch(tr.scrollIntoView());
}
return true;
};
/**
* Custom Enter handler: splits the block, inherits style-related attrs,
* clears paragraph borders, and preserves font marks on the new paragraph.
*
* splitBlock creates a new paragraph with default attrs (all null),
* so we must manually copy style-related attrs from the source paragraph.
* Word does NOT propagate paragraph borders (w:pBdr) on Enter.
*/
const INHERITED_PARA_ATTRS = [
'defaultTextFormatting',
'styleId',
'lineSpacing',
'lineSpacingRule',
'spaceAfter',
'spaceBefore',
'contextualSpacing',
] as const;
/** Mark types that represent style-inherited formatting (font, size, color). */
export const STYLE_MARK_NAMES = new Set(['fontFamily', 'fontSize', 'textColor']);
/**
* Replace an empty new paragraph's style with `nextStyleId`, projecting that
* style's resolved paragraph + run formatting onto the node. Stored marks are
* set from the next style's run formatting so typed text matches (and so the
* previous paragraph's marks — e.g. a heading's bold — don't carry over).
*/
function applyNextParagraphStyle(
tr: Transaction,
pos: number,
newPara: PMNode,
nextStyleId: string,
resolver: StyleResolver,
schema: Schema
): void {
const resolved = resolver.resolveParagraphStyle(nextStyleId);
tr.setNodeMarkup(pos, undefined, {
...newPara.attrs,
styleId: nextStyleId,
...paragraphAttrsFromResolvedStyle(resolved),
borders: null,
});
// setStoredMarks MUST come after setNodeMarkup — every step clears them.
tr.setStoredMarks(
resolved.runFormatting ? textFormattingToMarks(resolved.runFormatting, schema) : []
);
}
/**
* Apply post-split paragraph inheritance to `tr`. Assumes the split already
* happened and the caller's intent is that `tr.selection.$from` resolves
* into the NEW (second) paragraph. When the source style has a `w:next`,
* switches the empty new paragraph to it (e.g. heading → body text);
* otherwise copies style-related attrs from `sourcePara`, clears borders,
* and — for an empty new paragraph — syncs defaultTextFormatting and
* `setStoredMarks` so typed text inherits font / size / color from the source.
*
* Shared by the plain Enter handler and the suggesting-mode Enter handler.
*/
export function applyPostSplitInheritance(
tr: Transaction,
sourcePara: PMNode | null,
styleMarks: readonly Mark[],
schema: Schema,
resolver?: StyleResolver | null
): void {
const { $from } = tr.selection;
const newPara = $from.parent;
if (newPara.type.name !== 'paragraph') return;
// Word's `w:next`: pressing Enter at the end of a paragraph (the new
// paragraph is empty) switches it to the style's follow-on style — e.g.
// a heading drops to body text. Only applies to an empty trailing
// paragraph; splitting mid-paragraph keeps the style on both halves.
if (resolver && sourcePara && newPara.textContent.length === 0) {
const nextStyleId = resolver.getNextStyleId(sourcePara.attrs.styleId as string | null);
if (nextStyleId) {
applyNextParagraphStyle(tr, $from.before(), newPara, nextStyleId, resolver, schema);
return;
}
}
const newAttrs: Record<string, unknown> = { ...newPara.attrs };
let attrsChanged = false;
if (sourcePara) {
for (const key of INHERITED_PARA_ATTRS) {
const srcVal = (sourcePara.attrs as Record<string, unknown>)[key];
if (srcVal != null && newAttrs[key] == null) {
newAttrs[key] = srcVal;
attrsChanged = true;
}
}
}
if (newAttrs.borders) {
newAttrs.borders = null;
attrsChanged = true;
}
if (attrsChanged) {
tr.setNodeMarkup($from.before(), undefined, newAttrs);
}
if (newPara.textContent.length !== 0) return;
let effectiveMarks: Mark[] = [...styleMarks];
if (effectiveMarks.length === 0 && sourcePara) {
const dtf = sourcePara.attrs.defaultTextFormatting as TextFormatting | undefined;
if (dtf) {
const allMarks = textFormattingToMarks(dtf, schema);
effectiveMarks = allMarks.filter((m) => STYLE_MARK_NAMES.has(m.type.name));
}
}
if (effectiveMarks.length === 0) return;
const dtf: TextFormatting = { ...((newAttrs.defaultTextFormatting as TextFormatting) ?? {}) };
let dtfChanged = false;
for (const m of effectiveMarks) {
if (m.type.name === 'fontSize' && (m.attrs.size as number) !== dtf.fontSize) {
dtf.fontSize = m.attrs.size as number;
dtfChanged = true;
}
if (m.type.name === 'fontFamily') {
const ascii = m.attrs.ascii as string | undefined;
if (ascii && (!dtf.fontFamily || dtf.fontFamily.ascii !== ascii)) {
dtf.fontFamily = mergeFontFamily(dtf.fontFamily, {
ascii,
hAnsi: m.attrs.hAnsi as string | undefined,
}) as TextFormatting['fontFamily'];
dtfChanged = true;
}
}
}
if (dtfChanged) {
tr.setNodeMarkup($from.before(), undefined, {
...newAttrs,
defaultTextFormatting: dtf,
});
}
// setStoredMarks MUST come after setNodeMarkup — ReplaceStep clears stored marks.
tr.setStoredMarks(effectiveMarks);
}
const splitBlockClearBorders: Command = (state, dispatch, view) => {
// Capture source paragraph info BEFORE split (splitBlock resets everything)
const { $from: preSplitFrom } = state.selection;
const sourcePara = preSplitFrom.parent.type.name === 'paragraph' ? preSplitFrom.parent : null;
// Collect style marks from the cursor position before splitting.
// Use storedMarks if set, otherwise resolve from the position.
const preMarks = state.storedMarks || preSplitFrom.marks();
const styleMarks = preMarks.filter((m) => STYLE_MARK_NAMES.has(m.type.name));
// Intercept splitBlock's transaction so we can modify it before dispatch.
// This ensures attrs + stored marks are set in a single transaction,
// avoiding a flash where the empty paragraph has no formatting.
let splitTr: Transaction | null = null;
const capturingDispatch = dispatch
? (tr: Transaction) => {
splitTr = tr;
}
: undefined;
if (!splitBlock(state, capturingDispatch, view)) {
return false;
}
if (dispatch && splitTr !== null) {
const tr = splitTr as Transaction;
applyPostSplitInheritance(
tr,
sourcePara,
styleMarks,
state.schema,
getDocumentStyleResolver(state)
);
dispatch(tr.scrollIntoView());
}
return true;
};
export const BaseKeymapExtension = createExtension({
name: 'baseKeymap',
priority: Priority.Low,
onSchemaReady(_ctx: ExtensionContext): ExtensionRuntime {
return {
keyboardShortcuts: {
// Base keymap provides default editing commands
...baseKeymap,
// Override some keys with better defaults
Enter: splitBlockClearBorders,
Backspace: chainCommands(deleteSelection, clearIndentOnBackspace, joinBackward),
Delete: chainCommands(deleteSelection, joinForward),
'Mod-a': selectAll,
Escape: selectParentNode,
},
};
},
});