Files
multica/packages/views/editor/extensions/code-block-view.tsx
jiawen134 00415de463 feat(editor): render mermaid diagrams inside issue descriptions (#2297)
* 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>
2026-05-10 13:11:20 +08:00

86 lines
2.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
// `mermaid.initialize()` mutates a process-global config, so back-to-back
// renders during typing can race a concurrent ReadonlyContent render
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
// "live preview" feel while making concurrent inits unlikely in practice.
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
function CodeBlockView({ node }: NodeViewProps) {
const { t } = useT("editor");
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const isMermaid = language === "mermaid";
const chart = node.textContent;
const debouncedChart = useDebouncedValue(
isMermaid ? chart : "",
MERMAID_PREVIEW_DEBOUNCE_MS,
);
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
{isMermaid && debouncedChart.trim() && (
<div
contentEditable={false}
className="mermaid-diagram-preview mb-1"
>
<MermaidDiagram chart={debouncedChart} />
</div>
)}
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
>
{language && (
<span className="text-xs text-muted-foreground select-none">
{language}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t(($) => $.code_block.copy_code)}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre spellCheck={false}>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
}
export { CodeBlockView };