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:
parent
38e92040c4
commit
ac2a4c419f
4 changed files with 74 additions and 92 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue