mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(editor): render mermaid diagrams inside issue descriptions Issue descriptions are rendered through the Tiptap-based ContentEditor (not ReadonlyContent), so the mermaid handler that PR #1888 added to ReadonlyContent never reached them. Comments worked because comment-card toggles between ContentEditor (edit mode) and ReadonlyContent (display mode); issue descriptions stay in ContentEditor permanently. This patch teaches the Tiptap CodeBlock NodeView to render a Mermaid preview when the language is `mermaid`, giving issue descriptions a split view: live diagram on top, editable source below. Theme variables (light/dark), the sandboxed iframe, the lightbox and error fallback all come from the existing implementation — only the location moved. Changes: - Extract MermaidDiagram + helpers (theme detection, sandbox iframe, lightbox, useThemeVersion) from `readonly-content.tsx` into a new `editor/mermaid-diagram.tsx`. ReadonlyContent (~200 lines lighter) imports the same component, so comment-card / inbox rendering is unchanged byte-for-byte. - Update `code-block-view.tsx` (the Tiptap CodeBlock NodeView) to render `<MermaidDiagram>` above the editable source whenever the block's language is `mermaid` and the source is non-empty. Tested: - pnpm --filter @multica/views typecheck — clean - pnpm --filter @multica/views test — 327 tests pass (43 files) - Manually verified a mermaid block in an issue description renders as an SVG flowchart while staying editable underneath. Closes #2079 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(editor): debounce mermaid preview re-renders during edits Addresses review feedback on #2297. Previously every keystroke in a Mermaid code block triggered `mermaid.initialize() + render()` on the CodeBlockView preview. Because `mermaid.initialize()` mutates a process-global config, those bursts could race a concurrent ReadonlyContent render (e.g. a comment card) and clobber its theme variables. 200ms is short enough that the preview still feels live during typing but long enough to make concurrent inits unlikely in practice. The ReadonlyContent path is unchanged: chart there is the saved markdown and never changes after mount, so the race only existed on the new edit-time path this PR introduced. A small `useDebouncedValue` hook local to the file gates `chart` so that it only flows into MermaidDiagram after 200ms of stable input. When the language is non-Mermaid the hook short-circuits to "", so non-Mermaid blocks pay no extra cost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
"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 { isValidElement, memo, useMemo, useRef, useState } from "react";
|
|
import ReactMarkdown, {
|
|
defaultUrlTransform,
|
|
type Components,
|
|
} from "react-markdown";
|
|
import rehypeKatex from "rehype-katex";
|
|
import remarkBreaks from "remark-breaks";
|
|
import remarkGfm from "remark-gfm";
|
|
import remarkMath from "remark-math";
|
|
import rehypeRaw from "rehype-raw";
|
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
import { createLowlight, common } from "lowlight";
|
|
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
|
import { toHtml } from "hast-util-to-html";
|
|
import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
|
|
import { useNavigation } from "../navigation";
|
|
import { useT } from "../i18n";
|
|
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
|
import { ImageLightbox } from "./extensions/image-view";
|
|
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
|
import { openLink, isMentionHref } from "./utils/link-handler";
|
|
import { preprocessMarkdown } from "./utils/preprocess";
|
|
import { MermaidDiagram } from "./mermaid-diagram";
|
|
import "katex/dist/katex.min.css";
|
|
import "./content-editor.css";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lowlight — same engine + language set as Tiptap's CodeBlockLowlight
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const lowlight = createLowlight(common);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const sanitizeSchema = {
|
|
...defaultSchema,
|
|
protocols: {
|
|
...defaultSchema.protocols,
|
|
href: [...(defaultSchema.protocols?.href ?? []), "mention"],
|
|
},
|
|
attributes: {
|
|
...defaultSchema.attributes,
|
|
div: [
|
|
...(defaultSchema.attributes?.div ?? []),
|
|
"dataType",
|
|
"dataHref",
|
|
"dataFilename",
|
|
],
|
|
code: [
|
|
...(defaultSchema.attributes?.code ?? []),
|
|
["className", /^language-/],
|
|
["className", /^math-/],
|
|
["className", /^hljs/],
|
|
],
|
|
img: [
|
|
...(defaultSchema.attributes?.img ?? []),
|
|
"alt",
|
|
],
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
|
|
const { push, openInNewTab } = useNavigation();
|
|
const p = useWorkspacePaths();
|
|
const path = p.issueDetail(issueId);
|
|
return (
|
|
<span
|
|
className="inline align-middle"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
if (openInNewTab) {
|
|
openInNewTab(path, label);
|
|
}
|
|
return;
|
|
}
|
|
push(path);
|
|
}}
|
|
>
|
|
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Named component so it can call useWorkspaceSlug() — arrow function inlined
|
|
// inside `components` below would still work, but extracting it keeps the
|
|
// hook usage explicit and avoids hook-in-object-literal surprises.
|
|
function ReadonlyLink({
|
|
href,
|
|
children,
|
|
}: {
|
|
href?: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const slug = useWorkspaceSlug();
|
|
|
|
if (isMentionHref(href)) {
|
|
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 <IssueMentionLink issueId={match[2]} label={label} />;
|
|
}
|
|
// Member / agent / all mentions
|
|
return <span className="mention">{children}</span>;
|
|
}
|
|
|
|
// Regular links — open directly on click
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (href) openLink(href, slug);
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
const components: Partial<Components> = {
|
|
// Links — route mention:// to mention components, others show preview card
|
|
a: ReadonlyLink,
|
|
|
|
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
|
|
img: function ReadonlyImage({ src, alt }) {
|
|
const { t } = useT("editor");
|
|
const [lightbox, setLightbox] = useState(false);
|
|
const imgSrc = typeof src === "string" ? src : "";
|
|
const imgAlt = alt ?? "";
|
|
|
|
const handleView = () => setLightbox(true);
|
|
const handleDownload = () => {
|
|
window.open(imgSrc, "_blank", "noopener,noreferrer");
|
|
};
|
|
const handleCopyLink = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(imgSrc);
|
|
toast.success(t(($) => $.image.link_copied));
|
|
} catch {
|
|
toast.error(t(($) => $.image.copy_link_failed));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<span className="image-node">
|
|
<span className="image-figure" onClick={handleView}>
|
|
<img src={imgSrc} alt={imgAlt} className="image-content" draggable={false} />
|
|
<span
|
|
className="image-toolbar"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button type="button" onClick={handleView} title={t(($) => $.image.view)}>
|
|
<Maximize2 className="size-3.5" />
|
|
</button>
|
|
<button type="button" onClick={handleDownload} title={t(($) => $.image.download)}>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
<button type="button" onClick={handleCopyLink} title={t(($) => $.image.copy_link)}>
|
|
<LinkIcon className="size-3.5" />
|
|
</button>
|
|
</span>
|
|
</span>
|
|
{lightbox && (
|
|
<ImageLightbox src={imgSrc} alt={imgAlt} onClose={() => setLightbox(false)} />
|
|
)}
|
|
</span>
|
|
);
|
|
},
|
|
|
|
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
|
|
div: ({ node, children, ...props }) => {
|
|
const dataType = node?.properties?.dataType as string | undefined;
|
|
if (dataType === "fileCard") {
|
|
const rawHref = (node?.properties?.dataHref as string) || "";
|
|
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
|
|
const href = /^https?:\/\//i.test(rawHref) ? rawHref : "";
|
|
const filename = (node?.properties?.dataFilename as string) || "";
|
|
return (
|
|
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm">{filename}</p>
|
|
</div>
|
|
{href && (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
|
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
|
|
>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return <div {...props}>{children}</div>;
|
|
},
|
|
|
|
// 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 === "mermaid") {
|
|
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
|
|
}
|
|
|
|
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 }) => {
|
|
if (isValidElement(children) && children.type === MermaidDiagram) {
|
|
return <>{children}</>;
|
|
}
|
|
return <pre>{children}</pre>;
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ReadonlyContentProps {
|
|
content: string;
|
|
className?: string;
|
|
}
|
|
|
|
// Memoized so a long timeline of comments (Inbox + IssueDetail) does not
|
|
// re-run the full react-markdown + rehype-* + lowlight pipeline on every
|
|
// parent re-render. Props are `content` and `className` (both strings), so
|
|
// React.memo's default shallow comparison is value-equality here.
|
|
export const ReadonlyContent = memo(function ReadonlyContent({
|
|
content,
|
|
className,
|
|
}: ReadonlyContentProps) {
|
|
const processed = useMemo(() => preprocessMarkdown(content), [content]);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const hover = useLinkHover(wrapperRef);
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
|
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
|
urlTransform={urlTransform}
|
|
components={components}
|
|
>
|
|
{processed}
|
|
</ReactMarkdown>
|
|
<LinkHoverCard {...hover} />
|
|
</div>
|
|
);
|
|
});
|