mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +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>
86 lines
2.9 KiB
TypeScript
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 };
|