Skip to content

Commit 6634239

Browse files
authored
feat(editor): indent lists by the marker above (#523)
## Why Tab always indented by two spaces. That nests a bullet under `-`, but not under `1.` (needs 3) or `10.` (needs 4). Inkdrop v6.1 fixed this last week; we still had `indentWithTab`. ## What - Tab / Shift-Tab indent a list item by the marker width of the item above (`-` → 2, `1.` → 3, `10.` → 4). Descendants move with the parent. - Numbered lists in the same region are renumbered in the same undo step. - List markers in the editor are colored by nesting level (`--md-list-mark-color`, `--md-list-mark-2-color`, `--md-list-mark-3-color`). Deeper levels cycle. Preview markers stay text-colored unless a theme opts into `--mde-preview-list-marker-*`. - Tables still own Tab inside a cell (plugin keymap is later). Plain lines still fall through to `indentWithTab`. Independent of #522. ## Test plan - [ ] In a note: `1. parent` then `- child`. Tab on the bullet — it should sit three spaces in and render nested. - [ ] Shift-Tab lifts it back. One undo reverts both the indent and any renumber. - [ ] `1. a` / `2. b` / `3. c` — Tab on `2.` → nested `1. b`, outer `2. c`. - [ ] Nested bullets get a second/third marker color in the editor. - [ ] Tab inside a table cell still moves between cells. - [ ] Tab on a non-list line still indents by tab size.
1 parent 73f1078 commit 6634239

11 files changed

Lines changed: 665 additions & 3 deletions

File tree

apps/desktop/src/renderer/components/MarkdownEditor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
wrapSelectionWithUrl,
5454
continueMarkupKeymap,
5555
editorLineKeymap,
56+
listIndentKeymap,
5657
} from '@dripnex/commands';
5758
import {
5859
createWikilinkHighlighter,
@@ -78,6 +79,7 @@ import {
7879
} from '../utils/isMissingWikilink';
7980
import { notebookStyleProps } from '../utils/notebookStyle';
8081
import { createEditorTheme, markdownHighlighting, SCROLL_PAST_END_PADDING } from './editorTheme.js';
82+
import { listMarkHighlighter } from './editor/listMarkDecorations';
8183
import { fenceLanguageCompletions, slashCompletions } from './editor/slashCompletions';
8284
import { UrlPastePicker } from './editor/UrlPastePicker';
8385
import styles from './MarkdownEditor.module.css';
@@ -412,6 +414,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
412414
...searchKeymap,
413415
...foldKeymap,
414416
...historyKeymap,
417+
listIndentKeymap,
415418
indentWithTab,
416419
]),
417420

@@ -426,6 +429,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
426429

427430
// Syntax highlighting
428431
syntaxHighlighting(markdownHighlighting),
432+
listMarkHighlighter,
429433

430434
// Wikilink [[note]] highlighting
431435
wikilinkHighlightCompartment.of(createWikilinkHighlighter(null)),

apps/desktop/src/renderer/components/editor/MarkdownPreview.module.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,30 @@
264264
margin: 0.25em 0;
265265
}
266266

267+
.markdown-preview li::marker {
268+
color: var(--mde-preview-list-marker-color);
269+
}
270+
271+
.markdown-preview li li::marker {
272+
color: var(--mde-preview-list-marker-2-color);
273+
}
274+
275+
.markdown-preview li li li::marker {
276+
color: var(--mde-preview-list-marker-3-color);
277+
}
278+
279+
.markdown-preview li li li li::marker {
280+
color: var(--mde-preview-list-marker-color);
281+
}
282+
283+
.markdown-preview li li li li li::marker {
284+
color: var(--mde-preview-list-marker-2-color);
285+
}
286+
287+
.markdown-preview li li li li li li::marker {
288+
color: var(--mde-preview-list-marker-3-color);
289+
}
290+
267291
/* Task lists (GFM) */
268292
.markdown-preview ul:global(.contains-task-list) {
269293
list-style: none;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { EditorState } from '@codemirror/state';
3+
import { markdown } from '@codemirror/lang-markdown';
4+
import { syntaxTree } from '@codemirror/language';
5+
import { listMarkLevelClass } from '../listMarkDecorations';
6+
7+
describe('listMarkLevelClass', () => {
8+
it('cycles every three nesting levels', () => {
9+
expect(listMarkLevelClass(1)).toBe('md-list-mark');
10+
expect(listMarkLevelClass(2)).toBe('md-list-mark md-list-mark-2');
11+
expect(listMarkLevelClass(3)).toBe('md-list-mark md-list-mark-3');
12+
expect(listMarkLevelClass(4)).toBe('md-list-mark');
13+
expect(listMarkLevelClass(5)).toBe('md-list-mark md-list-mark-2');
14+
});
15+
});
16+
17+
describe('lezer markdown list marks', () => {
18+
it('exposes ListMark nodes at each nesting level', () => {
19+
const state = EditorState.create({
20+
doc: '- a\n - b\n - c',
21+
extensions: [markdown()],
22+
});
23+
const marks: { from: number; to: number; depth: number }[] = [];
24+
syntaxTree(state).iterate({
25+
enter(node) {
26+
if (node.name !== 'ListMark') return;
27+
let depth = 0;
28+
let parent = node.node.parent;
29+
while (parent) {
30+
if (parent.name === 'ListItem') depth += 1;
31+
parent = parent.parent;
32+
}
33+
marks.push({ from: node.from, to: node.to, depth });
34+
},
35+
});
36+
expect(marks.map(m => m.depth)).toEqual([1, 2, 3]);
37+
expect(state.doc.sliceString(marks[0]!.from, marks[0]!.to)).toMatch(/-/);
38+
});
39+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Color Markdown list markers by nesting depth.
3+
*
4+
* Level 1 uses `--md-list-mark-color`; levels 2 and 3 use the matching
5+
* tokens; deeper levels cycle. Only the marker is colored.
6+
*/
7+
8+
import { syntaxTree } from '@codemirror/language';
9+
import { RangeSetBuilder } from '@codemirror/state';
10+
import { Decoration, ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
11+
12+
export function listMarkLevelClass(depth: number): string {
13+
const cycle = ((Math.max(1, depth) - 1) % 3) + 1;
14+
if (cycle === 1) return 'md-list-mark';
15+
return `md-list-mark md-list-mark-${cycle}`;
16+
}
17+
18+
function listMarkDecorations(view: EditorView) {
19+
const builder = new RangeSetBuilder<Decoration>();
20+
for (const { from, to } of view.visibleRanges) {
21+
syntaxTree(view.state).iterate({
22+
from,
23+
to,
24+
enter(node) {
25+
if (node.name !== 'ListMark') return;
26+
let depth = 0;
27+
let parent = node.node.parent;
28+
while (parent) {
29+
if (parent.name === 'ListItem') depth += 1;
30+
parent = parent.parent;
31+
}
32+
if (depth < 1) depth = 1;
33+
builder.add(node.from, node.to, Decoration.mark({ class: listMarkLevelClass(depth) }));
34+
},
35+
});
36+
}
37+
return builder.finish();
38+
}
39+
40+
export const listMarkHighlighter = ViewPlugin.fromClass(
41+
class {
42+
decorations = Decoration.none;
43+
44+
constructor(view: EditorView) {
45+
this.decorations = listMarkDecorations(view);
46+
}
47+
48+
update(update: ViewUpdate) {
49+
if (update.docChanged || update.viewportChanged) {
50+
this.decorations = listMarkDecorations(update.view);
51+
}
52+
}
53+
},
54+
{ decorations: value => value.decorations }
55+
);

apps/desktop/src/renderer/components/editorTheme.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
7070
'.cm-line': {
7171
padding: '0 4px',
7272
},
73+
'.md-list-mark': {
74+
color: 'var(--md-list-mark-color, var(--cm-list))',
75+
},
76+
'.md-list-mark-2': {
77+
color: 'var(--md-list-mark-2-color)',
78+
},
79+
'.md-list-mark-3': {
80+
color: 'var(--md-list-mark-3-color)',
81+
},
7382
'.cm-nes-ghost': {
7483
opacity: '0.45',
7584
pointerEvents: 'none',
@@ -181,8 +190,7 @@ export const markdownHighlighting = HighlightStyle.define([
181190
{ tag: tags.link, color: 'var(--cm-link)', textDecoration: 'underline' },
182191
{ tag: tags.url, color: 'var(--cm-link)' },
183192

184-
// Lists
185-
{ tag: tags.list, color: 'var(--cm-list)' },
193+
// Lists — marker color comes from listMarkDecorations (.md-list-mark*)
186194

187195
// Quotes
188196
{

apps/desktop/src/renderer/ui/tokens/tokens.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@
121121
--chrome-header-padding: 8px 10px;
122122
--chrome-traffic-inset: 72px;
123123

124+
/* ── Editor list marks (Inkdrop --md-list-mark-*) ─────────────────────────── */
125+
--cm-list: var(--accent);
126+
--md-list-mark-color: var(--accent);
127+
--md-list-mark-2-color: #60a5fa;
128+
--md-list-mark-3-color: #c4b5fd;
129+
--mde-preview-list-marker-color: var(--text-primary);
130+
--mde-preview-list-marker-2-color: var(--text-primary);
131+
--mde-preview-list-marker-3-color: var(--text-primary);
132+
124133
/* ── Radii ────────────────────────────────────────────────────────────────── */
125134
--radius-sm: 4px;
126135
--radius-md: 6px;
@@ -209,6 +218,14 @@
209218
--status-completed: #16a34a;
210219
--status-dropped: #e11d48;
211220

221+
--cm-list: var(--accent);
222+
--md-list-mark-color: var(--accent);
223+
--md-list-mark-2-color: #2563eb;
224+
--md-list-mark-3-color: #7c3aed;
225+
--mde-preview-list-marker-color: var(--text-primary);
226+
--mde-preview-list-marker-2-color: var(--text-primary);
227+
--mde-preview-list-marker-3-color: var(--text-primary);
228+
212229
--glass-bg: rgba(247, 246, 243, 0.88);
213230
--glass-bg-fallback: rgba(247, 246, 243, 0.96);
214231
--glass-bg-menu: rgba(255, 255, 255, 0.94);

packages/commands/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ export {
4545
editorLineKeymap,
4646
} from './markdown/lineCommands.js';
4747

48+
export {
49+
parseListLine,
50+
formatListLine,
51+
indentList,
52+
indentListItem,
53+
dedentListItem,
54+
listIndentKeymap,
55+
} from './markdown/listIndent.js';
56+
export type { ParsedListLine, ListKind } from './markdown/listIndent.js';
57+
4858
export {
4959
SLASH_ITEMS,
5060
FENCE_LANGUAGES,

0 commit comments

Comments
 (0)