learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: MarkdownEditor component with comprehensive tests and prose styling.

Changed files
+752 -65
web
+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
··· 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace( 26 + /'/g, 27 + "&#039;", 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
··· 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
··· 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
··· 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}