refactor(editor): unify editor into features/editor with single markdown pipeline

Replace three divergent data paths (Marked HTML loading, regex post-processing
saving, separate paste parsing) with one symmetric path through @tiptap/markdown.

Key changes:
- Create features/editor/ module with ContentEditor (unified edit+readonly)
  and TitleEditor, replacing components/common/ editor files
- Load content via contentType: 'markdown' instead of markdownToHtml() hack
- Save content via editor.getMarkdown() directly, no post-processing
- Merge RichTextEditor + ReadonlyEditor into single ContentEditor with
  editable prop
- Extract extensions into separate modules (mention, file-upload,
  markdown-paste, submit-shortcut, code-block-view)
- Extract shared preprocessMentionShortcodes to components/markdown/mentions.ts
- Add copyMarkdown utility for clipboard operations
- Upgrade all @tiptap packages from 3.20.5 to 3.22.1 (lexer isolation fix,
  HTML entity roundtrip fix, table alignment support)
- Delete markdownToHtml.ts, readonly-editor.tsx, and 10 old component files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-03 10:28:29 +08:00
parent 8eb1caa72b
commit 27e58d91af
29 changed files with 848 additions and 999 deletions

View file

@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
function CodeBlockView({ node }: NodeViewProps) {
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
>
{language && (
<span className="text-xs text-muted-foreground select-none">
{language}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Copy code"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre spellCheck={false}>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
}
export { CodeBlockView };

View file

@ -0,0 +1,119 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function removeImageBySrc(editor: any, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node: any, pos: number) => {
if (deleted) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
/**
* Shared upload flow: insert blob preview upload replace with real URL.
* Used by both paste/drop (at cursor) and button upload (at end of doc).
*/
export async function uploadAndInsertFile(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any,
file: File,
handler: (file: File) => Promise<UploadResult | null>,
pos?: number,
) {
const isImage = file.type.startsWith("image/");
if (isImage) {
const blobUrl = URL.createObjectURL(file);
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
const result = await handler(file);
if (!result) return;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
}
export function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
for (const file of Array.from(files)) {
await uploadAndInsertFile(editor, file, handler);
}
return true;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}

View file

@ -0,0 +1,110 @@
import type { RefObject } from "react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Image from "@tiptap/extension-image";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { Table } from "@tiptap/extension-table";
import { Markdown } from "@tiptap/markdown";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createFileUploadExtension } from "./file-upload";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: true,
autolink: true,
linkOnPaste: false,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
});
const LinkReadonly = Link.configure({
openOnClick: false,
autolink: false,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
});
const ImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
uploading: {
default: false,
renderHTML: (attrs: Record<string, unknown>) =>
attrs.uploading ? { "data-uploading": "" } : {},
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
},
};
},
}).configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
onSubmitRef?: RefObject<(() => void) | undefined>;
onUploadFileRef?: RefObject<
((file: File) => Promise<UploadResult | null>) | undefined
>;
}
export function createEditorExtensions(
options: EditorExtensionsOptions,
): AnyExtension[] {
const { editable, placeholder: placeholderText } = options;
const extensions: AnyExtension[] = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(editable ? { suggestion: createMentionSuggestion() } : {}),
}),
];
if (editable) {
extensions.push(
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(() => options.onSubmitRef?.current?.()),
createFileUploadExtension(options.onUploadFileRef!),
);
}
return extensions;
}

View file

@ -0,0 +1,31 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
clipboardTextParser(text, _context, plainText) {
if (!plainText && editor.markdown) {
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
return Slice.maxOpen(node.content);
}
// Plain text fallback
const p = editor.schema.nodes.paragraph!;
const doc = editor.schema.nodes.doc!;
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
return new Slice(doc.create(null, paragraph).content, 0, 0);
},
},
}),
];
},
});
}

View file

@ -0,0 +1,64 @@
import Mention from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MentionView } from "./mention-view";
export const BaseMentionExtension = Mention.extend({
addNodeView() {
return ReactNodeViewRenderer(MentionView);
},
renderHTML({ node, HTMLAttributes }) {
const type = node.attrs.type ?? "member";
const prefix = type === "issue" ? "" : "@";
return [
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`${prefix}${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
},
tokenize(src: string) {
const match = src.match(
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
};
},
},
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
const prefix = type === "issue" ? "" : "@";
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
},
});

View file

@ -0,0 +1,325 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { Badge } from "@/components/ui/badge";
import type { IssueStatus } from "@/shared/types";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown beside the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
status?: IssueStatus;
}
interface MentionListProps {
items: MentionItem[];
command: (item: MentionItem) => void;
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// Group items by section
// ---------------------------------------------------------------------------
interface MentionGroup {
label: string;
items: MentionItem[];
}
function groupItems(items: MentionItem[]): MentionGroup[] {
const users: MentionItem[] = [];
const issues: MentionItem[] = [];
for (const item of items) {
if (item.type === "issue") {
issues.push(item);
} else {
users.push(item);
}
}
const groups: MentionGroup[] = [];
if (users.length > 0) groups.push({ label: "Users", items: users });
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
return groups;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) command(item);
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
</div>
);
}
const groups = groupItems(items);
// Build a flat index mapping: globalIndex → item
let globalIndex = 0;
return (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{group.label}
</div>
{group.items.map((item) => {
const idx = globalIndex++;
return (
<MentionRow
key={`${item.type}-${item.id}`}
item={item}
selected={idx === selectedIndex}
onSelect={() => selectItem(idx)}
buttonRef={(el) => { itemRefs.current[idx] = el; }}
/>
);
})}
</div>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// MentionRow — single item in the list
// ---------------------------------------------------------------------------
function MentionRow({
item,
selected,
onSelect,
buttonRef,
}: {
item: MentionItem;
selected: boolean;
onSelect: () => void;
buttonRef: (el: HTMLButtonElement | null) => void;
}) {
if (item.type === "issue") {
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
{item.status && (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">{item.description}</span>
)}
</button>
);
}
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
<ActorAvatar
actorType={item.type === "all" ? "member" : item.type}
actorId={item.id}
size={20}
/>
<span className="truncate font-medium">{item.label}</span>
{item.type === "agent" && (
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const { issues } = useIssueStore.getState();
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const issueItems: MentionItem[] = issues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
};
}

View file

@ -0,0 +1,64 @@
"use client";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useIssueStore } from "@/features/issues/store";
import { StatusIcon } from "@/features/issues/components/status-icon";
export function MentionView({ node }: NodeViewProps) {
const { type, id, label } = node.attrs;
if (type === "issue") {
return (
<NodeViewWrapper as="span" className="inline">
<IssueMention issueId={id} fallbackLabel={label} />
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper as="span" className="inline">
<span className="mention">@{label ?? id}</span>
</NodeViewWrapper>
);
}
function IssueMention({
issueId,
fallbackLabel,
}: {
issueId: string;
fallbackLabel?: string;
}) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.open(`/issues/${issueId}`, "_blank", "noopener,noreferrer");
};
if (!issue) {
return (
<a
href={`/issues/${issueId}`}
onClick={handleClick}
className="issue-mention text-primary font-medium cursor-pointer hover:underline"
>
{fallbackLabel ?? issueId.slice(0, 8)}
</a>
);
}
return (
<a
href={`/issues/${issueId}`}
onClick={handleClick}
className="issue-mention inline-flex items-center align-middle gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
<span className="text-foreground">{issue.title}</span>
</a>
);
}

View file

@ -0,0 +1,15 @@
import { Extension } from "@tiptap/core";
export function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}