Files
blog-manager/components/MarkdownEditor.tsx
2026-11-01 13:23:44 -05:00

175 lines
6.6 KiB
TypeScript

"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<number>();
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 = "<blockquote>\nQuote text\n</blockquote>";
export default function MarkdownEditor({ value, onChange, onView }: Props) {
const [view, setView] = useState<EditorView | null>(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: "<quote>", 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 (
<div className="pane">
<div className="pane-head">
{tools.map((tool, i) =>
tool === "sep" ? (
<span key={i} className="tb-sep" />
) : (
<button
key={tool.label}
type="button"
title={tool.title}
disabled={!view}
onMouseDown={(e) => e.preventDefault()}
onClick={() => view && tool.run(view)}
>
{tool.label}
</button>
)
)}
<span className="spacer" />
<span>markdown</span>
</div>
<div className="pane-body">
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme={dark ? "dark" : "light"}
height="100%"
style={{ position: "absolute", inset: 0 }}
basicSetup={{ foldGutter: false, highlightActiveLine: false }}
onCreateEditor={(v) => {
setView(v);
onView?.(v);
}}
/>
</div>
</div>
);
}