From fe975fb2bbddef888dbf3e432920779bfc0c6fb4 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:11:20 +0800 Subject: [PATCH 1/2] feat(editor): unify Tiptap editor for editing and readonly display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/common/mention-extension.ts | 81 +++++++++ .../components/common/mention-suggestion.tsx | 148 +++++++++++++---- apps/web/components/common/mention-view.tsx | 74 +++++++++ .../web/components/common/readonly-editor.tsx | 157 ++++++++++++++++++ .../components/common/rich-text-editor.css | 27 +++ .../components/common/rich-text-editor.tsx | 65 +------- .../issues/components/comment-card.tsx | 6 +- 7 files changed, 459 insertions(+), 99 deletions(-) create mode 100644 apps/web/components/common/mention-extension.ts create mode 100644 apps/web/components/common/mention-view.tsx create mode 100644 apps/web/components/common/readonly-editor.tsx diff --git a/apps/web/components/common/mention-extension.ts b/apps/web/components/common/mention-extension.ts new file mode 100644 index 00000000..7764bc4d --- /dev/null +++ b/apps/web/components/common/mention-extension.ts @@ -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})`; + }, +}); diff --git a/apps/web/components/common/mention-suggestion.tsx b/apps/web/components/common/mention-suggestion.tsx index ad525038..74806f83 100644 --- a/apps/web/components/common/mention-suggestion.tsx +++ b/apps/web/components/common/mention-suggestion.tsx @@ -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( ); } + const groups = groupItems(items); + + // Build a flat index mapping: globalIndex → item + let globalIndex = 0; + return ( -
- {items.map((item, index) => ( - + {group.items.map((item) => { + const idx = globalIndex++; + return ( + selectItem(idx)} + buttonRef={(el) => { itemRefs.current[idx] = el; }} + /> + ); + })} +
))} ); }, ); +// --------------------------------------------------------------------------- +// 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 ( + + ); + } + + return ( + + ); +} + // --------------------------------------------------------------------------- // 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); diff --git a/apps/web/components/common/mention-view.tsx b/apps/web/components/common/mention-view.tsx new file mode 100644 index 00000000..4ac5df6a --- /dev/null +++ b/apps/web/components/common/mention-view.tsx @@ -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 ( + + + + ); + } + + return ( + + @{label ?? id} + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + {fallbackLabel ?? issueId.slice(0, 8)} + + ); + } + + return ( + + + {issue.identifier} + {issue.title} + + ); +} diff --git a/apps/web/components/common/readonly-editor.tsx b/apps/web/components/common/readonly-editor.tsx new file mode 100644 index 00000000..e4b5d585 --- /dev/null +++ b/apps/web/components/common/readonly-editor.tsx @@ -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 = {}; + 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 ; +}); + +export { ReadonlyEditor, type ReadonlyEditorProps }; diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 5883156d..2eba86e9 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -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; diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 67f597cc..a5d4f8d8 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -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})`; - }, }); // --------------------------------------------------------------------------- diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 9d96bb04..422a8729 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -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({ ) : ( <>
- {entry.content ?? ""} +
{!isTemp && (
- {entry.content ?? ""} +
{!isTemp && ( Date: Thu, 2 Apr 2026 16:36:59 +0800 Subject: [PATCH 2/2] fix(editor): use ProseMirror schema for image upload state Address code review feedback: - Replace rAF + DOM query with Image extension `uploading` attribute managed by ProseMirror schema (no race conditions) - Remove redundant removeAttribute call (setNodeMarkup rebuilds DOM) - Restore pulse animation on img[data-uploading] for upload feedback - Remove dev mock from use-file-upload.ts (was blocking real uploads) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/common/rich-text-editor.css | 8 ++-- .../components/common/rich-text-editor.tsx | 37 +++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 2eba86e9..faa3bb16 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -241,14 +241,14 @@ margin: 0.5rem 0; } -/* Uploading image placeholder (blob: URLs = in-flight uploads) */ -.rich-text-editor img[src^="blob:"] { +/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */ +.rich-text-editor img[data-uploading] { opacity: 0.5; border-radius: var(--radius); - animation: rte-pulse 1.5s ease-in-out infinite; + animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } -@keyframes rte-pulse { +@keyframes rte-upload-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.3; } } diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index a5d4f8d8..a7a92247 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -150,23 +150,13 @@ function createFileUploadExtension( const isImage = file.type.startsWith("image/"); if (isImage) { - // Instant preview via blob URL, then replace with real URL after upload + // Instant preview via blob URL with uploading flag for CSS styling 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: { src: blobUrl, alt: file.name }, - }) - .run(); + editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run(); } else { - editor - .chain() - .focus() - .setImage({ src: blobUrl, alt: file.name }) - .run(); + editor.chain().focus().setImage(imgAttrs).run(); } try { @@ -174,14 +164,12 @@ function createFileUploadExtension( if (result) { const { tr } = editor.state; editor.state.doc.descendants((node, nodePos) => { - if ( - node.type.name === "image" && - node.attrs.src === blobUrl - ) { + if (node.type.name === "image" && node.attrs.src === blobUrl) { tr.setNodeMarkup(nodePos, undefined, { ...node.attrs, src: result.link, alt: result.filename, + uploading: false, }); } }); @@ -330,7 +318,18 @@ const RichTextEditor = forwardRef( LinkExtension, Typography, MentionExtension, - Image.configure({ + Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + uploading: { + default: false, + renderHTML: (attrs) => (attrs.uploading ? { "data-uploading": "" } : {}), + parseHTML: (el) => el.hasAttribute("data-uploading"), + }, + }; + }, + }).configure({ inline: false, allowBase64: false, HTMLAttributes: { style: "max-width: 100%; height: auto;" },