+28
-64
web/src/components/NoteEditor.tsx
+28
-64
web/src/components/NoteEditor.tsx
···
1
1
/* eslint-disable solid/no-innerhtml */
2
2
import { EditorToolbar } from "$components/notes/EditorToolbar";
3
+
import { MarkdownEditor, type MarkdownEditorAPI } from "$components/notes/MarkdownEditor";
3
4
import { api } from "$lib/api";
4
5
import type { Note } from "$lib/model";
5
6
import { toast } from "$lib/toast";
···
12
13
import rehypeStringify from "rehype-stringify";
13
14
import remarkParse from "remark-parse";
14
15
import remarkRehype from "remark-rehype";
15
-
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
16
+
import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js";
16
17
import { unified } from "unified";
17
18
18
19
export type EditorFont = "neon" | "argon" | "krypton" | "radon" | "xenon" | "jetbrains" | "google";
···
21
22
22
23
type EditorTab = "write" | "preview";
23
24
24
-
function getFontName(font: EditorFont | (() => EditorFont)) {
25
-
switch (typeof font === "function" ? font() : font) {
26
-
case "neon":
27
-
return "Monaspace Neon";
28
-
case "argon":
29
-
return "Monaspace Argon";
30
-
case "krypton":
31
-
return "Monaspace Krypton";
32
-
case "radon":
33
-
return "Monaspace Radon";
34
-
case "xenon":
35
-
return "Monaspace Xenon";
36
-
case "google":
37
-
return "Google Sans Code";
38
-
default:
39
-
return "JetBrains Mono";
40
-
}
41
-
}
42
-
43
25
const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use(
44
26
rehypeExternalLinks,
45
27
{ target: "_blank", rel: ["nofollow"] },
···
57
39
const [editorFont, setEditorFont] = createSignal<EditorFont>("jetbrains");
58
40
const [activeTab, setActiveTab] = createSignal<EditorTab>("write");
59
41
60
-
let textareaRef: HTMLTextAreaElement | undefined;
42
+
let editorApi: MarkdownEditorAPI | undefined;
61
43
let textcomplete: Textcomplete | undefined;
62
44
63
45
const [allNotes] = createResource(async (): Promise<Note[]> => {
···
75
57
updatePreviewContent().catch(e => console.error(`Preview error: ${e instanceof Error ? e.message : e}`));
76
58
});
77
59
78
-
onMount(() => {
79
-
if (!textareaRef) return;
60
+
onCleanup(() => {
61
+
textcomplete?.destroy();
62
+
});
63
+
64
+
const initTextcomplete = (api: MarkdownEditorAPI) => {
65
+
editorApi = api;
66
+
const textarea = api.getTextarea();
67
+
if (!textarea) return;
80
68
81
-
const editor = new TextareaEditor(textareaRef);
69
+
const editor = new TextareaEditor(textarea);
82
70
textcomplete = new Textcomplete(editor, [{
83
71
match: /\[\[([^\]]*)/,
84
72
search: (term: string, callback: (results: string[]) => void) => {
···
91
79
replace: (title: string) => `[[${title}]]`,
92
80
template: (title: string) => title,
93
81
}]);
94
-
});
95
-
96
-
onCleanup(() => {
97
-
textcomplete?.destroy();
98
-
});
99
-
100
-
const fontValue = createMemo(() => getFontName(editorFont));
101
-
102
-
const insertAtCursor = (before: string, after: string = "") => {
103
-
if (!textareaRef) return;
104
-
const start = textareaRef.selectionStart;
105
-
const end = textareaRef.selectionEnd;
106
-
const text = content();
107
-
const selectedText = text.substring(start, end);
108
-
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
109
-
setContent(newText);
110
-
setTimeout(() => {
111
-
textareaRef!.focus();
112
-
textareaRef!.setSelectionRange(start + before.length, start + before.length + selectedText.length);
113
-
}, 0);
114
82
};
83
+
84
+
const insertAtCursor = (before: string, after: string = "") => editorApi?.insertAtCursor(before, after);
115
85
116
86
const handleBold = () => insertAtCursor("**", "**");
117
87
const handleItalic = () => insertAtCursor("*", "*");
···
141
111
}
142
112
};
143
113
114
+
const handleEditorKeyDown = (e: KeyboardEvent) => handleKeyDown(e);
115
+
144
116
const handleSubmit = async (e: Event) => {
145
117
e.preventDefault();
146
118
try {
···
182
154
toast.error("Failed to save note");
183
155
}
184
156
};
185
-
186
-
const lineNumbers = () => Array.from({ length: content().split("\n").length }, (_, i) => i + 1);
187
157
188
158
return (
189
159
<div class="max-w-5xl mx-auto p-6">
···
253
223
</div>
254
224
255
225
<Show when={activeTab() === "write"}>
256
-
<div class="border border-slate-700 border-t-0 rounded-b-lg overflow-hidden">
226
+
<div
227
+
class="border border-slate-700 border-t-0 rounded-b-lg overflow-hidden"
228
+
onKeyDown={handleEditorKeyDown}>
257
229
<EditorToolbar
258
230
onBold={handleBold}
259
231
onItalic={handleItalic}
···
263
235
onCodeBlock={handleCodeBlock}
264
236
onWikilink={handleWikilink}
265
237
onList={handleList} />
266
-
<div class="flex">
267
-
<Show when={showLineNumbers()}>
268
-
<div
269
-
class={`bg-slate-900 border-r border-slate-700 text-slate-600 text-right px-2 py-3 select-none text-sm leading-relaxed`}>
270
-
<For each={lineNumbers()}>{(num) => <div>{num}</div>}</For>
271
-
</div>
272
-
</Show>
273
-
<textarea
274
-
ref={textareaRef}
275
-
value={content()}
276
-
onInput={(e) => setContent(e.target.value)}
277
-
style={{ "font-family": fontValue() }}
278
-
onKeyDown={handleKeyDown}
279
-
class={`flex-1 bg-slate-800 text-white p-3 text-sm leading-relaxed resize-none focus:outline-none min-h-[400px]`}
280
-
placeholder="# Heading
238
+
<MarkdownEditor
239
+
value={content()}
240
+
onChange={setContent}
241
+
showLineNumbers={showLineNumbers()}
242
+
font={editorFont()}
243
+
ref={initTextcomplete}
244
+
placeholder="# Heading
281
245
282
246
Write your thoughts...
283
247
284
-
Link to other notes with [[Title]]" />
285
-
</div>
248
+
Link to other notes with [[Title]]"
249
+
class="bg-slate-800 min-h-[400px]" />
286
250
</div>
287
251
</Show>
288
252
+220
web/src/components/notes/MarkdownEditor.tsx
+220
web/src/components/notes/MarkdownEditor.tsx
···
1
+
import type { EditorFont } from "$components/NoteEditor";
2
+
import { type BundledTheme, codeToHtml } from "shiki";
3
+
import { type Component, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
4
+
5
+
export type MarkdownEditorProps = {
6
+
value: string;
7
+
onChange: (value: string) => void;
8
+
placeholder?: string;
9
+
showLineNumbers?: boolean;
10
+
font?: EditorFont;
11
+
theme?: BundledTheme;
12
+
class?: string;
13
+
/** Ref callback to expose insertAtCursor method */
14
+
ref?: (api: MarkdownEditorAPI) => void;
15
+
};
16
+
17
+
export type MarkdownEditorAPI = {
18
+
insertAtCursor: (before: string, after?: string) => void;
19
+
focus: () => void;
20
+
getTextarea: () => HTMLTextAreaElement | undefined;
21
+
insertTab: () => void;
22
+
};
23
+
24
+
function escapeHtml(text: string): string {
25
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(
26
+
/'/g,
27
+
"'",
28
+
);
29
+
}
30
+
31
+
function getFontFamily(font: EditorFont | undefined): string {
32
+
switch (font) {
33
+
case "neon":
34
+
return "Monaspace Neon, monospace";
35
+
case "argon":
36
+
return "Monaspace Argon, monospace";
37
+
case "krypton":
38
+
return "Monaspace Krypton, monospace";
39
+
case "radon":
40
+
return "Monaspace Radon, monospace";
41
+
case "xenon":
42
+
return "Monaspace Xenon, monospace";
43
+
case "google":
44
+
return "Google Sans Code, monospace";
45
+
default:
46
+
return "JetBrains Mono, monospace";
47
+
}
48
+
}
49
+
50
+
/**
51
+
* A markdown editor component with live syntax highlighting.
52
+
*
53
+
* Uses a layered approach: hidden textarea for input + visible div with highlighted HTML overlay.
54
+
* Handles IME composition events to prevent disruption during CJK input.
55
+
*/
56
+
export const MarkdownEditor: Component<MarkdownEditorProps> = (props) => {
57
+
let textareaRef: HTMLTextAreaElement | undefined;
58
+
let overlayRef: HTMLDivElement | undefined;
59
+
let containerRef: HTMLDivElement | undefined;
60
+
61
+
const [highlightedHtml, setHighlightedHtml] = createSignal("");
62
+
const [isComposing, setIsComposing] = createSignal(false);
63
+
const [currentLine, setCurrentLine] = createSignal(1);
64
+
65
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
66
+
67
+
const updateHighlight = async (text: string) => {
68
+
if (isComposing()) return;
69
+
70
+
try {
71
+
const textToHighlight = text || " ";
72
+
const html = await codeToHtml(textToHighlight, { lang: "markdown", theme: props.theme ?? "vitesse-dark" });
73
+
setHighlightedHtml(html);
74
+
} catch (e) {
75
+
console.error("Highlight error:", e);
76
+
setHighlightedHtml(`<pre><code>${escapeHtml(text)}</code></pre>`);
77
+
}
78
+
};
79
+
80
+
const debouncedHighlight = (text: string) => {
81
+
if (debounceTimer) clearTimeout(debounceTimer);
82
+
debounceTimer = setTimeout(() => updateHighlight(text), 50);
83
+
};
84
+
85
+
const syncScroll = () => {
86
+
if (textareaRef && overlayRef) {
87
+
overlayRef.scrollTop = textareaRef.scrollTop;
88
+
overlayRef.scrollLeft = textareaRef.scrollLeft;
89
+
}
90
+
};
91
+
92
+
const handleCompositionStart = () => {
93
+
setIsComposing(true);
94
+
};
95
+
96
+
const handleCompositionEnd = () => {
97
+
setIsComposing(false);
98
+
debouncedHighlight(props.value);
99
+
};
100
+
101
+
const handleInput = (e: InputEvent) => {
102
+
const target = e.target as HTMLTextAreaElement;
103
+
props.onChange(target.value);
104
+
updateCurrentLine(target);
105
+
if (!isComposing()) {
106
+
debouncedHighlight(target.value);
107
+
}
108
+
};
109
+
110
+
const updateCurrentLine = (textarea: HTMLTextAreaElement) => {
111
+
const text = textarea.value.substring(0, textarea.selectionStart);
112
+
const line = text.split("\n").length;
113
+
setCurrentLine(line);
114
+
};
115
+
116
+
const handleKeyDown = (e: KeyboardEvent) => {
117
+
if (e.key === "Tab") {
118
+
e.preventDefault();
119
+
insertTab();
120
+
}
121
+
};
122
+
123
+
const handleSelect = () => {
124
+
if (textareaRef) {
125
+
updateCurrentLine(textareaRef);
126
+
}
127
+
};
128
+
129
+
const insertTab = () => {
130
+
insertAtCursor(" "); // 2-space soft tab
131
+
};
132
+
133
+
const insertAtCursor = (before: string, after: string = "") => {
134
+
if (!textareaRef) return;
135
+
136
+
const start = textareaRef.selectionStart;
137
+
const end = textareaRef.selectionEnd;
138
+
const text = props.value;
139
+
const selectedText = text.substring(start, end);
140
+
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
141
+
142
+
props.onChange(newText);
143
+
144
+
setTimeout(() => {
145
+
if (textareaRef) {
146
+
textareaRef.focus();
147
+
const newCursorPos = start + before.length + selectedText.length;
148
+
textareaRef.setSelectionRange(newCursorPos, newCursorPos);
149
+
}
150
+
}, 0);
151
+
};
152
+
153
+
onMount(() => {
154
+
if (props.ref) {
155
+
props.ref({ insertAtCursor, focus: () => textareaRef?.focus(), getTextarea: () => textareaRef, insertTab });
156
+
}
157
+
158
+
updateHighlight(props.value);
159
+
});
160
+
161
+
onCleanup(() => {
162
+
if (debounceTimer) clearTimeout(debounceTimer);
163
+
});
164
+
165
+
createEffect(() => {
166
+
const value = props.value;
167
+
if (!isComposing()) {
168
+
debouncedHighlight(value);
169
+
}
170
+
});
171
+
172
+
const lineNumbers = () => Array.from({ length: Math.max(props.value.split("\n").length, 1) }, (_, i) => i + 1);
173
+
const fontFamily = () => getFontFamily(props.font);
174
+
175
+
return (
176
+
<div ref={containerRef} class={`relative overflow-hidden ${props.class ?? ""}`} style={{ "min-height": "400px" }}>
177
+
<div class="flex h-full">
178
+
<Show when={props.showLineNumbers !== false}>
179
+
<div
180
+
class="bg-slate-900 border-r border-slate-700 text-slate-600 text-right px-2 py-3 select-none text-sm leading-relaxed shrink-0"
181
+
style={{ "font-family": fontFamily() }}
182
+
aria-hidden="true">
183
+
<For each={lineNumbers()}>
184
+
{(num) => <div class={num === currentLine() ? "text-blue-400" : ""}>{num}</div>}
185
+
</For>
186
+
</div>
187
+
</Show>
188
+
189
+
<div class="relative flex-1 min-h-full">
190
+
<div
191
+
ref={overlayRef}
192
+
class="absolute inset-0 p-3 text-sm leading-relaxed overflow-hidden pointer-events-none whitespace-pre-wrap break-word"
193
+
style={{ "font-family": fontFamily() }}>
194
+
{/* eslint-disable-next-line solid/no-innerhtml */}
195
+
<div class="shiki-editor-highlight" innerHTML={highlightedHtml()} />
196
+
</div>
197
+
198
+
<textarea
199
+
ref={textareaRef}
200
+
value={props.value}
201
+
onInput={handleInput}
202
+
onScroll={syncScroll}
203
+
onKeyDown={handleKeyDown}
204
+
onSelect={handleSelect}
205
+
onClick={handleSelect}
206
+
onCompositionStart={handleCompositionStart}
207
+
onCompositionEnd={handleCompositionEnd}
208
+
placeholder={props.placeholder}
209
+
aria-label="Markdown editor"
210
+
aria-multiline="true"
211
+
class="absolute inset-0 w-full h-full p-3 text-sm leading-relaxed resize-none focus:outline-none bg-transparent text-transparent caret-white selection:bg-blue-500/30"
212
+
style={{ "font-family": fontFamily(), "white-space": "pre-wrap", "word-break": "break-word" }}
213
+
spellcheck={false} />
214
+
</div>
215
+
</div>
216
+
</div>
217
+
);
218
+
};
219
+
220
+
export default MarkdownEditor;
+414
web/src/components/notes/tests/MarkdownEditor.test.tsx
+414
web/src/components/notes/tests/MarkdownEditor.test.tsx
···
1
+
import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library";
2
+
import { createSignal } from "solid-js";
3
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
import { MarkdownEditor, type MarkdownEditorAPI } from "../MarkdownEditor";
5
+
6
+
vi.mock("shiki", () => ({ codeToHtml: vi.fn(async (code: string) => `<pre><code>${code}</code></pre>`) }));
7
+
8
+
describe("MarkdownEditor", () => {
9
+
afterEach(() => {
10
+
cleanup();
11
+
vi.clearAllMocks();
12
+
});
13
+
14
+
describe("Basic Rendering", () => {
15
+
it("renders editor with initial value", () => {
16
+
const onChange = vi.fn();
17
+
render(() => <MarkdownEditor value="# Hello" onChange={onChange} />);
18
+
19
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
20
+
expect(textarea).toBeInTheDocument();
21
+
expect(textarea.value).toBe("# Hello");
22
+
});
23
+
24
+
it("renders placeholder when provided", () => {
25
+
const onChange = vi.fn();
26
+
render(() => <MarkdownEditor value="" onChange={onChange} placeholder="Write here..." />);
27
+
28
+
const textarea = screen.getByPlaceholderText("Write here...");
29
+
expect(textarea).toBeInTheDocument();
30
+
});
31
+
32
+
it("renders line numbers by default", () => {
33
+
const [value, setValue] = createSignal("line1\nline2\nline3");
34
+
render(() => <MarkdownEditor value={value()} onChange={setValue} />);
35
+
36
+
expect(screen.getByText("1")).toBeInTheDocument();
37
+
expect(screen.getByText("2")).toBeInTheDocument();
38
+
expect(screen.getByText("3")).toBeInTheDocument();
39
+
});
40
+
41
+
it("hides line numbers when showLineNumbers is false", () => {
42
+
const onChange = vi.fn();
43
+
render(() => <MarkdownEditor value="line1\nline2" onChange={onChange} showLineNumbers={false} />);
44
+
45
+
expect(screen.queryByText("1")).not.toBeInTheDocument();
46
+
expect(screen.queryByText("2")).not.toBeInTheDocument();
47
+
});
48
+
});
49
+
50
+
describe("Input Handling", () => {
51
+
it("calls onChange when user types", async () => {
52
+
const onChange = vi.fn();
53
+
render(() => <MarkdownEditor value="" onChange={onChange} />);
54
+
55
+
const textarea = screen.getByRole("textbox");
56
+
fireEvent.input(textarea, { target: { value: "new text" } });
57
+
58
+
expect(onChange).toHaveBeenCalledWith("new text");
59
+
});
60
+
61
+
it("updates value when controlled externally", () => {
62
+
const [value, setValue] = createSignal("initial");
63
+
render(() => <MarkdownEditor value={value()} onChange={setValue} />);
64
+
65
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
66
+
expect(textarea.value).toBe("initial");
67
+
68
+
setValue("updated");
69
+
expect(textarea.value).toBe("updated");
70
+
});
71
+
});
72
+
73
+
describe("Cursor Positioning", () => {
74
+
it("maintains selectionStart and selectionEnd on textarea", () => {
75
+
const onChange = vi.fn();
76
+
render(() => <MarkdownEditor value="Hello World" onChange={onChange} />);
77
+
78
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
79
+
textarea.setSelectionRange(0, 5);
80
+
81
+
expect(textarea.selectionStart).toBe(0);
82
+
expect(textarea.selectionEnd).toBe(5);
83
+
});
84
+
85
+
it("preserves cursor position when onChange updates value", async () => {
86
+
const onChange = vi.fn();
87
+
88
+
render(() => <MarkdownEditor value="Hello" onChange={onChange} />);
89
+
90
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
91
+
92
+
textarea.focus();
93
+
textarea.setSelectionRange(5, 5);
94
+
fireEvent.input(textarea, { target: { value: "Hello!" } });
95
+
96
+
expect(onChange).toHaveBeenCalled();
97
+
});
98
+
99
+
it("selection range preserved during typing", () => {
100
+
const onChange = vi.fn();
101
+
render(() => <MarkdownEditor value="ABC" onChange={onChange} />);
102
+
103
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
104
+
textarea.setSelectionRange(0, 3);
105
+
106
+
expect(textarea.selectionStart).toBe(0);
107
+
expect(textarea.selectionEnd).toBe(3);
108
+
});
109
+
});
110
+
111
+
describe("IME Composition Handling", () => {
112
+
let editorApi: MarkdownEditorAPI | undefined;
113
+
114
+
beforeEach(() => {
115
+
editorApi = undefined;
116
+
});
117
+
118
+
it("blocks highlight updates during composition", async () => {
119
+
const onChange = vi.fn();
120
+
render(() => (
121
+
<MarkdownEditor
122
+
value=""
123
+
onChange={onChange}
124
+
ref={(api) => {
125
+
editorApi = api;
126
+
void editorApi;
127
+
}} />
128
+
));
129
+
130
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
131
+
132
+
fireEvent.compositionStart(textarea);
133
+
fireEvent.input(textarea, { target: { value: "中" } });
134
+
expect(onChange).toHaveBeenCalledWith("中");
135
+
});
136
+
137
+
it("triggers highlight after compositionend", async () => {
138
+
const onChange = vi.fn();
139
+
render(() => <MarkdownEditor value="" onChange={onChange} />);
140
+
141
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
142
+
143
+
fireEvent.compositionStart(textarea);
144
+
fireEvent.input(textarea, { target: { value: "日本語" } });
145
+
fireEvent.compositionEnd(textarea);
146
+
expect(onChange).toHaveBeenCalledWith("日本語");
147
+
});
148
+
149
+
it("cursor position stable during IME input", () => {
150
+
const onChange = vi.fn();
151
+
render(() => <MarkdownEditor value="prefix " onChange={onChange} />);
152
+
153
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
154
+
textarea.setSelectionRange(7, 7);
155
+
fireEvent.compositionStart(textarea);
156
+
expect(textarea.selectionStart).toBe(7);
157
+
});
158
+
});
159
+
160
+
describe("Toolbar Integration via API", () => {
161
+
let editorApi: MarkdownEditorAPI | undefined;
162
+
163
+
beforeEach(() => {
164
+
editorApi = undefined;
165
+
});
166
+
167
+
it("exposes insertAtCursor via ref callback", () => {
168
+
const onChange = vi.fn();
169
+
render(() => (
170
+
<MarkdownEditor
171
+
value="Hello World"
172
+
onChange={onChange}
173
+
ref={(api) => {
174
+
editorApi = api;
175
+
}} />
176
+
));
177
+
178
+
expect(editorApi).toBeDefined();
179
+
expect(typeof editorApi?.insertAtCursor).toBe("function");
180
+
});
181
+
182
+
it("insertAtCursor inserts text at cursor position", async () => {
183
+
let currentValue = "Hello World";
184
+
const onChange = (val: string) => {
185
+
currentValue = val;
186
+
};
187
+
188
+
render(() => (
189
+
<MarkdownEditor
190
+
value={currentValue}
191
+
onChange={onChange}
192
+
ref={(api) => {
193
+
editorApi = api;
194
+
}} />
195
+
));
196
+
197
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
198
+
199
+
textarea.focus();
200
+
textarea.setSelectionRange(6, 6);
201
+
editorApi?.insertAtCursor("**", "**");
202
+
expect(currentValue).toBe("Hello ****World");
203
+
});
204
+
205
+
it("insertAtCursor wraps selected text", async () => {
206
+
let currentValue = "Hello World";
207
+
const onChange = (val: string) => {
208
+
currentValue = val;
209
+
};
210
+
211
+
render(() => (
212
+
<MarkdownEditor
213
+
value={currentValue}
214
+
onChange={onChange}
215
+
ref={(api) => {
216
+
editorApi = api;
217
+
}} />
218
+
));
219
+
220
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
221
+
222
+
textarea.focus();
223
+
textarea.setSelectionRange(6, 11);
224
+
editorApi?.insertAtCursor("**", "**");
225
+
expect(currentValue).toBe("Hello **World**");
226
+
});
227
+
228
+
it("focus method focuses the textarea", () => {
229
+
const onChange = vi.fn();
230
+
render(() => (
231
+
<MarkdownEditor
232
+
value=""
233
+
onChange={onChange}
234
+
ref={(api) => {
235
+
editorApi = api;
236
+
}} />
237
+
));
238
+
239
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
240
+
expect(document.activeElement).not.toBe(textarea);
241
+
242
+
editorApi?.focus();
243
+
expect(document.activeElement).toBe(textarea);
244
+
});
245
+
246
+
it("getTextarea returns the textarea element", () => {
247
+
const onChange = vi.fn();
248
+
render(() => (
249
+
<MarkdownEditor
250
+
value=""
251
+
onChange={onChange}
252
+
ref={(api) => {
253
+
editorApi = api;
254
+
}} />
255
+
));
256
+
257
+
const textarea = screen.getByRole("textbox");
258
+
const apiTextarea = editorApi?.getTextarea();
259
+
260
+
expect(apiTextarea).toBe(textarea);
261
+
});
262
+
});
263
+
264
+
describe("Line Numbers", () => {
265
+
it("updates line count when text changes", async () => {
266
+
const [value, setValue] = createSignal("line1");
267
+
render(() => <MarkdownEditor value={value()} onChange={setValue} />);
268
+
269
+
expect(screen.getByText("1")).toBeInTheDocument();
270
+
expect(screen.queryByText("2")).not.toBeInTheDocument();
271
+
272
+
setValue("line1\nline2");
273
+
274
+
expect(screen.getByText("1")).toBeInTheDocument();
275
+
expect(screen.getByText("2")).toBeInTheDocument();
276
+
});
277
+
278
+
it("shows at least one line number for empty content", () => {
279
+
const onChange = vi.fn();
280
+
render(() => <MarkdownEditor value="" onChange={onChange} />);
281
+
expect(screen.getByText("1")).toBeInTheDocument();
282
+
});
283
+
});
284
+
285
+
describe("Font Selection", () => {
286
+
it("applies JetBrains Mono font by default", () => {
287
+
const onChange = vi.fn();
288
+
render(() => <MarkdownEditor value="test" onChange={onChange} />);
289
+
290
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
291
+
expect(textarea.style.fontFamily).toContain("JetBrains Mono");
292
+
});
293
+
294
+
it("applies custom font when specified", () => {
295
+
const onChange = vi.fn();
296
+
render(() => <MarkdownEditor value="test" onChange={onChange} font="neon" />);
297
+
298
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
299
+
expect(textarea.style.fontFamily).toContain("Monaspace Neon");
300
+
});
301
+
});
302
+
303
+
describe("Edge Cases", () => {
304
+
it("Tab key inserts 2-space soft tab", () => {
305
+
let currentValue = "Hello";
306
+
const onChange = (val: string) => {
307
+
currentValue = val;
308
+
};
309
+
310
+
render(() => <MarkdownEditor value={currentValue} onChange={onChange} />);
311
+
312
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
313
+
textarea.focus();
314
+
textarea.setSelectionRange(5, 5);
315
+
316
+
fireEvent.keyDown(textarea, { key: "Tab" });
317
+
318
+
expect(currentValue).toBe("Hello ");
319
+
});
320
+
321
+
it("Tab key is prevented from default behavior", () => {
322
+
const onChange = vi.fn();
323
+
render(() => <MarkdownEditor value="test" onChange={onChange} />);
324
+
325
+
const textarea = screen.getByRole("textbox");
326
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
327
+
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
328
+
329
+
textarea.dispatchEvent(event);
330
+
331
+
expect(preventDefaultSpy).toHaveBeenCalled();
332
+
});
333
+
334
+
it("paste event triggers onChange with pasted content", () => {
335
+
const onChange = vi.fn();
336
+
render(() => <MarkdownEditor value="Hello " onChange={onChange} />);
337
+
338
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
339
+
textarea.focus();
340
+
textarea.setSelectionRange(6, 6);
341
+
342
+
fireEvent.input(textarea, { target: { value: "Hello World" } });
343
+
expect(onChange).toHaveBeenCalledWith("Hello World");
344
+
});
345
+
346
+
it("handles rapid typing without crashing", async () => {
347
+
const onChange = vi.fn();
348
+
render(() => <MarkdownEditor value="" onChange={onChange} />);
349
+
350
+
const textarea = screen.getByRole("textbox");
351
+
352
+
for (let i = 0; i < 10; i++) {
353
+
fireEvent.input(textarea, { target: { value: "a".repeat(i + 1) } });
354
+
}
355
+
356
+
expect(onChange).toHaveBeenCalledTimes(10);
357
+
});
358
+
});
359
+
360
+
describe("Current Line Highlighting", () => {
361
+
it("highlights line 1 by default", () => {
362
+
const [value, setValue] = createSignal("line1\nline2\nline3");
363
+
render(() => <MarkdownEditor value={value()} onChange={setValue} />);
364
+
365
+
const line1 = screen.getByText("1");
366
+
expect(line1.className).toContain("text-blue-400");
367
+
368
+
const line2 = screen.getByText("2");
369
+
expect(line2.className).not.toContain("text-blue-400");
370
+
});
371
+
372
+
it("updates highlight when cursor moves to different line", () => {
373
+
const [value, setValue] = createSignal("line1\nline2\nline3");
374
+
render(() => <MarkdownEditor value={value()} onChange={setValue} />);
375
+
376
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
377
+
378
+
textarea.focus();
379
+
textarea.setSelectionRange(6, 6);
380
+
fireEvent.click(textarea);
381
+
const line2 = screen.getByText("2");
382
+
expect(line2.className).toContain("text-blue-400");
383
+
384
+
const line1 = screen.getByText("1");
385
+
expect(line1.className).not.toContain("text-blue-400");
386
+
});
387
+
});
388
+
389
+
describe("Accessibility", () => {
390
+
it("has aria-label on textarea", () => {
391
+
const onChange = vi.fn();
392
+
render(() => <MarkdownEditor value="test" onChange={onChange} />);
393
+
394
+
const textarea = screen.getByRole("textbox");
395
+
expect(textarea).toHaveAttribute("aria-label", "Markdown editor");
396
+
});
397
+
398
+
it("has aria-multiline on textarea", () => {
399
+
const onChange = vi.fn();
400
+
render(() => <MarkdownEditor value="test" onChange={onChange} />);
401
+
402
+
const textarea = screen.getByRole("textbox");
403
+
expect(textarea).toHaveAttribute("aria-multiline", "true");
404
+
});
405
+
406
+
it("line numbers are aria-hidden", () => {
407
+
const onChange = vi.fn();
408
+
render(() => <MarkdownEditor value="line1\nline2" onChange={onChange} />);
409
+
410
+
const lineContainer = screen.getByText("1").parentElement;
411
+
expect(lineContainer).toHaveAttribute("aria-hidden", "true");
412
+
});
413
+
});
414
+
});
+87
web/src/index.css
+87
web/src/index.css
···
237
237
color: #fbbf24;
238
238
text-decoration: line-through;
239
239
}
240
+
241
+
/* Prose styling for rendered markdown */
242
+
.prose h1 {
243
+
@apply text-3xl font-bold mb-4 mt-8 text-white;
244
+
}
245
+
246
+
.prose h2 {
247
+
@apply text-2xl font-semibold mb-3 mt-6 text-white;
248
+
}
249
+
250
+
.prose h3 {
251
+
@apply text-xl font-semibold mb-2 mt-4 text-white;
252
+
}
253
+
254
+
.prose h4 {
255
+
@apply text-lg font-semibold mb-2 mt-3 text-white;
256
+
}
257
+
258
+
.prose h5 {
259
+
@apply text-base font-semibold mb-1 mt-2 text-white;
260
+
}
261
+
262
+
.prose h6 {
263
+
@apply text-sm font-semibold mb-1 mt-2 text-slate-300;
264
+
}
265
+
266
+
.prose p {
267
+
@apply mb-4 leading-relaxed text-slate-300;
268
+
}
269
+
270
+
.prose a:not(.wikilink) {
271
+
@apply text-blue-400 hover:text-blue-300 underline;
272
+
}
273
+
274
+
.prose code:not(pre code) {
275
+
@apply bg-slate-700 px-1.5 py-0.5 rounded text-sm text-emerald-400;
276
+
}
277
+
278
+
.prose pre {
279
+
@apply bg-slate-800 rounded-lg p-4 overflow-x-auto my-4;
280
+
}
281
+
282
+
.prose blockquote {
283
+
@apply border-l-4 border-blue-500 pl-4 italic text-slate-400 my-4;
284
+
}
285
+
286
+
.prose ul {
287
+
@apply list-disc list-inside mb-4 space-y-1 text-slate-300;
288
+
}
289
+
290
+
.prose ol {
291
+
@apply list-decimal list-inside mb-4 space-y-1 text-slate-300;
292
+
}
293
+
294
+
.prose li {
295
+
@apply leading-relaxed;
296
+
}
297
+
298
+
.prose hr {
299
+
@apply border-slate-600 my-6;
300
+
}
301
+
302
+
.prose strong {
303
+
@apply font-semibold text-white;
304
+
}
305
+
306
+
.prose em {
307
+
@apply italic text-slate-200;
308
+
}
309
+
310
+
/* Shiki editor highlight container styling */
311
+
.shiki-editor-highlight {
312
+
@apply w-full h-full;
313
+
}
314
+
315
+
.shiki-editor-highlight pre {
316
+
@apply bg-transparent m-0 p-0;
317
+
}
318
+
319
+
.shiki-editor-highlight code {
320
+
@apply bg-transparent;
321
+
}
322
+
323
+
/* Shiki content in preview mode */
324
+
.shiki-content pre {
325
+
@apply bg-slate-800 rounded-lg p-4 overflow-x-auto my-4;
326
+
}
+3
-1
web/src/pages/NoteView.tsx
+3
-1
web/src/pages/NoteView.tsx
···
104
104
{n().title || "Untitled"}
105
105
</h1>
106
106
<div class="flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400">
107
-
<span>Updated {new Date(n().updated_at).toLocaleDateString()}</span>
107
+
<Show when={n().updated_at ?? n().created_at}>
108
+
{val => <span>Updated {new Date(val()).toLocaleDateString()}</span>}
109
+
</Show>
108
110
<Show when={n().visibility.type !== "Private"}>
109
111
<span class="inline-flex items-center rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">
110
112
{n().visibility.type}