"use client";
import {
Bold,
Code,
Columns2,
Eye,
Heading2,
Italic,
Link,
List,
ListOrdered,
ListTodo,
PenLine,
Quote,
Strikethrough,
type LucideIcon,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useDeferredValue,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type MarkdownEditorMode = "write" | "preview" | "split";
export type MarkdownAction =
| "bold"
| "italic"
| "strike"
| "code"
| "link"
| "heading"
| "quote"
| "bullet"
| "numbered"
| "checklist";
export interface MarkdownEditorProps {
/** Controlled Markdown source. */
value?: string;
/** Initial source when uncontrolled. */
defaultValue?: string;
onChange?: (value: string) => void;
/** Controlled view mode. */
mode?: MarkdownEditorMode;
defaultMode?: MarkdownEditorMode;
onModeChange?: (mode: MarkdownEditorMode) => void;
placeholder?: string;
/** Textarea auto-grows between these heights (px), then scrolls. */
minHeight?: number;
maxHeight?: number;
/** Floating formatting toolbar that appears over a text selection. */
selectionToolbar?: boolean;
/** Word / character count footer. */
showFooter?: boolean;
disabled?: boolean;
readOnly?: boolean;
name?: string;
id?: string;
/** Accessible name for the textarea. */
label?: string;
className?: string;
textareaClassName?: string;
previewClassName?: string;
}
/* -------------------------------------------------------------------------- */
/* Safe MD renderer */
/* -------------------------------------------------------------------------- */
/**
* Only http(s), mailto and relative URLs survive. Browsers strip tabs/newlines
* and leading control chars from URLs, so the scheme check runs on a cleaned
* copy (`java\tscript:` must not slip through).
*/
export function safeHref(raw: string): string | undefined {
const href = raw.trim();
if (!href) return undefined;
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point.
const cleaned = href.replace(/[\u0000- \u007f-\u009f]/g, "");
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);
if (scheme) {
const s = scheme[1].toLowerCase();
return s === "http" || s === "https" || s === "mailto" ? href : undefined;
}
// No scheme: relative path, query, fragment or protocol-relative (http/https).
return href;
}
const ESCAPABLE = "\\`*_{}[]()#+-.!~>|";
function findClosing(text: string, marker: string, from: number): number {
let j = text.indexOf(marker, from);
while (j !== -1) {
if (text[j - 1] === "\\") {
j = text.indexOf(marker, j + 1);
continue;
}
// A single `*`/`_` must not close on half of a `**`/`__` run.
if (marker.length === 1 && (text[j + 1] === marker || text[j - 1] === marker)) {
j = text.indexOf(marker, j + 2);
continue;
}
return j;
}
return -1;
}
function isWordChar(char: string | undefined) {
return char !== undefined && /[\p{L}\p{N}]/u.test(char);
}
function renderInline(text: string, keyPrefix: string): ReactNode[] {
const out: ReactNode[] = [];
let buffer = "";
let i = 0;
let k = 0;
const flush = () => {
if (buffer) {
out.push(buffer);
buffer = "";
}
};
const key = () => `${keyPrefix}-${k++}`;
while (i < text.length) {
const ch = text[i];
if (ch === "\\" && i + 1 < text.length && ESCAPABLE.includes(text[i + 1])) {
buffer += text[i + 1];
i += 2;
continue;
}
if (ch === "`") {
let run = 1;
while (text[i + run] === "`") run++;
const fence = "`".repeat(run);
const end = text.indexOf(fence, i + run);
if (end !== -1) {
flush();
out.push(
{text.slice(i + run, end).replace(/^ (.+) $/, "$1")}
,
);
i = end + run;
continue;
}
}
const two = text.slice(i, i + 2);
if (two === "**" || two === "__" || two === "~~") {
const end = text.indexOf(two, i + 2);
const inner = end === -1 ? "" : text.slice(i + 2, end);
if (end !== -1 && inner.trim() && !/^\s/.test(inner)) {
flush();
const children = renderInline(inner, key());
out.push(
two === "~~" ? (
{children}
) : (
{children}
),
);
i = end + 2;
continue;
}
}
if ((ch === "*" || ch === "_") && text[i + 1] !== ch) {
// `snake_case_words` stay literal: `_` only opens at a word boundary.
const opens = ch === "*" || !isWordChar(text[i - 1]);
const end = opens ? findClosing(text, ch, i + 1) : -1;
const inner = end === -1 ? "" : text.slice(i + 1, end);
if (
end !== -1 &&
inner.trim() &&
!/^\s/.test(inner) &&
(ch === "*" || !isWordChar(text[end + 1]))
) {
flush();
out.push({renderInline(inner, key())});
i = end + 1;
continue;
}
}
if (ch === "[") {
const close = findBracket(text, i);
if (close !== -1 && text[close + 1] === "(") {
const parenEnd = findParen(text, close + 1);
if (parenEnd !== -1) {
const label = text.slice(i + 1, close);
const target = text.slice(close + 2, parenEnd).trim().split(/\s+/)[0] ?? "";
const href = safeHref(target);
flush();
const children = renderInline(label || target, key());
if (href) {
const external = /^(https?:)?\/\//i.test(href);
out.push(
{children}
,
);
} else {
// Unsafe scheme: keep the label, drop the link.
out.push({children});
}
i = parenEnd + 1;
continue;
}
}
}
buffer += ch;
i++;
}
flush();
return out;
}
/** Matching `)` for the `(` at `open`, so URLs like `/wiki/A_(b)` stay whole. */
function findParen(text: string, open: number) {
let depth = 0;
for (let j = open; j < text.length; j++) {
if (text[j] === "\\") {
j++;
continue;
}
if (text[j] === "(") depth++;
else if (text[j] === ")") {
depth--;
if (depth === 0) return j;
}
}
return -1;
}
function findBracket(text: string, open: number) {
let depth = 0;
for (let j = open; j < text.length; j++) {
if (text[j] === "\\") {
j++;
continue;
}
if (text[j] === "[") depth++;
else if (text[j] === "]") {
depth--;
if (depth === 0) return j;
}
}
return -1;
}
function renderLines(lines: string[], keyPrefix: string): ReactNode[] {
const out: ReactNode[] = [];
for (let line = 0; line < lines.length; line++) {
// Lines are positional by nature: the key is the line slot, not data identity.
if (line > 0) out.push(
);
out.push(...renderInline(lines[line], `${keyPrefix}-${line}`));
}
return out;
}
const FENCE_RE = /^\s{0,3}(```+|~~~+)\s*([\w+#.-]*)\s*$/;
const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/;
const HR_RE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
const QUOTE_RE = /^\s{0,3}>\s?/;
const LIST_ITEM_RE = /^(\s*)([-*+]|\d{1,9}[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/;
type ListItem = {
ordered: boolean;
start: number;
checked: boolean | null;
text: string[];
children: ListItem[];
indent: number;
};
const HEADING_CLASS = [
"text-2xl font-semibold tracking-tight",
"text-xl font-semibold tracking-tight",
"text-lg font-semibold",
"text-base font-semibold",
"text-sm font-semibold",
"text-sm font-semibold text-muted-foreground",
];
function renderList(items: ListItem[], key: string): ReactNode {
const ordered = items[0]?.ordered ?? false;
const isTaskList = items.every((item) => item.checked !== null);
const body = items.map((item, index) => {
const itemKey = `${key}-${index}`;
const content = (
<>
{renderLines(item.text, itemKey)}
{item.children.length > 0 ? renderList(item.children, `${itemKey}-sub`) : null}
>
);
if (item.checked !== null) {
return (
{content}
);
}
return (
{content}
);
});
const listClass = cn("my-2 space-y-1", isTaskList ? "pl-0" : "pl-5", ordered ? "list-decimal" : "list-disc");
return ordered ? (
{body}
) : (
);
}
function parseBlocks(lines: string[], keyPrefix: string): ReactNode[] {
const out: ReactNode[] = [];
let i = 0;
let b = 0;
const key = () => `${keyPrefix}-${b++}`;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
const fence = FENCE_RE.exec(line);
if (fence) {
const marker = fence[1];
const lang = fence[2];
const body: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
body.push(lines[i]);
i++;
}
i++; // closing fence (or end of input)
out.push(
{body.join("\n")}
,
);
continue;
}
const heading = HEADING_RE.exec(line);
if (heading) {
const level = heading[1].length as 1 | 2 | 3 | 4 | 5 | 6;
const Tag = `h${level}` as const;
out.push(
{renderInline(heading[2], key())}
,
);
i++;
continue;
}
if (HR_RE.test(line)) {
out.push(
);
i++;
continue;
}
if (QUOTE_RE.test(line)) {
const inner: string[] = [];
while (i < lines.length && QUOTE_RE.test(lines[i])) {
inner.push(lines[i].replace(QUOTE_RE, ""));
i++;
}
const quoteKey = key();
out.push(
{parseBlocks(inner, quoteKey)}
,
);
continue;
}
if (LIST_ITEM_RE.test(line)) {
const roots: ListItem[] = [];
const stack: ListItem[] = [];
while (i < lines.length) {
const current = lines[i];
const match = LIST_ITEM_RE.exec(current);
if (match) {
const indent = match[1].replace(/\t/g, " ").length;
const marker = match[2];
const item: ListItem = {
ordered: /\d/.test(marker),
start: Number.parseInt(marker, 10) || 1,
checked: match[3] === undefined ? null : match[3] !== " ",
text: [match[4]],
children: [],
indent,
};
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
const parent = stack[stack.length - 1];
if (parent) parent.children.push(item);
else roots.push(item);
stack.push(item);
i++;
continue;
}
// Lazy continuation: an indented, non-blank line belongs to the last item.
if (current.trim() && /^\s+/.test(current) && stack.length) {
stack[stack.length - 1].text.push(current.trim());
i++;
continue;
}
break;
}
// Split the root run whenever the list type flips (bullets → numbers).
let run: ListItem[] = [];
const flushRun = () => {
if (run.length) out.push(renderList(run, key()));
run = [];
};
for (const item of roots) {
if (run.length && run[0].ordered !== item.ordered) flushRun();
run.push(item);
}
flushRun();
continue;
}
const para: string[] = [];
while (
i < lines.length &&
lines[i].trim() &&
!FENCE_RE.test(lines[i]) &&
!HEADING_RE.test(lines[i]) &&
!HR_RE.test(lines[i]) &&
!QUOTE_RE.test(lines[i]) &&
!LIST_ITEM_RE.test(lines[i])
) {
para.push(lines[i].trim());
i++;
}
const paraKey = key();
out.push(
{renderLines(para, paraKey)}
,
);
}
return out;
}
/** Render Markdown to React elements — no HTML strings, no dangerouslySetInnerHTML. */
export function renderMarkdown(source: string): ReactNode[] {
return parseBlocks(source.replace(/\r\n?/g, "\n").split("\n"), "md");
}
export function MarkdownPreview({
source,
className,
emptyLabel = "Nothing to preview",
}: {
source: string;
className?: string;
emptyLabel?: ReactNode;
}) {
const nodes = useMemo(() => renderMarkdown(source), [source]);
return (
{nodes.length ? nodes :
{emptyLabel}
}
);
}
/* -------------------------------------------------------------------------- */
/* Editing engine */
/* -------------------------------------------------------------------------- */
/**
* Replace a range so the browser's undo stack records it. `insertText` keeps
* Ctrl+Z working; where it is unsupported (or refuses), `setRangeText` edits
* the DOM and `commit` hands React the new value directly — a synthetic
* input event is not reliable, since React's value tracker may swallow it.
*/
function replaceRange(textarea: HTMLTextAreaElement, edit: Edit, commit: (value: string) => void) {
const { start, end, text, selectStart, selectEnd = selectStart } = edit;
const expected = textarea.value.slice(0, start) + text + textarea.value.slice(end);
textarea.focus();
textarea.setSelectionRange(start, end);
let applied = false;
try {
applied =
typeof document.execCommand === "function" &&
(text === "" && start !== end
? document.execCommand("delete", false)
: document.execCommand("insertText", false, text));
} catch {
applied = false;
}
if (!applied || textarea.value !== expected) {
textarea.setRangeText(text, start, end, "end");
commit(expected);
}
textarea.setSelectionRange(selectStart, selectEnd);
}
type Edit = { start: number; end: number; text: string; selectStart: number; selectEnd?: number };
function lineBounds(value: string, start: number, end: number) {
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
// A selection ending right after a newline should not pull in the next line.
const effectiveEnd = end > start && value[end - 1] === "\n" ? end - 1 : end;
const nl = value.indexOf("\n", effectiveEnd);
return { lineStart, lineEnd: nl === -1 ? value.length : nl };
}
const PLACEHOLDER: Record<"bold" | "italic" | "strike" | "code", string> = {
bold: "bold text",
italic: "italic text",
strike: "struck text",
code: "code",
};
const WRAP_MARKER: Record<"bold" | "italic" | "strike" | "code", string> = {
bold: "**",
italic: "_",
strike: "~~",
code: "`",
};
function wrapEdit(
value: string,
start: number,
end: number,
kind: "bold" | "italic" | "strike" | "code",
): Edit {
const selected = value.slice(start, end);
if (kind === "code" && selected.includes("\n")) {
const fenced = `\`\`\`\n${selected.replace(/\n$/, "")}\n\`\`\``;
const before = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const text = `${before}${fenced}`;
return {
start,
end,
text,
selectStart: start + before.length + 4,
selectEnd: start + before.length + 4 + selected.replace(/\n$/, "").length,
};
}
const m = WRAP_MARKER[kind];
// Unwrap: markers hug the selection from outside…
if (value.slice(start - m.length, start) === m && value.slice(end, end + m.length) === m) {
// …but `**bold**` must not read as italic `*` around `*bold*`.
const outerBefore = value[start - m.length - 1];
const outerAfter = value[end + m.length];
if (m.length > 1 || (outerBefore !== m && outerAfter !== m)) {
return {
start: start - m.length,
end: end + m.length,
text: selected,
selectStart: start - m.length,
selectEnd: end - m.length,
};
}
}
// …or sit inside it.
if (selected.length >= m.length * 2 + 1 && selected.startsWith(m) && selected.endsWith(m)) {
const inner = selected.slice(m.length, -m.length);
return { start, end, text: inner, selectStart: start, selectEnd: start + inner.length };
}
const body = selected || PLACEHOLDER[kind];
// Keep surrounding whitespace outside the markers (`** text**` won't parse).
const lead = /^\s*/.exec(body)?.[0] ?? "";
const trail = /\s*$/.exec(body)?.[0] ?? "";
const core = body.slice(lead.length, body.length - trail.length) || body;
const text = `${lead}${m}${core}${m}${trail}`;
const coreStart = start + lead.length + m.length;
return { start, end, text, selectStart: coreStart, selectEnd: coreStart + core.length };
}
function linkEdit(value: string, start: number, end: number): Edit {
const selected = value.slice(start, end);
if (/^(https?:\/\/|mailto:)\S+$/i.test(selected.trim())) {
const text = `[link text](${selected.trim()})`;
return { start, end, text, selectStart: start + 1, selectEnd: start + 10 };
}
const label = selected || "link text";
const text = `[${label}](https://)`;
const urlStart = start + label.length + 3;
// With a label already chosen, jump to the URL; otherwise select the label.
return selected
? { start, end, text, selectStart: urlStart, selectEnd: urlStart + 8 }
: { start, end, text, selectStart: start + 1, selectEnd: start + 1 + label.length };
}
const LIST_PREFIX_RE = /^(\s*)([-*+]\s+\[[ xX]\]\s+|[-*+]\s+|\d{1,9}[.)]\s+)/;
const TASK_RE = /^\s*[-*+]\s+\[[ xX]\]\s+/;
const BULLET_RE = /^\s*[-*+]\s+(?!\[[ xX]\]\s)/;
const ORDERED_RE = /^\s*\d{1,9}[.)]\s+/;
const HEADING_PREFIX_RE = /^#{1,6}\s+/;
const QUOTE_PREFIX_RE = /^>\s?/;
function linePrefixEdit(
value: string,
start: number,
end: number,
kind: "heading" | "quote" | "bullet" | "numbered" | "checklist",
): Edit {
const { lineStart, lineEnd } = lineBounds(value, start, end);
const lines = value.slice(lineStart, lineEnd).split("\n");
const content = lines.filter((line) => line.trim());
const targets = content.length ? content : lines;
const has: Record = {
heading: HEADING_PREFIX_RE,
quote: QUOTE_PREFIX_RE,
bullet: BULLET_RE,
numbered: ORDERED_RE,
checklist: TASK_RE,
};
const allHave = targets.every((line) => has[kind].test(line));
let n = 0;
const next = lines.map((line) => {
if (!line.trim() && content.length) return line;
if (kind === "heading") {
return allHave ? line.replace(HEADING_PREFIX_RE, "") : `## ${line.replace(HEADING_PREFIX_RE, "")}`;
}
if (kind === "quote") {
return allHave ? line.replace(QUOTE_PREFIX_RE, "") : `> ${line}`;
}
const indent = /^\s*/.exec(line)?.[0] ?? "";
const rest = line.replace(LIST_PREFIX_RE, "").replace(/^\s*/, "");
if (allHave) return `${indent}${rest}`;
n++;
const marker = kind === "bullet" ? "- " : kind === "numbered" ? `${n}. ` : "- [ ] ";
return `${indent}${marker}${rest}`;
});
const text = next.join("\n");
if (start === end && lines.length === 1) {
const caret = Math.max(lineStart, start + (text.length - lines[0].length));
return { start: lineStart, end: lineEnd, text, selectStart: caret, selectEnd: caret };
}
return { start: lineStart, end: lineEnd, text, selectStart: lineStart, selectEnd: lineStart + text.length };
}
function computeEdit(value: string, start: number, end: number, action: MarkdownAction): Edit {
switch (action) {
case "bold":
case "italic":
case "strike":
case "code":
return wrapEdit(value, start, end, action);
case "link":
return linkEdit(value, start, end);
default:
return linePrefixEdit(value, start, end, action);
}
}
/** Enter inside a list/quote: continue it, or end it on an empty item. */
function continuationEdit(value: string, caret: number): Edit | null {
const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
const line = value.slice(lineStart, caret);
const list = /^(\s*)(?:([-*+])\s+(\[[ xX]\]\s+)?|(\d{1,9})([.)])\s+)/.exec(line);
const quote = /^(\s*>\s?)/.exec(line);
const match = list ?? quote;
if (!match) return null;
if (!line.slice(match[0].length).trim()) {
// Empty item: drop the marker and leave a plain line.
const nlAfter = value.indexOf("\n", caret);
const rest = value.slice(caret, nlAfter === -1 ? value.length : nlAfter);
if (rest.trim()) return null;
return { start: lineStart, end: caret, text: "", selectStart: lineStart };
}
let marker: string;
if (list) {
const indent = list[1];
if (list[4]) marker = `${indent}${Number.parseInt(list[4], 10) + 1}${list[5]} `;
else marker = `${indent}${list[2]} ${list[3] ? "[ ] " : ""}`;
} else {
marker = match[1];
}
const text = `\n${marker}`;
return { start: caret, end: caret, text, selectStart: caret + text.length };
}
/** Tab / Shift+Tab on list lines: indent or outdent by two spaces. */
function indentEdit(value: string, start: number, end: number, outdent: boolean): Edit | null {
const { lineStart, lineEnd } = lineBounds(value, start, end);
const lines = value.slice(lineStart, lineEnd).split("\n");
if (!lines.some((line) => LIST_PREFIX_RE.test(line))) return null;
let firstDelta = 0;
const next = lines.map((line, index) => {
if (!LIST_PREFIX_RE.test(line)) return line;
if (outdent) {
const remove = Math.min(2, (/^ */.exec(line)?.[0] ?? "").length);
if (index === 0) firstDelta = -remove;
return line.slice(remove);
}
if (index === 0) firstDelta = 2;
return ` ${line}`;
});
const text = next.join("\n");
const delta = text.length - (lineEnd - lineStart);
return {
start: lineStart,
end: lineEnd,
text,
selectStart: Math.max(lineStart, start + firstDelta),
selectEnd: Math.max(lineStart, end + (start === end ? firstDelta : delta)),
};
}
/* -------------------------------------------------------------------------- */
/* Caret geometry (mirror) */
/* -------------------------------------------------------------------------- */
const MIRRORED_PROPS = [
"boxSizing",
"width",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontFamily",
"lineHeight",
"letterSpacing",
"textTransform",
"textIndent",
"wordSpacing",
"tabSize",
] as const;
/** Pixel position of a caret index inside a textarea (relative to its border box). */
function caretCoordinates(textarea: HTMLTextAreaElement, index: number) {
const mirror = document.createElement("div");
const style = window.getComputedStyle(textarea);
for (const prop of MIRRORED_PROPS) {
mirror.style[prop] = style[prop];
}
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.top = "0";
mirror.style.left = "-9999px";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.overflowWrap = "break-word";
mirror.style.overflow = "hidden";
mirror.textContent = textarea.value.slice(0, index);
const marker = document.createElement("span");
marker.textContent = textarea.value.slice(index) || ".";
mirror.appendChild(marker);
document.body.appendChild(mirror);
const top = marker.offsetTop - textarea.scrollTop;
const left = marker.offsetLeft - textarea.scrollLeft;
const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5;
document.body.removeChild(mirror);
return { top, left, lineHeight };
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
type ToolDef = { action: MarkdownAction; label: string; icon: LucideIcon; shortcut?: string };
const TOOLS: ToolDef[][] = [
[
{ action: "bold", label: "Bold", icon: Bold, shortcut: "B" },
{ action: "italic", label: "Italic", icon: Italic, shortcut: "I" },
{ action: "strike", label: "Strikethrough", icon: Strikethrough, shortcut: "Shift+X" },
{ action: "code", label: "Code", icon: Code, shortcut: "E" },
{ action: "link", label: "Link", icon: Link, shortcut: "K" },
],
[
{ action: "heading", label: "Heading", icon: Heading2 },
{ action: "quote", label: "Quote", icon: Quote },
],
[
{ action: "bullet", label: "Bulleted list", icon: List, shortcut: "Shift+8" },
{ action: "numbered", label: "Numbered list", icon: ListOrdered, shortcut: "Shift+7" },
{ action: "checklist", label: "Checklist", icon: ListTodo },
],
];
const FLOATING_TOOLS: MarkdownAction[] = ["bold", "italic", "strike", "code", "link"];
const FLOATING_WIDTH = 168; // 5 × 28px buttons + gaps/padding; fixed so clamping is exact.
const FLOATING_HEIGHT = 36;
const MODES: { value: MarkdownEditorMode; label: string; icon: LucideIcon }[] = [
{ value: "write", label: "Write", icon: PenLine },
{ value: "preview", label: "Preview", icon: Eye },
{ value: "split", label: "Split", icon: Columns2 },
];
function countWords(text: string) {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
}
export function MarkdownEditor({
value,
defaultValue = "",
onChange,
mode,
defaultMode = "write",
onModeChange,
placeholder = "Write in Markdown…",
minHeight = 160,
maxHeight = 360,
selectionToolbar = true,
showFooter = true,
disabled = false,
readOnly = false,
name,
id,
label = "Markdown editor",
className,
textareaClassName,
previewClassName,
}: MarkdownEditorProps) {
const reduce = useReducedMotion();
const uid = useId();
const textareaId = id ?? `${uid}-input`;
const previewId = `${uid}-preview`;
const textareaRef = useRef(null);
const surfaceRef = useRef(null);
const [internal, setInternal] = useState(defaultValue);
const controlled = value !== undefined;
const source = controlled ? value : internal;
const [internalMode, setInternalMode] = useState(defaultMode);
const currentMode = mode ?? internalMode;
const setMode = useCallback(
(next: MarkdownEditorMode) => {
if (mode === undefined) setInternalMode(next);
onModeChange?.(next);
},
[mode, onModeChange],
);
const [modKey, setModKey] = useState("Ctrl");
useEffect(() => {
// Read the platform after mount so server and client markup match.
if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) setModKey("⌘");
}, []);
const [floating, setFloating] = useState<{ x: number; y: number } | null>(null);
const editable = !disabled && !readOnly;
const showWrite = currentMode !== "preview";
const showPreview = currentMode !== "write";
const deferredSource = useDeferredValue(source);
const commit = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
// Auto-grow: height is the purpose here, so this writes layout directly
// (no animation) and only once per value change.
// biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the text or view changes.
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "auto";
const full = textarea.scrollHeight;
const next = Math.min(Math.max(full, minHeight), maxHeight);
textarea.style.height = `${next}px`;
textarea.style.overflowY = full > maxHeight ? "auto" : "hidden";
}, [source, minHeight, maxHeight, currentMode]);
const updateFloating = useCallback(() => {
const textarea = textareaRef.current;
const surface = surfaceRef.current;
if (!selectionToolbar || !textarea || !surface || !editable) return setFloating(null);
const { selectionStart, selectionEnd } = textarea;
if (
document.activeElement !== textarea ||
selectionStart === selectionEnd ||
!textarea.value.slice(selectionStart, selectionEnd).trim()
) {
return setFloating(null);
}
const a = caretCoordinates(textarea, selectionStart);
const b = caretCoordinates(textarea, selectionEnd);
const sameLine = Math.abs(a.top - b.top) < 2;
const anchorX = sameLine ? (a.left + b.left) / 2 : a.left;
const offsetX = textarea.offsetLeft;
const offsetY = textarea.offsetTop;
const width = surface.clientWidth;
const x = Math.min(
Math.max(offsetX + anchorX - FLOATING_WIDTH / 2, 8),
Math.max(width - FLOATING_WIDTH - 8, 8),
);
let y = offsetY + a.top - FLOATING_HEIGHT - 6;
if (y < 4) y = offsetY + b.top + b.lineHeight + 6;
y = Math.min(Math.max(y, 4), offsetY + textarea.clientHeight - 4);
setFloating({ x, y });
}, [editable, selectionToolbar]);
useEffect(() => {
if (!selectionToolbar) return;
const onSelectionChange = () => {
if (document.activeElement === textareaRef.current) updateFloating();
};
document.addEventListener("selectionchange", onSelectionChange);
return () => document.removeEventListener("selectionchange", onSelectionChange);
}, [selectionToolbar, updateFloating]);
const apply = useCallback(
(action: MarkdownAction) => {
const textarea = textareaRef.current;
if (!textarea || !editable) return;
const edit = computeEdit(
textarea.value,
textarea.selectionStart,
textarea.selectionEnd,
action,
);
replaceRange(textarea, edit, commit);
updateFloating();
},
[commit, editable, updateFloating],
);
const onKeyDown = (event: ReactKeyboardEvent) => {
if (!editable || event.nativeEvent.isComposing) return;
const textarea = event.currentTarget;
const mod = event.metaKey || event.ctrlKey;
const { selectionStart: start, selectionEnd: end } = textarea;
if (mod && !event.altKey) {
const key = event.key.toLowerCase();
let action: MarkdownAction | null = null;
if (!event.shiftKey && key === "b") action = "bold";
else if (!event.shiftKey && key === "i") action = "italic";
else if (!event.shiftKey && key === "k") action = "link";
else if (!event.shiftKey && key === "e") action = "code";
else if (event.shiftKey && key === "x") action = "strike";
// `code` keeps Shift+7/8 layout-independent of what Shift produces.
else if (event.shiftKey && event.code === "Digit7") action = "numbered";
else if (event.shiftKey && event.code === "Digit8") action = "bullet";
if (action) {
event.preventDefault();
apply(action);
}
return;
}
if (event.key === "Tab" && !mod && !event.altKey) {
// Only list lines capture Tab; elsewhere Tab keeps moving focus.
const edit = indentEdit(textarea.value, start, end, event.shiftKey);
if (edit) {
event.preventDefault();
replaceRange(textarea, edit, commit);
}
return;
}
if (event.key === "Enter" && !event.shiftKey && !mod && !event.altKey && start === end) {
const edit = continuationEdit(textarea.value, start);
if (edit) {
event.preventDefault();
replaceRange(textarea, edit, commit);
}
return;
}
if (event.key === "Escape" && floating) {
setFloating(null);
}
};
const onTabKeyDown = (event: ReactKeyboardEvent, index: number) => {
let next = index;
if (event.key === "ArrowRight") next = (index + 1) % MODES.length;
else if (event.key === "ArrowLeft") next = (index - 1 + MODES.length) % MODES.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = MODES.length - 1;
else return;
event.preventDefault();
setMode(MODES[next].value);
const buttons = event.currentTarget.parentElement?.parentElement?.querySelectorAll(
"[role=tab]",
);
buttons?.[next]?.focus();
};
const words = useMemo(() => countWords(source), [source]);
const shortcutLabel = (tool: ToolDef) =>
tool.shortcut ? `${tool.label} (${modKey}+${tool.shortcut})` : tool.label;
const tapScale = reduce ? undefined : { scale: 0.88 };
return (
{/* Header: view tabs + formatting toolbar */}
{MODES.map((item, index) => {
const active = currentMode === item.value;
const Icon = item.icon;
return (
{active ? (
) : null}
);
})}
{TOOLS.map((group, groupIndex) => (
{groupIndex > 0 ? : null}
{group.map((tool) => {
const Icon = tool.icon;
return (
event.preventDefault()}
onClick={() => apply(tool.action)}
whileTap={tapScale}
transition={SPRING_PRESS}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
>
);
})}
))}
{/* Body */}
{showPreview ? (
) : null}
{showFooter ? (
Markdown supported
{words} {words === 1 ? "word" : "words"} · {source.length}{" "}
{source.length === 1 ? "character" : "characters"}
) : null}
);
}