175 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|