refactor(editor): migrate to @tiptap/markdown, fix mention rendering

- Replace tiptap-markdown with official @tiptap/markdown (markdown→JSON direct, skip DOM)
- Add contentType:"markdown" for proper \n\n paragraph parsing
- Fix mention renderHTML: use mergeAttributes for class/data-type, <a>→<span>
- Fix type attribute leak: add renderHTML:()=>({}) to suppress raw "type" attr
- Link style: permanent underline → hover-only underline (matches read-only)
- Mention style: primary+background pill → brand color text only
- Comment edit: replace <input> with RichTextEditor for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-31 15:38:29 +08:00
parent 38e92040c4
commit ac2a4c419f
4 changed files with 74 additions and 92 deletions

View file

@ -127,18 +127,20 @@
/* Links */
.rich-text-editor a {
color: var(--primary);
text-decoration: none;
}
.rich-text-editor a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, transparent);
padding: 0 0.2em;
border-radius: calc(var(--radius) * 0.5);
color: var(--brand);
font-weight: 500;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */

View file

@ -13,7 +13,7 @@ import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import { Markdown } from "@tiptap/markdown";
import { Extension } from "@tiptap/core";
import { Extension, mergeAttributes } from "@tiptap/core";
import { cn } from "@/lib/utils";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
@ -38,48 +38,12 @@ interface RichTextEditorRef {
focus: () => void;
}
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Mention extension configured for markdown serialization
// Stores as: [@Label](mention://type/id)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Link extension — always serialize as [text](url), never <url> autolinks;
// support Cmd+Click / Ctrl+Click to open in new tab.
// ---------------------------------------------------------------------------
const LinkExtension = Link.configure({
openOnClick: true,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
}).extend({
addStorage() {
return {
markdown: {
serialize: {
open() {
return "[";
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
close(_state: any, mark: any) {
const href = (mark.attrs.href as string).replace(/[\(\)"]/g, "\\$&");
const title = mark.attrs.title
? ` "${(mark.attrs.title as string).replace(/"/g, '\\"')}"`
: "";
return `](${href}${title})`;
},
mixable: true,
},
parse: {},
},
};
},
});
const MentionExtension = Mention.configure({
@ -88,13 +52,16 @@ const MentionExtension = Mention.configure({
}).extend({
renderHTML({ node, HTMLAttributes }) {
return [
"a",
{
...HTMLAttributes,
href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`,
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`@${node.attrs.label ?? node.attrs.id}`,
];
},
@ -103,21 +70,39 @@ const MentionExtension = Mention.configure({
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
addStorage() {
return {
markdown: {
serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) {
state.write(
`[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`,
);
},
parse: {},
},
};
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
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], id: match[3] },
};
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
return `[@${label ?? id}](mention://${type}/${id})`;
},
});
@ -160,11 +145,6 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
// Helper to get markdown from tiptap-markdown storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string =>
ed?.storage?.markdown?.getMarkdown?.() ?? "";
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
@ -172,7 +152,8 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue,
content: defaultValue || "",
contentType: defaultValue ? "markdown" : undefined,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
@ -191,7 +172,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(getEditorMarkdown(ed));
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
editorProps: {
@ -223,7 +204,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => getEditorMarkdown(editor),
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},

View file

@ -61,10 +61,7 @@ function createComponents(
// Mention links: mention://member/id or mention://agent/id
if (href?.startsWith('mention://')) {
return (
<span
className="text-primary font-medium"
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
>
<span className="text-brand font-medium mx-0.5">
{children}
</span>
)

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui/card";
@ -18,6 +18,7 @@ import { ReactionBar } from "@/components/common/reaction-bar";
import { Markdown } from "@/components/markdown";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry } from "@/shared/types";
@ -54,28 +55,28 @@ function CommentRow({
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState("");
const editEditorRef = useRef<RichTextEditorRef>(null);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const startEdit = () => {
setEditContent(entry.content ?? "");
setEditing(true);
};
const cancelEdit = () => {
setEditing(false);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
try {
await onEdit(entry.id, trimmed);
setEditing(false);
setEditContent("");
} catch {
toast.error("Failed to update comment");
}
@ -140,23 +141,24 @@ function CommentRow({
</div>
{editing ? (
<form
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
<div
className="mt-2 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<input
autoFocus
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
aria-label="Edit comment"
className="w-full text-sm bg-transparent border-b border-border outline-none py-1"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
/>
<div className="flex gap-2 mt-1.5">
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
debounceMs={100}
/>
</div>
</form>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
</div>
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">