feat(editor): unify Tiptap editor for editing and readonly display

Replace react-markdown in comment/reply display with Tiptap readonly mode,
ensuring visual consistency between editing and viewing. Extract shared
BaseMentionExtension with MentionView NodeView used in both modes — issue
mentions render as inline cards with StatusIcon, clickable to open in new tab.
Redesign mention suggestion popup with grouped sections (Users/Issues),
agent badges, and StatusIcon for issues.

- New: mention-extension.ts (shared mention core)
- New: mention-view.tsx (shared NodeView for both modes)
- New: readonly-editor.tsx (lightweight Tiptap readonly wrapper)
- Modified: rich-text-editor.tsx (import from shared mention-extension)
- Modified: rich-text-editor.css (readonly + issue-mention overrides)
- Modified: comment-card.tsx (Markdown → ReadonlyEditor)
- Modified: mention-suggestion.tsx (grouped UI, StatusIcon, agent badge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-02 16:11:20 +08:00
parent fc0ef0fcd8
commit fe975fb2bb
7 changed files with 459 additions and 99 deletions

View file

@ -0,0 +1,81 @@
import Mention from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MentionView } from "./mention-view";
/**
* BaseMentionExtension shared mention extension for both editing and readonly modes.
*
* Includes: NodeView (MentionView), renderHTML, addAttributes, markdownTokenizer,
* parseMarkdown, renderMarkdown.
*
* MentionView renders identically in both modes (issue inline card, member/agent span).
* Only difference: in readonly mode, issue mentions are clickable links.
*
* Usage:
* Editing: BaseMentionExtension.configure({ suggestion: createMentionSuggestion() })
* Readonly: BaseMentionExtension.configure({})
*/
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: () => ({}),
},
};
},
// @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) {
// Matches both [@Label](mention://type/id) and [Label](mention://issue/id)
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] },
};
},
},
// 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 || {};
const prefix = type === "issue" ? "" : "@";
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
},
});

View file

@ -8,12 +8,14 @@ import {
useRef,
useState,
} from "react";
import { Hash, Users } from "lucide-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";
// ---------------------------------------------------------------------------
@ -24,8 +26,10 @@ export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown below the label (e.g. issue title) */
/** Secondary text shown beside the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
status?: IssueStatus;
}
interface MentionListProps {
@ -37,6 +41,33 @@ 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
// ---------------------------------------------------------------------------
@ -88,45 +119,93 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
);
}
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 min-w-[180px] max-h-[240px] overflow-y-auto">
{items.map((item, index) => (
<button
ref={(el) => { itemRefs.current[index] = el; }}
key={`${item.type}-${item.id}`}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
{item.type === "all" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Users className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
) : (
<ActorAvatar
actorType={item.type}
actorId={item.id}
size={20}
/>
)}
<div className="flex flex-col min-w-0">
<span className="truncate">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
<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>
</button>
))}
</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
// ---------------------------------------------------------------------------
@ -144,7 +223,7 @@ export function createMentionSuggestion(): Omit<
// 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, description: "Notify all members" }]
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const memberItems: MentionItem[] = members
@ -170,6 +249,7 @@ export function createMentionSuggestion(): Omit<
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);

View file

@ -0,0 +1,74 @@
"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";
/**
* MentionView shared NodeView for mention nodes (both editing and readonly).
*
* Rendering and behavior are identical in both modes.
* Issue mentions are always clickable (open in new tab).
*/
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>
);
}
// ---------------------------------------------------------------------------
// IssueMention — inline card, always opens in new tab
// ---------------------------------------------------------------------------
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,157 @@
"use client";
import { useEffect, useRef, memo } from "react";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { cn } from "@/lib/utils";
import { BaseMentionExtension } from "./mention-extension";
import { CodeBlockView } from "./code-block-view";
import { preprocessLinks } from "@/components/markdown/linkify";
import "./rich-text-editor.css";
const lowlight = createLowlight(common);
// ---------------------------------------------------------------------------
// Module-level extension singletons (prevent useEditor re-creation)
// ---------------------------------------------------------------------------
const extensions = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
Link.configure({
openOnClick: false,
autolink: false,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
}),
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
}),
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: {
class: "rounded-md my-2",
style: "max-width: 100%; height: auto;",
},
}),
Markdown,
];
// ---------------------------------------------------------------------------
// Content preprocessing
// ---------------------------------------------------------------------------
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
* link format [@LABEL](mention://member/UUID).
*/
function preprocessMentionShortcodes(text: string): string {
if (!text.includes("[@ ")) return text;
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match: string, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
return `[@${label}](mention://member/${id})`;
},
);
}
function preprocess(content: string): string {
return preprocessLinks(preprocessMentionShortcodes(content));
}
// ---------------------------------------------------------------------------
// ReadonlyEditor
// ---------------------------------------------------------------------------
interface ReadonlyEditorProps {
content: string;
className?: string;
}
/**
* ReadonlyEditor lightweight Tiptap wrapper for displaying markdown content.
*
* Uses the same ProseMirror engine and CSS as the editing RichTextEditor,
* ensuring visual consistency between edit and display modes.
*
* Features:
* - Issue mentions render as IssueMentionCard (inline card with status icon)
* - Links are clickable (open in new tab)
* - Code blocks have syntax highlighting and copy button
* - Content is preprocessed: raw URL linkification + legacy mention format conversion
*/
const ReadonlyEditor = memo(function ReadonlyEditor({
content,
className,
}: ReadonlyEditorProps) {
const prevContentRef = useRef(content);
const editor = useEditor({
immediatelyRender: false,
editable: false,
content: preprocess(content),
contentType: content ? "markdown" : undefined,
extensions,
editorProps: {
attributes: {
class: cn("rich-text-editor readonly text-sm", className),
},
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
// Skip links inside NodeView wrappers — they handle their own clicks
// (e.g. IssueMentionCard uses Next.js Link for client-side navigation)
if (target.closest("[data-node-view-wrapper]")) return false;
const link = target.closest("a");
const href = link?.getAttribute("href");
if (href && !href.startsWith("mention://")) {
event.preventDefault();
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
return false;
},
},
},
});
// Update content when prop changes (e.g. after editing a comment)
useEffect(() => {
if (!editor || content === prevContentRef.current) return;
prevContentRef.current = content;
const processed = preprocess(content);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsed = (editor.storage as any).markdown?.parse?.(processed);
if (parsed) {
editor.commands.setContent(parsed);
}
}, [editor, content]);
if (!editor) return null;
return <EditorContent editor={editor} />;
});
export { ReadonlyEditor, type ReadonlyEditorProps };

View file

@ -192,6 +192,16 @@
text-underline-offset: 2px;
}
/* Issue mention cards — override link styling */
.rich-text-editor a.issue-mention {
color: inherit;
text-decoration: none;
}
.rich-text-editor a.issue-mention:hover {
text-decoration: none;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
@ -214,6 +224,23 @@
color: var(--muted-foreground);
}
/* Readonly mode overrides */
.rich-text-editor.readonly.ProseMirror {
caret-color: transparent;
cursor: default;
}
/* Mention NodeView inline layout fix */
.rich-text-editor [data-node-view-wrapper] {
display: inline;
}
/* Images in readonly mode */
.rich-text-editor.readonly img {
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* Uploading image placeholder (blob: URLs = in-flight uploads) */
.rich-text-editor img[src^="blob:"] {
opacity: 0.5;

View file

@ -13,14 +13,14 @@ import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { Extension, mergeAttributes } from "@tiptap/core";
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
import { cn } from "@/lib/utils";
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 "./rich-text-editor.css";
@ -58,68 +58,9 @@ const LinkExtension = Link.configure({
},
});
const MentionExtension = Mention.configure({
const MentionExtension = BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
}).extend({
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: () => ({}),
},
};
},
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
// and [Label](mention://issue/id) (issue mentions have no @ prefix)
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] },
};
},
},
// 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 || {};
const prefix = type === "issue" ? "" : "@";
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
},
});
// ---------------------------------------------------------------------------

View file

@ -21,7 +21,7 @@ import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown/Markdown";
import { ReadonlyEditor } from "@/components/common/readonly-editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { ReplyInput } from "./reply-input";
@ -193,7 +193,7 @@ function CommentRow({
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<ReadonlyEditor content={entry.content ?? ""} />
</div>
{!isTemp && (
<ReactionBar
@ -393,7 +393,7 @@ function CommentCard({
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<ReadonlyEditor content={entry.content ?? ""} />
</div>
{!isTemp && (
<ReactionBar