Files
plainleaf/client/codemirror/markdown_enter.test.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

187 lines
5.1 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { ensureSyntaxTree } from "@codemirror/language";
import { customEnterCommand } from "./markdown_enter.ts";
const CURSOR = "|^|";
function runEnter(input: string): string | false {
const cursorPos = input.indexOf(CURSOR);
const doc = input.slice(0, cursorPos) + input.slice(cursorPos + CURSOR.length);
const state = EditorState.create({
doc,
selection: EditorSelection.cursor(cursorPos),
extensions: [markdown()],
});
// Force synchronous parse of the full document
ensureSyntaxTree(state, state.doc.length);
let newState: EditorState | null = null;
const result = customEnterCommand({
state,
dispatch: (tr) => {
newState = tr.state;
},
});
if (!result || !newState) return false;
const newDoc = (newState as EditorState).doc.toString();
const newCursor = (newState as EditorState).selection.main.head;
return newDoc.slice(0, newCursor) + CURSOR + newDoc.slice(newCursor);
}
// --- Bullet lists ---
describe("Enter in bullet lists", () => {
test("colon at end creates indented child", () => {
expect(runEnter("- groceries:|^|")).toBe("- groceries:\n - |^|");
});
test("no colon creates sibling", () => {
expect(runEnter("- buy milk|^|")).toBe("- buy milk\n- |^|");
});
test("empty item exits list", () => {
expect(runEnter("- item one\n- |^|")).toBe("- item one\n|^|");
});
test("preserves * bullet with colon", () => {
expect(runEnter("* note:|^|")).toBe("* note:\n * |^|");
});
test("preserves + bullet with colon", () => {
expect(runEnter("+ ideas:|^|")).toBe("+ ideas:\n + |^|");
});
test("trailing whitespace before cursor is trimmed", () => {
expect(runEnter("- stuff: |^|")).toBe("- stuff:\n - |^|");
});
});
// --- Ordered lists ---
describe("Enter in ordered lists", () => {
test("colon at end creates indented 1.", () => {
expect(runEnter("1. overview:|^|")).toBe("1. overview:\n 1. |^|");
});
test("no colon creates next number", () => {
expect(runEnter("1. first item|^|")).toBe("1. first item\n2. |^|");
});
test("preserves ) delimiter with colon", () => {
expect(runEnter("3) section:|^|")).toBe("3) section:\n 1) |^|");
});
test("renumbers subsequent siblings", () => {
expect(runEnter("1. first|^|\n2. second")).toBe(
"1. first\n2. |^|\n3. second",
);
});
});
// --- Nested lists ---
describe("Enter in nested lists", () => {
test("nested bullet with colon indents deeper", () => {
expect(runEnter("- parent:\n - child:|^|")).toBe(
"- parent:\n - child:\n - |^|",
);
});
test("nested bullet without colon creates sibling", () => {
expect(runEnter("- parent\n - child|^|")).toBe(
"- parent\n - child\n - |^|",
);
});
test("empty nested item exits to parent level", () => {
expect(runEnter("- parent\n - |^|")).toBe("- parent\n- |^|");
});
});
// --- Task lists ---
describe("Enter in task lists", () => {
test("task with colon creates indented child task", () => {
expect(runEnter("- [x] project tasks:|^|")).toBe(
"- [x] project tasks:\n - [ ] |^|",
);
});
test("task without colon creates sibling task", () => {
expect(runEnter("- [x] done item|^|")).toBe("- [x] done item\n- [ ] |^|");
});
test("unchecked task with colon creates indented child task", () => {
expect(runEnter("- [ ] parent task:|^|")).toBe(
"- [ ] parent task:\n - [ ] |^|",
);
});
test("nested task with colon indents deeper", () => {
expect(runEnter("- [ ] parent:\n - [ ] child:|^|")).toBe(
"- [ ] parent:\n - [ ] child:\n - [ ] |^|",
);
});
test("task in blockquote with colon", () => {
expect(runEnter("> - [x] tasks:|^|")).toBe("> - [x] tasks:\n> - [ ] |^|");
});
});
// --- Colon not at end of line ---
describe("colon not at end of line", () => {
test("colon in key: value does not indent", () => {
expect(runEnter("- key: value|^|")).toBe("- key: value\n- |^|");
});
test("colon in time does not indent", () => {
expect(runEnter("- 10:30 meeting|^|")).toBe("- 10:30 meeting\n- |^|");
});
test("colon in URL does not indent", () => {
expect(runEnter("- see https://example.com|^|")).toBe(
"- see https://example.com\n- |^|",
);
});
});
// --- Non-tight lists (no extra blank lines) ---
describe("non-tight lists", () => {
test("no blank line inserted before new item", () => {
expect(runEnter("- a\n\n- b|^|")).toBe("- a\n\n- b\n- |^|");
});
});
// --- Blockquotes ---
describe("blockquote behavior unchanged", () => {
test("continues blockquote", () => {
expect(runEnter("> text|^|")).toBe("> text\n> |^|");
});
test("list in blockquote with colon", () => {
expect(runEnter("> - items:|^|")).toBe("> - items:\n> - |^|");
});
});
// --- Non-list context ---
describe("non-list context falls through", () => {
test("plain paragraph returns false", () => {
expect(runEnter("hello:|^|")).toBe(false);
});
test("empty document returns false", () => {
expect(runEnter("|^|")).toBe(false);
});
});