diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 47c3c416..4df15d5d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -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 */ diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 447c4278..dd1c148a 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -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 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( 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( 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( 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( }, []); useImperativeHandle(ref, () => ({ - getMarkdown: () => getEditorMarkdown(editor), + getMarkdown: () => editor?.getMarkdown() ?? "", clearContent: () => { editor?.commands.clearContent(); }, diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 511084da..a63c6213 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -61,10 +61,7 @@ function createComponents( // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { return ( - + {children} ) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index b72baec2..13aed067 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -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(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({ {editing ? ( -
{ e.preventDefault(); saveEdit(); }} +
{ if (e.key === "Escape") cancelEdit(); }} > - 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(); }} - /> -
- - +
+
- +
+ + +
+
) : ( <>