From b5eb697cdd5171cac0258a476749709c06a164ba Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 2 Jan 2026 11:51:52 -0600 Subject: [PATCH] feat: MarkdownEditor component with comprehensive tests and prose styling. --- web/src/components/NoteEditor.tsx | 92 ++-- web/src/components/notes/MarkdownEditor.tsx | 220 ++++++++++ .../notes/tests/MarkdownEditor.test.tsx | 414 ++++++++++++++++++ web/src/index.css | 87 ++++ web/src/pages/NoteView.tsx | 4 +- 5 files changed, 752 insertions(+), 65 deletions(-) create mode 100644 web/src/components/notes/MarkdownEditor.tsx create mode 100644 web/src/components/notes/tests/MarkdownEditor.test.tsx diff --git a/web/src/components/NoteEditor.tsx b/web/src/components/NoteEditor.tsx index 7403e64..78cfa8a 100644 --- a/web/src/components/NoteEditor.tsx +++ b/web/src/components/NoteEditor.tsx @@ -1,5 +1,6 @@ /* eslint-disable solid/no-innerhtml */ import { EditorToolbar } from "$components/notes/EditorToolbar"; +import { MarkdownEditor, type MarkdownEditorAPI } from "$components/notes/MarkdownEditor"; import { api } from "$lib/api"; import type { Note } from "$lib/model"; import { toast } from "$lib/toast"; @@ -12,7 +13,7 @@ import rehypeExternalLinks from "rehype-external-links"; import rehypeStringify from "rehype-stringify"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; -import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; +import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"; import { unified } from "unified"; export type EditorFont = "neon" | "argon" | "krypton" | "radon" | "xenon" | "jetbrains" | "google"; @@ -21,25 +22,6 @@ type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent? type EditorTab = "write" | "preview"; -function getFontName(font: EditorFont | (() => EditorFont)) { - switch (typeof font === "function" ? font() : font) { - case "neon": - return "Monaspace Neon"; - case "argon": - return "Monaspace Argon"; - case "krypton": - return "Monaspace Krypton"; - case "radon": - return "Monaspace Radon"; - case "xenon": - return "Monaspace Xenon"; - case "google": - return "Google Sans Code"; - default: - return "JetBrains Mono"; - } -} - const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use( rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }, @@ -57,7 +39,7 @@ export function NoteEditor(props: NoteEditorProps) { const [editorFont, setEditorFont] = createSignal("jetbrains"); const [activeTab, setActiveTab] = createSignal("write"); - let textareaRef: HTMLTextAreaElement | undefined; + let editorApi: MarkdownEditorAPI | undefined; let textcomplete: Textcomplete | undefined; const [allNotes] = createResource(async (): Promise => { @@ -75,10 +57,16 @@ export function NoteEditor(props: NoteEditorProps) { updatePreviewContent().catch(e => console.error(`Preview error: ${e instanceof Error ? e.message : e}`)); }); - onMount(() => { - if (!textareaRef) return; + onCleanup(() => { + textcomplete?.destroy(); + }); - const editor = new TextareaEditor(textareaRef); + const initTextcomplete = (api: MarkdownEditorAPI) => { + editorApi = api; + const textarea = api.getTextarea(); + if (!textarea) return; + + const editor = new TextareaEditor(textarea); textcomplete = new Textcomplete(editor, [{ match: /\[\[([^\]]*)/, search: (term: string, callback: (results: string[]) => void) => { @@ -91,28 +79,10 @@ export function NoteEditor(props: NoteEditorProps) { replace: (title: string) => `[[${title}]]`, template: (title: string) => title, }]); - }); - - onCleanup(() => { - textcomplete?.destroy(); - }); - - const fontValue = createMemo(() => getFontName(editorFont)); - - const insertAtCursor = (before: string, after: string = "") => { - if (!textareaRef) return; - const start = textareaRef.selectionStart; - const end = textareaRef.selectionEnd; - const text = content(); - const selectedText = text.substring(start, end); - const newText = text.substring(0, start) + before + selectedText + after + text.substring(end); - setContent(newText); - setTimeout(() => { - textareaRef!.focus(); - textareaRef!.setSelectionRange(start + before.length, start + before.length + selectedText.length); - }, 0); }; + const insertAtCursor = (before: string, after: string = "") => editorApi?.insertAtCursor(before, after); + const handleBold = () => insertAtCursor("**", "**"); const handleItalic = () => insertAtCursor("*", "*"); const handleLink = () => insertAtCursor("[", "](url)"); @@ -141,6 +111,8 @@ export function NoteEditor(props: NoteEditorProps) { } }; + const handleEditorKeyDown = (e: KeyboardEvent) => handleKeyDown(e); + const handleSubmit = async (e: Event) => { e.preventDefault(); try { @@ -183,8 +155,6 @@ export function NoteEditor(props: NoteEditorProps) { } }; - const lineNumbers = () => Array.from({ length: content().split("\n").length }, (_, i) => i + 1); - return (
@@ -253,7 +223,9 @@ export function NoteEditor(props: NoteEditorProps) {
-
+
-
- -
- {(num) =>
{num}
}
-
-
-