Skip to content

Commit 4863346

Browse files
committed
feat(editor): indent lists by the marker above
Tab always added two spaces, so a bullet under `1.` never nested. Match Inkdrop: indent by the marker width, renumber, color marks.
1 parent 2865017 commit 4863346

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,
@@ -77,6 +78,7 @@ import {
7778
} from '../utils/isMissingWikilink';
7879
import { notebookStyleProps } from '../utils/notebookStyle';
7980
import { createEditorTheme, markdownHighlighting, SCROLL_PAST_END_PADDING } from './editorTheme.js';
81+
import { listMarkHighlighter } from './editor/listMarkDecorations';
8082
import { fenceLanguageCompletions, slashCompletions } from './editor/slashCompletions';
8183
import { UrlPastePicker } from './editor/UrlPastePicker';
8284
import styles from './MarkdownEditor.module.css';
@@ -411,6 +413,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
411413
...searchKeymap,
412414
...foldKeymap,
413415
...historyKeymap,
416+
listIndentKeymap,
414417
indentWithTab,
415418
]),
416419

@@ -425,6 +428,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
425428

426429
// Syntax highlighting
427430
syntaxHighlighting(markdownHighlighting),
431+
listMarkHighlighter,
428432

429433
// Wikilink [[note]] highlighting
430434
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
@@ -69,6 +69,15 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
6969
'.cm-line': {
7070
padding: '0 4px',
7171
},
72+
'.md-list-mark': {
73+
color: 'var(--md-list-mark-color, var(--cm-list))',
74+
},
75+
'.md-list-mark-2': {
76+
color: 'var(--md-list-mark-2-color)',
77+
},
78+
'.md-list-mark-3': {
79+
color: 'var(--md-list-mark-3-color)',
80+
},
7281
'.cm-nes-ghost': {
7382
opacity: '0.45',
7483
pointerEvents: 'none',
@@ -145,8 +154,7 @@ export const markdownHighlighting = HighlightStyle.define([
145154
{ tag: tags.link, color: 'var(--cm-link)', textDecoration: 'underline' },
146155
{ tag: tags.url, color: 'var(--cm-link)' },
147156

148-
// Lists
149-
{ tag: tags.list, color: 'var(--cm-list)' },
157+
// Lists — marker color comes from listMarkDecorations (.md-list-mark*)
150158

151159
// Quotes
152160
{

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)