yap-blog/src/components/admin/RichTextEditor.tsx
matt 837465e81f Make Markdown typing shortcuts work everywhere in the editor
Tiptap's built-in bold/italic input rules only fire after whitespace,
so **bold** typed after punctuation (an em dash, an open paren, a
colon) never converted, and [text](url) had no input rule at all. A
small extension adds punctuation-tolerant bold/italic rules (using
lookbehinds so the preceding character isn't swallowed by the rule's
range deletion) and a link rule that fires on the closing parenthesis.
Link URLs pass the same validator as navigation links, so javascript:
URLs stay as plain text, and mid-word patterns like 2**3** are left
alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:05:41 -04:00

378 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Image from "@tiptap/extension-image";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import type { Editor } from "@tiptap/react";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useId, useRef, useState } from "react";
import { MarkdownInputRules } from "@/components/admin/markdown-input-rules";
import { ErrorText, Label, cx } from "@/components/ui";
import { renderMarkdown } from "@/lib/markdown";
import { uploadImageFile } from "@/lib/upload-client";
/**
* WordPress-style WYSIWYG editor (Tiptap/ProseMirror).
*
* - Emits HTML into a hidden field so the surrounding server-action form
* submits it like any other input (sanitized server-side on save).
* - Images are uploaded via /api/admin/uploads — from the toolbar button,
* by dropping files onto the editor, or by pasting from the clipboard —
* and inserted inline as /uploads/... URLs.
* - Pasting plain text that looks like Markdown converts it through the
* same remark pipeline used everywhere else; pasting rich HTML uses
* ProseMirror's native handling.
*/
// Cheap markdown sniff: headings, lists, quotes, fences, emphasis,
// links, or inline code. Plain prose without these pastes untouched.
const MARKDOWN_PATTERN =
/(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s?|```)|\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\([^)\n]+\)|`[^`\n]+`/;
function looksLikeMarkdown(text: string): boolean {
return MARKDOWN_PATTERN.test(text);
}
function ToolButton({
label,
active = false,
disabled = false,
onClick,
children,
className,
}: {
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={active}
disabled={disabled}
// Keep the editor focused while clicking toolbar buttons; without
// this the button grabs focus on mousedown and typed characters go
// to the button instead of the document.
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
className={cx(
"inline-flex h-8 min-w-8 items-center justify-center rounded px-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40",
active
? "bg-link text-ink-inverse"
: "text-ink hover:bg-background hover:text-ink-strong",
className,
)}
>
{children}
</button>
);
}
function ToolDivider() {
return <span aria-hidden="true" className="mx-1 h-5 w-px self-center bg-edge-strong" />;
}
export function RichTextEditor({
name,
label,
initialHTML,
error,
minHeightClassName = "min-h-72",
}: {
name: string;
label: string;
initialHTML: string;
error?: string;
/** Tailwind min-height class for the writing canvas. */
minHeightClassName?: string;
}) {
const id = useId();
const errorId = `${id}-error`;
const [html, setHtml] = useState(initialHTML);
const [uploadState, setUploadState] = useState<
{ kind: "idle" } | { kind: "uploading" } | { kind: "error"; message: string }
>({ kind: "idle" });
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<Editor | null>(null);
async function uploadAndInsert(files: File[]) {
const editor = editorRef.current;
const images = files.filter((f) => f.type.startsWith("image/"));
if (!editor || images.length === 0) return;
setUploadState({ kind: "uploading" });
for (const file of images) {
const result = await uploadImageFile(file);
if ("error" in result) {
setUploadState({ kind: "error", message: result.error });
return;
}
editor.chain().focus().setImage({ src: result.url, alt: "" }).run();
}
setUploadState({ kind: "idle" });
}
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
}),
Image.configure({ allowBase64: false }),
TableKit.configure({ table: { resizable: false } }),
Placeholder.configure({ placeholder: "Write your story…" }),
MarkdownInputRules,
],
content: initialHTML,
editorProps: {
attributes: {
class: `markdown-body ${minHeightClassName} px-4 py-3 focus:outline-none`,
"aria-label": label,
},
handlePaste: (_view, event) => {
const clipboard = event.clipboardData;
if (!clipboard) return false;
const files = Array.from(clipboard.files ?? []);
if (files.some((f) => f.type.startsWith("image/"))) {
event.preventDefault();
void uploadAndInsert(files);
return true;
}
// Rich HTML pastes keep ProseMirror's native handling.
if (clipboard.getData("text/html")) return false;
const text = clipboard.getData("text/plain");
if (text && looksLikeMarkdown(text)) {
event.preventDefault();
editorRef.current?.chain().focus().insertContent(renderMarkdown(text)).run();
return true;
}
return false;
},
handleDrop: (_view, event) => {
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.some((f) => f.type.startsWith("image/"))) {
event.preventDefault();
void uploadAndInsert(files);
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
setHtml(editor.isEmpty ? "" : editor.getHTML());
},
});
// The paste/drop handlers above are created once by useEditor and reach
// the editor through this ref; render-time assignment would trip the
// react-hooks/refs rule, so sync it in an effect instead.
useEffect(() => {
editorRef.current = editor;
}, [editor]);
const state = useEditorState({
editor,
selector: ({ editor }) =>
editor
? {
paragraph: editor.isActive("paragraph"),
h2: editor.isActive("heading", { level: 2 }),
h3: editor.isActive("heading", { level: 3 }),
h4: editor.isActive("heading", { level: 4 }),
bold: editor.isActive("bold"),
italic: editor.isActive("italic"),
underline: editor.isActive("underline"),
strike: editor.isActive("strike"),
code: editor.isActive("code"),
link: editor.isActive("link"),
bulletList: editor.isActive("bulletList"),
orderedList: editor.isActive("orderedList"),
blockquote: editor.isActive("blockquote"),
codeBlock: editor.isActive("codeBlock"),
image: editor.isActive("image"),
table: editor.isActive("table"),
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
}
: null,
});
function setLink() {
if (!editor) return;
const current = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("Link URL (leave empty to remove):", current ?? "");
if (url === null) return;
if (url === "") {
editor.chain().focus().unsetLink().run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}
}
function editImageAlt() {
if (!editor) return;
const current = (editor.getAttributes("image").alt as string | undefined) ?? "";
const alt = window.prompt(
"Alt text for this image (leave empty if decorative):",
current,
);
if (alt === null) return;
editor.chain().focus().updateAttributes("image", { alt }).run();
}
const chain = () => editor!.chain().focus();
return (
<div>
<Label className="mb-1.5">{label}</Label>
<input type="hidden" name={name} value={html} />
<div
className={cx(
"editor-shell rounded-md border bg-background",
error ? "border-danger" : "border-edge",
)}
>
<div
role="toolbar"
aria-label={`${label} formatting`}
className="flex flex-wrap items-center gap-0.5 border-b border-edge bg-surface px-2 py-1.5"
>
<ToolButton label="Paragraph" active={state?.paragraph} disabled={!editor} onClick={() => chain().setParagraph().run()}>
</ToolButton>
<ToolButton label="Heading level 2" active={state?.h2} disabled={!editor} onClick={() => chain().toggleHeading({ level: 2 }).run()}>
H2
</ToolButton>
<ToolButton label="Heading level 3" active={state?.h3} disabled={!editor} onClick={() => chain().toggleHeading({ level: 3 }).run()}>
H3
</ToolButton>
<ToolButton label="Heading level 4" active={state?.h4} disabled={!editor} onClick={() => chain().toggleHeading({ level: 4 }).run()}>
H4
</ToolButton>
<ToolDivider />
<ToolButton label="Bold" active={state?.bold} disabled={!editor} onClick={() => chain().toggleBold().run()} className="font-bold">
B
</ToolButton>
<ToolButton label="Italic" active={state?.italic} disabled={!editor} onClick={() => chain().toggleItalic().run()} className="italic">
I
</ToolButton>
<ToolButton label="Underline" active={state?.underline} disabled={!editor} onClick={() => chain().toggleUnderline().run()} className="underline">
U
</ToolButton>
<ToolButton label="Strikethrough" active={state?.strike} disabled={!editor} onClick={() => chain().toggleStrike().run()} className="line-through">
S
</ToolButton>
<ToolButton label="Inline code" active={state?.code} disabled={!editor} onClick={() => chain().toggleCode().run()} className="font-mono text-xs">
{"</>"}
</ToolButton>
<ToolButton label="Link" active={state?.link} disabled={!editor} onClick={setLink}>
🔗
</ToolButton>
<ToolDivider />
<ToolButton label="Bullet list" active={state?.bulletList} disabled={!editor} onClick={() => chain().toggleBulletList().run()}>
</ToolButton>
<ToolButton label="Numbered list" active={state?.orderedList} disabled={!editor} onClick={() => chain().toggleOrderedList().run()}>
1.
</ToolButton>
<ToolButton label="Blockquote" active={state?.blockquote} disabled={!editor} onClick={() => chain().toggleBlockquote().run()}>
</ToolButton>
<ToolButton label="Code block" active={state?.codeBlock} disabled={!editor} onClick={() => chain().toggleCodeBlock().run()} className="font-mono text-xs">
{"{ }"}
</ToolButton>
<ToolButton label="Horizontal rule" disabled={!editor} onClick={() => chain().setHorizontalRule().run()}>
</ToolButton>
<ToolDivider />
<ToolButton label="Upload and insert image" disabled={!editor} onClick={() => fileInputRef.current?.click()}>
🖼
</ToolButton>
{state?.image && (
<ToolButton label="Edit image alt text" disabled={!editor} onClick={editImageAlt}>
Alt
</ToolButton>
)}
<ToolButton
label="Insert table"
active={state?.table}
disabled={!editor}
onClick={() => chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
</ToolButton>
<ToolDivider />
<ToolButton label="Undo" disabled={!editor || !state?.canUndo} onClick={() => chain().undo().run()}>
</ToolButton>
<ToolButton label="Redo" disabled={!editor || !state?.canRedo} onClick={() => chain().redo().run()}>
</ToolButton>
</div>
{state?.table && (
<div
role="toolbar"
aria-label="Table editing"
className="flex flex-wrap items-center gap-0.5 border-b border-edge bg-surface px-2 py-1"
>
<ToolButton label="Add column after" disabled={!editor} onClick={() => chain().addColumnAfter().run()}>
+Col
</ToolButton>
<ToolButton label="Delete column" disabled={!editor} onClick={() => chain().deleteColumn().run()}>
Col
</ToolButton>
<ToolButton label="Add row after" disabled={!editor} onClick={() => chain().addRowAfter().run()}>
+Row
</ToolButton>
<ToolButton label="Delete row" disabled={!editor} onClick={() => chain().deleteRow().run()}>
Row
</ToolButton>
<ToolButton label="Toggle header row" disabled={!editor} onClick={() => chain().toggleHeaderRow().run()}>
Header
</ToolButton>
<ToolButton label="Delete table" disabled={!editor} onClick={() => chain().deleteTable().run()}>
Table
</ToolButton>
</div>
)}
<EditorContent editor={editor} />
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
multiple
hidden
data-testid="editor-image-input"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
void uploadAndInsert(files);
}}
/>
</div>
<p className="mt-1.5 text-xs text-ink-muted" role="status" aria-live="polite">
{uploadState.kind === "uploading" && "Uploading image…"}
{uploadState.kind === "error" && (
<span className="text-danger">{uploadState.message}</span>
)}
{uploadState.kind === "idle" &&
"Drop or paste images to upload them. Pasted Markdown is converted automatically."}
</p>
<ErrorText id={errorId}>{error}</ErrorText>
</div>
);
}