Files
plainleaf/client/codemirror/markdown_enter.ts
T
Zef Hemel 8da76f230a Customized implementation of Enter handling for lists
* Fine tunes a few behaviors (like not adding extra newlines, ever
  (should fix #1629))
* When a list item ends with :, it automaticaly indents the next item
* Adds an extensive test suite with various cases

This needs some more testing, but should be a good starting point for
adding more tweaks later.
2026-04-06 15:17:48 +02:00

341 lines
9.7 KiB
TypeScript

// Custom Enter command for markdown lists, based on @codemirror/lang-markdown's
// insertNewlineContinueMarkupCommand with two modifications:
// 1. When a list item ends with ":", create an indented child item
// 2. Never insert extra blank lines for non-tight list continuation
import { markdownLanguage } from "@codemirror/lang-markdown";
import { indentUnit, syntaxTree } from "@codemirror/language";
import type { EditorState, Text } from "@codemirror/state";
import {
countColumn,
EditorSelection,
type StateCommand,
} from "@codemirror/state";
import type { SyntaxNode } from "@lezer/common";
// --- Inlined helpers from @codemirror/lang-markdown (not exported) ---
class Context {
constructor(
public node: SyntaxNode,
public from: number,
public to: number,
public spaceBefore: string,
public spaceAfter: string,
public type: string,
public item: SyntaxNode | null,
) {}
blank(maxWidth: number | null, trailing = true): string {
let result =
this.spaceBefore + (this.node.name === "Blockquote" ? ">" : "");
if (maxWidth != null) {
while (result.length < maxWidth) result += " ";
return result;
} else {
for (
let i = this.to - this.from - result.length - this.spaceAfter.length;
i > 0;
i--
) {
result += " ";
}
return result + (trailing ? this.spaceAfter : "");
}
}
marker(doc: Text, add: number): string {
const number =
this.node.name === "OrderedList"
? String(+itemNumber(this.item!, doc)[2] + add)
: "";
return this.spaceBefore + number + this.type + this.spaceAfter;
}
}
function getContext(node: SyntaxNode, doc: Text): Context[] {
const nodes: SyntaxNode[] = [],
context: Context[] = [];
for (let cur: SyntaxNode | null = node; cur; cur = cur.parent) {
if (cur.name === "FencedCode") return context;
if (cur.name === "ListItem" || cur.name === "Blockquote") {
nodes.push(cur);
}
}
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
let match;
const line = doc.lineAt(node.from),
startPos = node.from - line.from;
if (
node.name === "Blockquote" &&
(match = /^ *>( ?)/.exec(line.text.slice(startPos)))
) {
context.push(
new Context(
node,
startPos,
startPos + match[0].length,
"",
match[1],
">",
null,
),
);
} else if (
node.name === "ListItem" &&
node.parent!.name === "OrderedList" &&
(match = /^( *)\d+([.)])( *)/.exec(line.text.slice(startPos)))
) {
let after = match[3],
len = match[0].length;
if (after.length >= 4) {
after = after.slice(0, after.length - 4);
len -= 4;
}
context.push(
new Context(
node.parent!,
startPos,
startPos + len,
match[1],
after,
match[2],
node,
),
);
} else if (
node.name === "ListItem" &&
node.parent!.name === "BulletList" &&
(match = /^( *)([-+*])( {1,4}\[[ xX]\])?( +)/.exec(
line.text.slice(startPos),
))
) {
let after = match[4],
len = match[0].length;
if (after.length > 4) {
after = after.slice(0, after.length - 4);
len -= 4;
}
let type = match[2];
if (match[3]) type += match[3].replace(/[xX]/, " ");
context.push(
new Context(
node.parent!,
startPos,
startPos + len,
match[1],
after,
type,
node,
),
);
}
}
return context;
}
function itemNumber(item: SyntaxNode, doc: Text): RegExpExecArray {
return /^(\s*)(\d+)(?=[.)])/.exec(
doc.sliceString(item.from, item.from + 10),
)!;
}
function renumberList(
after: SyntaxNode,
doc: Text,
changes: { from: number; to: number; insert: string }[],
offset = 0,
) {
let prev = -1;
let node = after;
for (;;) {
if (node.name === "ListItem") {
const m = itemNumber(node, doc);
const number = +m[2];
if (prev >= 0) {
if (number !== prev + 1) return;
changes.push({
from: node.from + m[1].length,
to: node.from + m[0].length,
insert: String(prev + 2 + offset),
});
}
prev = number;
}
const next: SyntaxNode | null = node.nextSibling;
if (!next) break;
node = next;
}
}
function normalizeIndent(content: string, state: EditorState): string {
const blank = /^[ \t]*/.exec(content)![0].length;
if (!blank || state.facet(indentUnit) !== "\t") return content;
const col = countColumn(content, 4, blank);
let space = "";
for (let i = col; i > 0; ) {
if (i >= 4) {
space += "\t";
i -= 4;
} else {
space += " ";
i--;
}
}
return space + content.slice(blank);
}
// --- Custom Enter command ---
export const customEnterCommand: StateCommand = ({ state, dispatch }) => {
const tree = syntaxTree(state),
{ doc } = state;
// deno-lint-ignore no-explicit-any
let dont: any = null;
const changes = state.changeByRange((range) => {
if (
!range.empty ||
(!markdownLanguage.isActiveAt(state, range.from, -1) &&
!markdownLanguage.isActiveAt(state, range.from, 1))
) {
return (dont = { range });
}
const pos = range.from,
line = doc.lineAt(pos);
const context = getContext(tree.resolveInner(pos, -1), doc);
while (
context.length &&
context[context.length - 1].from > pos - line.from
) {
context.pop();
}
if (!context.length) return (dont = { range });
const inner = context[context.length - 1];
if (inner.to - inner.spaceAfter.length > pos - line.from) {
return (dont = { range });
}
const emptyLine =
pos >= inner.to - inner.spaceAfter.length &&
!/\S/.test(line.text.slice(inner.to));
// Empty line in list: always remove markup (nonTightLists = false)
if (inner.item && emptyLine) {
const next = context.length > 1 ? context[context.length - 2] : null;
let delTo: number,
insert = "";
if (next?.item) {
delTo = line.from + next.from;
insert = next.marker(doc, 1);
} else {
delTo = line.from + (next ? next.to : 0);
}
const changes: { from: number; to: number; insert: string }[] = [
{
from: delTo,
to: pos,
insert,
},
];
if (inner.node.name === "OrderedList") {
renumberList(inner.item, doc, changes, -2);
}
if (next && next.node.name === "OrderedList") {
renumberList(next.item!, doc, changes);
}
return {
range: EditorSelection.cursor(delTo + insert.length),
changes,
};
}
// Empty blockquote: two aligned empty quoted lines → exit
if (inner.node.name === "Blockquote" && emptyLine && line.from) {
const prevLine = doc.lineAt(line.from - 1),
quoted = />\s*$/.exec(prevLine.text);
if (quoted && quoted.index === inner.from) {
const changes = state.changes([
{ from: prevLine.from + quoted.index, to: prevLine.to },
{ from: line.from + inner.from, to: line.to },
]);
return { range: range.map(changes), changes };
}
}
// Normal continuation
const innerChanges: { from: number; to: number; insert: string }[] = [];
if (inner.node.name === "OrderedList") {
renumberList(inner.item!, doc, innerChanges);
}
const continued = inner.item && inner.item.from < line.from;
// Strip trailing whitespace from current line
let from = pos;
while (
from > line.from &&
/\s/.test(line.text.charAt(from - line.from - 1))
) {
from--;
}
// Check if line content ends with ":" (only in list context)
const lineContentEnd = from - line.from;
const endsWithColon =
inner.item &&
lineContentEnd > inner.to &&
line.text.charAt(lineContentEnd - 1) === ":";
let insert = "";
if (endsWithColon) {
// Generate blanks for outer context levels only
for (let i = 0, e = context.length - 1; i < e; i++) {
insert += context[i].blank(
countColumn(line.text, 4, context[i + 1].from) - insert.length,
);
}
// Indented child marker for innermost level
if (inner.node.name === "BulletList") {
// Use basic bullet width (2) as indent to avoid inflating for task checkboxes
insert += `${inner.spaceBefore} ${inner.type}${inner.spaceAfter}`;
} else {
// Ordered: full marker width as indent (no checkbox issue), then "1" + delimiter
insert += `${inner.blank(null)}1${inner.type}${inner.spaceAfter}`;
}
} else {
// Default: continue at same level
if (
!continued ||
/^[\s\d.)\-+*>]*/.exec(line.text)![0].length >= inner.to
) {
for (let i = 0, e = context.length - 1; i <= e; i++) {
insert +=
i === e && !continued
? context[i].marker(doc, 1)
: context[i].blank(
i < e
? countColumn(line.text, 4, context[i + 1].from) -
insert.length
: null,
);
}
}
}
insert = normalizeIndent(insert, state);
// Note: no blank line insertion for non-tight lists (removed)
innerChanges.push({
from,
to: pos,
insert: state.lineBreak + insert,
});
return {
range: EditorSelection.cursor(from + insert.length + 1),
changes: innerChanges,
};
});
if (dont) return false;
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
return true;
};