initial commit
This commit is contained in:
174
components/MarkdownEditor.tsx
Normal file
174
components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user