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>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|