Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
8.7 kB · 262 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263/** * List editing keymap: Enter / Backspace / Tab / Shift-Tab. * * We deliberately do NOT use `@codemirror/lang-markdown`'s built-in * `insertNewlineContinueMarkup`: it turns a list "loose" and inserts a blank * line between items once one exists, which reads as the new item appearing two * lines below. These commands instead keep lists tight and self-contained: * * - Enter (`continueListItem`): continue the marker (with trailing space) on * the next line at the same indent, auto-incrementing ordered lists; on an * empty item, exit the list instead. * - Tab (`sinkListItem`): nest the item under its previous sibling, aligning to * the parent's content column (e.g. 3 spaces under `1. `); for ordered lists * reset the nested marker to `1` and renumber the gap it leaves behind. * - Shift-Tab (`liftListItem`): outdent one level, renumbering to continue the * new parent list and restarting the siblings left behind. * - Backspace: `deleteMarkupBackward` (deletes the marker/indent on an item). * * Each returns false when the cursor is not in a list item, so the default * Enter/Tab handlers still apply elsewhere. * * Scope: single-line, tight list items (no wrapped continuation lines). */import { Prec, type Line, type StateCommand, type ChangeSpec,} from '@codemirror/state';import { keymap } from '@codemirror/view';import { deleteMarkupBackward } from '@codemirror/lang-markdown';
interface Item { kind: 'ordered' | 'bullet'; indentLen: number; // leading-whitespace width numFrom: number; // absolute offset of the number (ordered only) num: number; // parsed number (ordered only) numLen: number; // digit count (ordered only) delim: string; // ordered delimiter (`.` or `)`); bullet char for bullets contentCol: number; // column where the item's content starts}
const ORDERED_RE = /^(\s*)(\d+)([.)])(\s+)/;const BULLET_RE = /^(\s*)([-*+])(\s+)/;
/** Parse a line as a list item, or return null if it isn't one. */function parseItem(line: Line): Item | null { const om = ORDERED_RE.exec(line.text); if (om) { const indentLen = om[1].length; return { kind: 'ordered', indentLen, numFrom: line.from + indentLen, num: Number(om[2]), numLen: om[2].length, delim: om[3], contentCol: om[0].length, }; } const bm = BULLET_RE.exec(line.text); if (bm) { return { kind: 'bullet', indentLen: bm[1].length, numFrom: 0, num: 0, numLen: 0, delim: bm[2], contentCol: bm[0].length, }; } return null;}
/** The marker text (no leading indent) for continuing/creating an item. */function markerText(item: Item, num: number): string { return item.kind === 'ordered' ? `${num}${item.delim} ` : `${item.delim} `;}
/** Change that rewrites an ordered item's number in place. */function setNumber(item: Item, value: number): ChangeSpec { return { from: item.numFrom, to: item.numFrom + item.numLen, insert: String(value), };}
export const continueListItem: StateCommand = ({ state, dispatch }) => { const sel = state.selection.main; if (!sel.empty) return false; const line = state.doc.lineAt(sel.head); const item = parseItem(line); if (!item) return false;
const contentStart = line.from + item.contentCol; // Cursor inside the marker itself — let the default newline handle it. if (sel.head < contentStart) return false;
// Empty item (no content on the line): exit the list, leaving a blank line. if (state.doc.sliceString(contentStart, line.to).trim() === '') { dispatch( state.update({ changes: { from: line.from, to: line.to, insert: '' }, selection: { anchor: line.from }, userEvent: 'input', }), ); return true; }
// Continue the list on a new (tight) line at the same indent. const indent = ' '.repeat(item.indentLen); const marker = markerText(item, item.num + 1); const insert = '\n' + indent + marker; dispatch( state.update({ changes: { from: sel.head, insert }, selection: { anchor: sel.head + insert.length }, userEvent: 'input', }), ); return true;};
export const sinkListItem: StateCommand = ({ state, dispatch }) => { const sel = state.selection.main; if (!sel.empty) return false; const line = state.doc.lineAt(sel.head); const cur = parseItem(line); if (!cur) return false;
// Find the previous sibling (same indent) to nest under. Stop at a shallower // line (that's a parent, so `cur` is a first child — cannot sink) or a blank. let prev: Item | null = null; for (let n = line.number - 1; n >= 1; n--) { const l = state.doc.line(n); if (l.text.trim() === '') break; const p = parseItem(l); if (!p) break; if (p.indentLen < cur.indentLen) break; // parent — no same-level sibling above if (p.indentLen === cur.indentLen) { prev = p; break; } // deeper line: part of an earlier sibling's sublist — keep scanning } if (!prev) return false; // first item of its list: let the default Tab run
const targetIndent = prev.contentCol; const addSpaces = targetIndent - cur.indentLen; if (addSpaces <= 0) return false;
const changes: ChangeSpec[] = [ { from: line.from, insert: ' '.repeat(addSpaces) }, ]; let cursorDelta = addSpaces;
if (cur.kind === 'ordered' && cur.num !== 1) { changes.push(setNumber(cur, 1)); cursorDelta += 1 - cur.numLen; // number width may shrink (e.g. "10" -> "1") // Close the gap left in the parent list: following same-level ordered // siblings shift down by one. for (let n = line.number + 1; n <= state.doc.lines; n++) { const l = state.doc.line(n); if (l.text.trim() === '') break; const f = parseItem(l); if (!f || f.indentLen < cur.indentLen) break; if (f.indentLen === cur.indentLen) { if (f.kind !== 'ordered') break; changes.push(setNumber(f, f.num - 1)); } // deeper lines belong to a sub-list — leave them alone } }
dispatch( state.update({ changes, selection: { anchor: sel.head + cursorDelta }, userEvent: 'input.indent', }), ); return true;};
export const liftListItem: StateCommand = ({ state, dispatch }) => { const sel = state.selection.main; if (!sel.empty) return false; const line = state.doc.lineAt(sel.head); const cur = parseItem(line); if (!cur || cur.indentLen === 0) return false; // top-level: let default outdent run
// The parent item (nearest shallower list line) defines the level we rejoin. let parent: Item | null = null; for (let n = line.number - 1; n >= 1; n--) { const l = state.doc.line(n); if (l.text.trim() === '') break; const p = parseItem(l); if (!p) break; if (p.indentLen < cur.indentLen) { parent = p; break; } } if (!parent) return false;
const removeSpaces = cur.indentLen - parent.indentLen; const changes: ChangeSpec[] = [ { from: line.from, to: line.from + removeSpaces, insert: '' }, ]; let cursorDelta = -removeSpaces;
if (cur.kind === 'ordered') { const newNum = parent.kind === 'ordered' ? parent.num + 1 : 1; if (newNum !== cur.num) { changes.push(setNumber(cur, newNum)); cursorDelta += String(newNum).length - cur.numLen; } // Following parent-level ordered siblings shift up by one (a new item joins). // Following old-nested siblings (deeper) restart from 1. let nestedSeq = 0; for (let n = line.number + 1; n <= state.doc.lines; n++) { const l = state.doc.line(n); if (l.text.trim() === '') break; const f = parseItem(l); if (!f || f.indentLen < parent.indentLen) break; if (f.indentLen === parent.indentLen) { if (f.kind !== 'ordered') break; changes.push(setNumber(f, f.num + 1)); } else if (f.indentLen === cur.indentLen) { if (f.kind !== 'ordered') break; nestedSeq += 1; changes.push(setNumber(f, nestedSeq)); } // other depths: leave alone } }
dispatch( state.update({ changes, selection: { anchor: sel.head + cursorDelta }, userEvent: 'delete.dedent', }), ); return true;};
/** * List editing bindings, high precedence so they beat the default Enter/Tab. * Each command falls through (returns false) outside a list item. */export const listEditingKeymap = Prec.high( keymap.of([ { key: 'Enter', run: continueListItem }, { key: 'Backspace', run: deleteMarkupBackward }, { key: 'Tab', run: sinkListItem }, { key: 'Shift-Tab', run: liftListItem }, ]),);