/* 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, Visibility } from "$lib/model"; import { authStore } from "$lib/store"; import { syncStore } from "$lib/sync-store"; import { toast } from "$lib/toast"; import { Button } from "$ui/Button"; import rehypeShiki from "@shikijs/rehype"; import { useNavigate } from "@solidjs/router"; import { Textcomplete } from "@textcomplete/core"; import { TextareaEditor } from "@textcomplete/textarea"; import rehypeExternalLinks from "rehype-external-links"; import rehypeStringify from "rehype-stringify"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"; import { unified } from "unified"; export type EditorFont = "neon" | "argon" | "krypton" | "radon" | "xenon" | "jetbrains" | "google"; type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent?: string }; type EditorTab = "write" | "preview"; const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use( rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }, ).use(rehypeStringify); export function NoteEditor(props: NoteEditorProps) { const navigate = useNavigate(); const [title, setTitle] = createSignal(props.initialTitle || ""); const [content, setContent] = createSignal(props.initialContent || ""); const [preview, setPreview] = createSignal(""); const [tags, setTags] = createSignal(""); const [visibilityType, setVisibilityType] = createSignal("Private"); const [sharedWith, setSharedWith] = createSignal(""); const [showLineNumbers, setShowLineNumbers] = createSignal(true); const [editorFont, setEditorFont] = createSignal("jetbrains"); const [activeTab, setActiveTab] = createSignal("write"); let editorApi: MarkdownEditorAPI | undefined; let textcomplete: Textcomplete | undefined; const [allNotes] = createResource(async (): Promise => { const res = await api.getNotes(); if (!res.ok) return []; return res.json(); }); const updatePreviewContent = async () => { const file = await processor.process(content()); setPreview(String(file)); }; createEffect(() => { updatePreviewContent().catch(e => console.error(`Preview error: ${e instanceof Error ? e.message : e}`)); }); onCleanup(() => { textcomplete?.destroy(); }); 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) => { const notes = allNotes() ?? []; const filtered = notes.filter((n) => n.title.toLowerCase().includes(term.toLowerCase())).slice(0, 10).map((n) => n.title ); callback(filtered); }, replace: (title: string) => `[[${title}]]`, template: (title: string) => title, }]); }; const insertAtCursor = (before: string, after: string = "") => editorApi?.insertAtCursor(before, after); const handleBold = () => insertAtCursor("**", "**"); const handleItalic = () => insertAtCursor("*", "*"); const handleLink = () => insertAtCursor("[", "](url)"); const handleCode = () => insertAtCursor("`", "`"); const handleCodeBlock = () => insertAtCursor("```\n", "\n```"); const handleWikilink = () => insertAtCursor("[[", "]]"); const handleList = () => insertAtCursor("- "); const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => insertAtCursor("#".repeat(level) + " "); const handleKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey) { switch (e.key) { case "b": e.preventDefault(); handleBold(); break; case "i": e.preventDefault(); handleItalic(); break; case "k": e.preventDefault(); handleLink(); break; } } }; const handleEditorKeyDown = (e: KeyboardEvent) => handleKeyDown(e); const handleSubmit = async (e: Event) => { e.preventDefault(); try { const user = authStore.user(); if (!user) { toast.error("Not authenticated"); return; } let visibility; if (visibilityType() === "SharedWith") { visibility = { type: "SharedWith", content: sharedWith().split(",").map((s) => s.trim()).filter((s) => s) }; } else { visibility = { type: visibilityType() }; } const parsedTags = tags().split(",").map((t) => t.trim()).filter((t) => t); const localNote = await syncStore.saveNoteLocally({ id: props.noteId, ownerDid: user.did, title: title(), body: content(), tags: parsedTags, visibility: visibility as Visibility, links: [], }); if (syncStore.isOnline()) { const payload = { title: title(), body: content(), tags: parsedTags, visibility }; const res = props.noteId ? await api.updateNote(props.noteId, payload) : await api.post("/notes", payload); if (res.ok) { toast.success("Note saved and synced!"); try { const serverNote = await res.json(); navigate(`/notes/${serverNote.id}`); } catch { navigate(`/notes/${localNote.id}`); } return; } } toast.success("Note saved locally"); navigate(`/notes/${localNote.id}`); } catch (e) { console.error(e); toast.error("Failed to save note"); } }; return ( {props.noteId ? "Edit Note" : "New Note"} setShowLineNumbers(e.target.checked)} class="rounded bg-slate-700 border-slate-600" /> Line numbers setEditorFont(e.target.value as EditorFont)} class="bg-slate-800 border-slate-700 text-white text-sm rounded px-2 py-1"> JetBrains Mono Monaspace Neon Monaspace Argon Monaspace Krypton Monaspace Radon Monaspace Xenon Google Sans Code Title setTitle(e.target.value)} class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Note Title" required /> setActiveTab("write")} class={`px-4 py-2 text-sm font-medium transition-colors ${ activeTab() === "write" ? "text-white border-b-2 border-blue-500 -mb-px" : "text-slate-400 hover:text-white" }`}> Write setActiveTab("preview")} class={`px-4 py-2 text-sm font-medium transition-colors ${ activeTab() === "preview" ? "text-white border-b-2 border-blue-500 -mb-px" : "text-slate-400 hover:text-white" }`}> Preview Tags setTags(e.target.value)} class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2" placeholder="rust, learning, ..." /> Visibility setVisibilityType(e.target.value)} class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2"> Private Unlisted Public Shared With... Share with DIDs (comma separated) setSharedWith(e.target.value)} class="w-full bg-slate-800 border border-slate-700 text-white rounded-lg p-2" placeholder="did:plc:..., did:plc:..." /> Save Note ); }