Skip to content

Commit 6bfd740

Browse files
List refactor #161
2 parents 5206abb + 2793b54 commit 6bfd740

13 files changed

Lines changed: 1545 additions & 717 deletions

File tree

app/src/features/editor/extensions/list-rendering.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,6 @@ describe("List rendering", () => {
273273
expect(insertTaskCheckbox(view)).toBe(true);
274274
expect(view.state.doc.toString()).toBe("- [ ] ");
275275
expect(view.state.selection.main.head).toBe(6);
276-
expect(view.state.selection.main.assoc).toBe(1);
277276

278277
view.destroy();
279278
});
@@ -325,13 +324,15 @@ describe("List rendering", () => {
325324
expect(line?.getAttribute("style")).toContain("--cm-md-list-child-indent");
326325
expect(line?.textContent?.startsWith(" ")).toBe(false);
327326

327+
// Empty lines between continuation paragraphs should NOT get
328+
// list-child styling (no lazy continuation support).
328329
const emptyChildLine = [...view.dom.querySelectorAll(".cm-line")].find(
329330
(element) =>
330331
element.classList.contains("cm-md-list-child") &&
331332
(element.textContent ?? "") === "",
332333
);
333334

334-
expect(emptyChildLine).toBeDefined();
335+
expect(emptyChildLine).toBeUndefined();
335336

336337
view.destroy();
337338
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { lists } from "@/features/editor/extensions/markdown-decorations/lists";
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// @vitest-environment jsdom
2+
3+
import { EditorState } from "@codemirror/state";
4+
import {
5+
markdown as markdownLanguage,
6+
markdownLanguage as markdownLang,
7+
} from "@codemirror/lang-markdown";
8+
import { describe, expect, it, beforeEach } from "vitest";
9+
10+
import {
11+
computeRenumberChanges,
12+
getListItems,
13+
getListItemAtLine,
14+
getListItemForLine,
15+
getListItemAtPosition,
16+
_invalidateListModelCache,
17+
} from "@/features/editor/extensions/lists/list-model";
18+
19+
function createState(doc: string): EditorState {
20+
return EditorState.create({
21+
doc,
22+
extensions: [
23+
markdownLanguage({
24+
base: markdownLang,
25+
}),
26+
],
27+
});
28+
}
29+
30+
beforeEach(() => {
31+
_invalidateListModelCache();
32+
});
33+
34+
describe("list model", () => {
35+
it("builds items for a simple flat list", () => {
36+
const state = createState("- alpha\n- beta\n- gamma");
37+
const items = getListItems(state);
38+
39+
expect(items).toHaveLength(3);
40+
expect(items[0].marker).toBe("-");
41+
expect(items[0].depth).toBe(0);
42+
expect(items[0].parentItem).toBeNull();
43+
expect(items[0].prevSibling).toBeNull();
44+
expect(items[0].nextSibling).toBe(items[1]);
45+
expect(items[1].prevSibling).toBe(items[0]);
46+
expect(items[1].nextSibling).toBe(items[2]);
47+
expect(items[2].nextSibling).toBeNull();
48+
});
49+
50+
it("builds correct marker positions", () => {
51+
const state = createState("- hello");
52+
const items = getListItems(state);
53+
54+
expect(items).toHaveLength(1);
55+
expect(items[0].markerFrom).toBe(0); // "-" starts at 0
56+
expect(items[0].markerTo).toBe(2); // "- " ends at 2
57+
expect(items[0].contentFrom).toBe(2); // content starts at 2
58+
expect(items[0].lineFrom).toBe(0);
59+
expect(items[0].lineTo).toBe(7);
60+
});
61+
62+
it("detects task items", () => {
63+
const state = createState("- [ ] unchecked\n- [x] checked\n- plain");
64+
const items = getListItems(state);
65+
66+
expect(items).toHaveLength(3);
67+
expect(items[0].task).not.toBeNull();
68+
expect(items[0].task?.checked).toBe(false);
69+
expect(items[0].contentFrom).toBe(6); // after "- [ ] "
70+
expect(items[1].task?.checked).toBe(true);
71+
expect(items[2].task).toBeNull();
72+
});
73+
74+
it("builds nested list structure", () => {
75+
const state = createState("- parent\n - child1\n - child2");
76+
const items = getListItems(state);
77+
78+
expect(items).toHaveLength(3);
79+
80+
// Parent is depth 0
81+
expect(items[0].depth).toBe(0);
82+
expect(items[0].children).toHaveLength(2);
83+
expect(items[0].parentItem).toBeNull();
84+
85+
// Children are depth 1
86+
expect(items[1].depth).toBe(1);
87+
expect(items[1].parentItem).toBe(items[0]);
88+
expect(items[1].prevSibling).toBeNull();
89+
expect(items[1].nextSibling).toBe(items[2]);
90+
91+
expect(items[2].depth).toBe(1);
92+
expect(items[2].parentItem).toBe(items[0]);
93+
expect(items[2].prevSibling).toBe(items[1]);
94+
expect(items[2].nextSibling).toBeNull();
95+
});
96+
97+
it("handles ordered lists", () => {
98+
const state = createState("1. first\n2. second");
99+
const items = getListItems(state);
100+
101+
expect(items).toHaveLength(2);
102+
expect(items[0].marker).toBe("1.");
103+
expect(items[1].marker).toBe("2.");
104+
});
105+
106+
it("computes continuation prefix", () => {
107+
const state = createState("- item");
108+
const items = getListItems(state);
109+
110+
// Bullet list: 2-space continuation
111+
expect(items[0].continuationPrefix).toBe(" ");
112+
});
113+
114+
it("computes ordered list continuation prefix", () => {
115+
const state = createState("1. item");
116+
const items = getListItems(state);
117+
118+
// Ordered list: 3-space continuation
119+
expect(items[0].continuationPrefix).toBe(" ");
120+
});
121+
122+
it("caches results per state", () => {
123+
const state = createState("- item");
124+
const items1 = getListItems(state);
125+
const items2 = getListItems(state);
126+
127+
expect(items1).toBe(items2); // same reference
128+
});
129+
130+
it("getListItemAtLine finds item by line start", () => {
131+
const state = createState("- alpha\n- beta");
132+
const items = getListItems(state);
133+
134+
expect(getListItemAtLine(state, 0)).toBe(items[0]);
135+
expect(getListItemAtLine(state, 8)).toBe(items[1]); // "- beta" starts at 8
136+
expect(getListItemAtLine(state, 5)).toBeNull(); // not a line start
137+
});
138+
139+
it("getListItemForLine finds item by any position on the line", () => {
140+
const state = createState("- alpha\n- beta");
141+
142+
const item = getListItemForLine(state, 3); // middle of "alpha"
143+
expect(item).not.toBeNull();
144+
expect(item?.marker).toBe("-");
145+
expect(item?.lineFrom).toBe(0);
146+
});
147+
148+
it("getListItemAtPosition finds item in marker range", () => {
149+
const state = createState("- hello");
150+
151+
expect(getListItemAtPosition(state, 0)).not.toBeNull(); // at "-"
152+
expect(getListItemAtPosition(state, 1)).not.toBeNull(); // at " "
153+
expect(getListItemAtPosition(state, 2)).not.toBeNull(); // at contentFrom
154+
expect(getListItemAtPosition(state, 3)).toBeNull(); // inside content
155+
});
156+
157+
it("handles non-list content gracefully", () => {
158+
const state = createState("just a paragraph\n\nno lists here");
159+
const items = getListItems(state);
160+
expect(items).toHaveLength(0);
161+
});
162+
163+
it("handles mixed content with lists", () => {
164+
const state = createState("paragraph\n\n- item\n\nanother paragraph");
165+
const items = getListItems(state);
166+
167+
expect(items).toHaveLength(1);
168+
expect(items[0].marker).toBe("-");
169+
});
170+
171+
it("computes indent style string", () => {
172+
const state = createState("- item");
173+
const items = getListItems(state);
174+
175+
expect(items[0].indentStyle).toContain("--cm-md-list-child-indent");
176+
expect(items[0].indentStyle).toContain("calc(");
177+
});
178+
});
179+
180+
describe("computeRenumberChanges", () => {
181+
it("returns null for correctly numbered lists", () => {
182+
const state = createState("1. first\n2. second\n3. third");
183+
expect(computeRenumberChanges(state)).toBeNull();
184+
});
185+
186+
it("fixes misnumbered items", () => {
187+
const state = createState("1. first\n1. second\n1. third");
188+
const changes = computeRenumberChanges(state);
189+
expect(changes).not.toBeNull();
190+
expect(changes).toHaveLength(2);
191+
expect(changes![0].insert).toBe("2.");
192+
expect(changes![1].insert).toBe("3.");
193+
});
194+
195+
it("fixes gaps in numbering", () => {
196+
const state = createState("1. first\n5. second\n9. third");
197+
const changes = computeRenumberChanges(state);
198+
expect(changes).not.toBeNull();
199+
expect(changes).toHaveLength(2);
200+
expect(changes![0].insert).toBe("2.");
201+
expect(changes![1].insert).toBe("3.");
202+
});
203+
204+
it("returns null for bullet lists", () => {
205+
const state = createState("- first\n- second\n- third");
206+
expect(computeRenumberChanges(state)).toBeNull();
207+
});
208+
209+
it("does not renumber bullet items mixed with ordered", () => {
210+
const state = createState("- bullet\n1. ordered\n2. ordered2");
211+
const changes = computeRenumberChanges(state);
212+
expect(changes).toBeNull();
213+
});
214+
});

0 commit comments

Comments
 (0)