From 4df32a853ba378b62bad655548cdde82ad4ece0d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:57:42 +0800 Subject: [PATCH] feat(editor): add ReadonlyContent component for lightweight markdown display - Add del selector to strikethrough CSS for react-markdown compatibility - Create ReadonlyContent using react-markdown + lowlight + content-editor.css - Export from editor module index Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/features/editor/content-editor.css | 3 +- apps/web/features/editor/index.ts | 1 + apps/web/features/editor/readonly-content.tsx | 155 ++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 apps/web/features/editor/readonly-content.tsx diff --git a/apps/web/features/editor/content-editor.css b/apps/web/features/editor/content-editor.css index ca0a3aba..a58931c7 100644 --- a/apps/web/features/editor/content-editor.css +++ b/apps/web/features/editor/content-editor.css @@ -353,7 +353,8 @@ font-style: italic; } -.rich-text-editor s { +.rich-text-editor s, +.rich-text-editor del { text-decoration: line-through; color: var(--muted-foreground); } diff --git a/apps/web/features/editor/index.ts b/apps/web/features/editor/index.ts index f82dd6cc..f2da91ea 100644 --- a/apps/web/features/editor/index.ts +++ b/apps/web/features/editor/index.ts @@ -9,3 +9,4 @@ export { type TitleEditorRef, } from "./title-editor"; export { copyMarkdown } from "./utils/clipboard"; +export { ReadonlyContent } from "./readonly-content"; diff --git a/apps/web/features/editor/readonly-content.tsx b/apps/web/features/editor/readonly-content.tsx new file mode 100644 index 00000000..8d6937d2 --- /dev/null +++ b/apps/web/features/editor/readonly-content.tsx @@ -0,0 +1,155 @@ +"use client"; + +/** + * ReadonlyContent — lightweight markdown renderer for readonly content display. + * + * Replaces for comment cards and other + * read-only surfaces. Uses react-markdown instead of a full Tiptap/ProseMirror + * instance, eliminating EditorView, Plugin, and NodeView overhead. + * + * Visual parity with ContentEditor is achieved by: + * - Wrapping output in
so the same + * content-editor.css rules apply to standard HTML tags + * - Using the same preprocessMarkdown pipeline (mention shortcodes + linkify) + * - Using lowlight for code highlighting (same engine as Tiptap's CodeBlockLowlight) + * so .hljs-* CSS rules from content-editor.css produce identical colors + * - Rendering mentions with the same IssueMentionCard component and .mention class + */ + +import { useMemo } from "react"; +import ReactMarkdown, { + defaultUrlTransform, + type Components, +} from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import { createLowlight, common } from "lowlight"; +import { toHtml } from "hast-util-to-html"; +import { cn } from "@/lib/utils"; +import { IssueMentionCard } from "@/features/issues/components/issue-mention-card"; +import { preprocessMarkdown } from "./utils/preprocess"; +import "./content-editor.css"; + +// --------------------------------------------------------------------------- +// Lowlight — same engine + language set as Tiptap's CodeBlockLowlight +// --------------------------------------------------------------------------- + +const lowlight = createLowlight(common); + +// --------------------------------------------------------------------------- +// URL transform — allow mention:// protocol through react-markdown's sanitizer +// --------------------------------------------------------------------------- + +function urlTransform(url: string): string { + if (url.startsWith("mention://")) return url; + return defaultUrlTransform(url); +} + +// --------------------------------------------------------------------------- +// Custom react-markdown components +// --------------------------------------------------------------------------- + +const components: Partial = { + // Links — route mention:// to mention components, others open in new tab + a: ({ href, children }) => { + if (href?.startsWith("mention://")) { + const match = href.match( + /^mention:\/\/(member|agent|issue|all)\/(.+)$/, + ); + if (match?.[1] === "issue" && match[2]) { + const label = + typeof children === "string" + ? children + : Array.isArray(children) + ? children.join("") + : undefined; + return ; + } + // Member / agent / all mentions + return {children}; + } + + // Regular links — open in new tab (matches ContentEditor readonly behavior) + return ( + { + e.preventDefault(); + if (href) window.open(href, "_blank", "noopener,noreferrer"); + }} + > + {children} + + ); + }, + + // Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap) + table: ({ children }) => ( +
+ {children}
+
+ ), + + // Code — lowlight highlighting for blocks, plain render for inline + code: ({ className, children, node, ...props }) => { + const lang = /language-(\w+)/.exec(className || "")?.[1]; + const isBlock = + node?.position && + node.position.start.line !== node.position.end.line; + + if (!isBlock && !lang) { + // Inline code — CSS handles styling via .rich-text-editor code + return {children}; + } + + // Block code — highlight with lowlight, output hljs classes + const code = String(children).replace(/\n$/, ""); + try { + const tree = lang + ? lowlight.highlight(lang, code) + : lowlight.highlightAuto(code); + return ( + + ); + } catch { + // Fallback — render without highlighting + return ( + + {children} + + ); + } + }, + + // Pre — pass through (CSS handles styling via .rich-text-editor pre) + pre: ({ children }) =>
{children}
, +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface ReadonlyContentProps { + content: string; + className?: string; +} + +export function ReadonlyContent({ content, className }: ReadonlyContentProps) { + const processed = useMemo(() => preprocessMarkdown(content), [content]); + + return ( +
+ + {processed} + +
+ ); +}