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) <noreply@anthropic.com>
This commit is contained in:
parent
88c2f4ddc4
commit
4df32a853b
3 changed files with 158 additions and 1 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ export {
|
|||
type TitleEditorRef,
|
||||
} from "./title-editor";
|
||||
export { copyMarkdown } from "./utils/clipboard";
|
||||
export { ReadonlyContent } from "./readonly-content";
|
||||
|
|
|
|||
155
apps/web/features/editor/readonly-content.tsx
Normal file
155
apps/web/features/editor/readonly-content.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ReadonlyContent — lightweight markdown renderer for readonly content display.
|
||||
*
|
||||
* Replaces <ContentEditor editable={false}> 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 <div class="rich-text-editor readonly"> 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<Components> = {
|
||||
// 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 <IssueMentionCard issueId={match[2]} fallbackLabel={label} />;
|
||||
}
|
||||
// Member / agent / all mentions
|
||||
return <span className="mention">{children}</span>;
|
||||
}
|
||||
|
||||
// Regular links — open in new tab (matches ContentEditor readonly behavior)
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) window.open(href, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
|
||||
table: ({ children }) => (
|
||||
<div className="tableWrapper">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 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 <code {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<code
|
||||
className={cn("hljs", lang && `language-${lang}`)}
|
||||
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// Fallback — render without highlighting
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
|
||||
pre: ({ children }) => <pre>{children}</pre>,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ReadonlyContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
||||
const processed = useMemo(() => preprocessMarkdown(content), [content]);
|
||||
|
||||
return (
|
||||
<div className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue