"use client"; import { useMemo, useState } from "react"; import CodeMirror, { EditorSelection, EditorView, keymap } from "@uiw/react-codemirror"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; import { useSystemDark } from "@/lib/useSystemDark"; /* Markdown source editor with a formatting toolbar. Source (not WYSIWYG) on * purpose: posts embed raw HTML blocks that rich-text editors would mangle. * The live preview beside it is what makes it visual. */ type Props = { value: string; onChange: (value: string) => void; /** Receives the EditorView so the parent can insert text (e.g. images). */ onView?: (view: EditorView | null) => void; }; /** Wrap each selection in `before`/`after`, or unwrap if already wrapped. */ export function wrap(view: EditorView, before: string, after = before, placeholder = "text") { view.dispatch( view.state.changeByRange((range) => { const doc = view.state.doc; const outerFrom = range.from - before.length; const outerTo = range.to + after.length; const isWrapped = outerFrom >= 0 && outerTo <= doc.length && doc.sliceString(outerFrom, range.from) === before && doc.sliceString(range.to, outerTo) === after; if (isWrapped) { return { changes: [ { from: outerFrom, to: range.from, insert: "" }, { from: range.to, to: outerTo, insert: "" }, ], range: EditorSelection.range(outerFrom, range.to - before.length), }; } const text = range.empty ? placeholder : doc.sliceString(range.from, range.to); return { changes: { from: range.from, to: range.to, insert: before + text + after }, range: EditorSelection.range(range.from + before.length, range.from + before.length + text.length), }; }) ); view.focus(); } /** Toggle a prefix on every line touched by the selection ("# ", "> ", "- "...). */ export function prefixLines(view: EditorView, prefix: string | ((i: number) => string)) { const { state } = view; const changes: { from: number; to?: number; insert: string }[] = []; const seen = new Set(); for (const range of state.selection.ranges) { const first = state.doc.lineAt(range.from).number; const last = state.doc.lineAt(range.to).number; for (let n = first, i = 0; n <= last; n++, i++) { if (seen.has(n)) continue; seen.add(n); const line = state.doc.line(n); const p = typeof prefix === "function" ? prefix(i) : prefix; // Strip any existing heading/quote/list marker so prefixes replace each other. const existing = /^(#{1,6} |> |- |\d+\. )/.exec(line.text)?.[0] ?? ""; if (existing === p) changes.push({ from: line.from, to: line.from + existing.length, insert: "" }); else changes.push({ from: line.from, to: line.from + existing.length, insert: p }); } } view.dispatch({ changes }); view.focus(); } /** Replace the selection with `text` (on its own lines when `block`). */ export function insertText(view: EditorView, text: string, block = false) { const { state } = view; const range = state.selection.main; let insert = text; if (block) { const before = state.doc.sliceString(Math.max(0, range.from - 2), range.from); const after = state.doc.sliceString(range.to, range.to + 1); if (range.from > 0 && !before.endsWith("\n\n")) insert = (before.endsWith("\n") ? "\n" : "\n\n") + insert; if (after !== "\n") insert += "\n"; } view.dispatch({ changes: { from: range.from, to: range.to, insert }, selection: EditorSelection.cursor(range.from + insert.length), }); view.focus(); } function link(view: EditorView) { const url = window.prompt("Link URL", "https://"); if (!url) return; wrap(view, "[", `](${url})`, "link text"); } const BLOCKQUOTE_HTML = "
\nQuote text\n
"; export default function MarkdownEditor({ value, onChange, onView }: Props) { const [view, setView] = useState(null); const dark = useSystemDark(); const extensions = useMemo( () => [ markdown({ base: markdownLanguage, codeLanguages: languages }), EditorView.lineWrapping, keymap.of([ { key: "Mod-b", run: (v) => (wrap(v, "**"), true) }, { key: "Mod-i", run: (v) => (wrap(v, "*"), true) }, { key: "Mod-k", run: (v) => (link(v), true) }, ]), ], [] ); const tools: ({ label: string; title: string; run: (v: EditorView) => void } | "sep")[] = [ { label: "H1", title: "Heading 1", run: (v) => prefixLines(v, "# ") }, { label: "H2", title: "Heading 2", run: (v) => prefixLines(v, "## ") }, { label: "H3", title: "Heading 3", run: (v) => prefixLines(v, "### ") }, "sep", { label: "B", title: "Bold (Ctrl+B)", run: (v) => wrap(v, "**") }, { label: "I", title: "Italic (Ctrl+I)", run: (v) => wrap(v, "*") }, { label: "", title: "Inline code", run: (v) => wrap(v, "`", "`", "code") }, { label: "link", title: "Link (Ctrl+K)", run: link }, "sep", { label: "• list", title: "Bullet list", run: (v) => prefixLines(v, "- ") }, { label: "1. list", title: "Numbered list", run: (v) => prefixLines(v, (i) => `${i + 1}. `) }, { label: "> quote", title: "Markdown quote", run: (v) => prefixLines(v, "> ") }, { label: "", title: "HTML blockquote (the site's raised style)", run: (v) => insertText(v, BLOCKQUOTE_HTML, true) }, { label: "```", title: "Code block", run: (v) => wrap(v, "```\n", "\n```", "code") }, { label: "—", title: "Horizontal rule", run: (v) => insertText(v, "---", true) }, ]; return (
{tools.map((tool, i) => tool === "sep" ? ( ) : ( ) )} markdown
{ setView(v); onView?.(v); }} />
); }