Files
multica/packages/views/editor/extensions/code-block-view.tsx
Bohan Jiang d6540a1869 fix(clipboard): support copy over http:// via execCommand fallback (#3810)
navigator.clipboard is only exposed in a secure context (https or
localhost). On self-hosted instances served over plain http:// it is
undefined, so every copy / "copy all" / export button silently failed and
left the clipboard empty (GitHub #3781).

Add a shared copyText(text): Promise<boolean> helper in
@multica/ui/lib/clipboard that prefers the async Clipboard API and falls
back to a hidden <textarea> + document.execCommand('copy') for non-secure
contexts. Migrate all direct navigator.clipboard.writeText call sites
(code blocks, agent transcript copy-all, token / webhook / issue-link
copy, etc.) to it, gating success side-effects on the returned boolean,
and remove the now-redundant copyMarkdown wrapper. Secure-context users
keep the native path unchanged.

MUL-3068

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:55:23 +08:00

150 lines
5.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Code as CodeIcon, Copy, Check, Eye } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
import { CodeBlockIframe } from "../code-block-iframe";
// Coalesces fast keystrokes before re-rendering live previews.
// `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.
// HTML preview reuses the same debounce: re-keying iframe.srcDoc on every
// keystroke causes the iframe to re-load and flicker.
const PREVIEW_DEBOUNCE_MS = 200;
const HTML_PREVIEW_HEIGHT = "h-[480px]";
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);
// HTML blocks default to "preview"; the user can flip to "source" to
// edit the markup directly. Note: the source `<pre>` MUST stay mounted
// (just hidden) so ProseMirror keeps its NodeView bindings — unmounting
// it would break editing.
const [view, setView] = useState<"preview" | "source">("preview");
const language = node.attrs.language || "";
const isMermaid = language === "mermaid";
const isHtml = language === "html";
const chart = node.textContent;
const debouncedChart = useDebouncedValue(
isMermaid ? chart : "",
PREVIEW_DEBOUNCE_MS,
);
const debouncedHtml = useDebouncedValue(
isHtml ? chart : "",
PREVIEW_DEBOUNCE_MS,
);
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
if (await copyText(text)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const showHtmlPreview = isHtml && view === "preview";
const toggleView = () =>
setView((v) => (v === "preview" ? "source" : "preview"));
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>
)}
{isHtml && showHtmlPreview && (
// CSS-hidden when toggled off so the `<pre>` below stays mounted —
// unmounting either side would either lose ProseMirror bindings
// (source) or thrash iframe.srcDoc (preview).
<div contentEditable={false} className="mb-1">
<CodeBlockIframe
html={debouncedHtml}
title="HTML preview"
heightClassName={HTML_PREVIEW_HEIGHT}
/>
</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>
)}
{isHtml && (
<button
type="button"
onClick={toggleView}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
aria-label={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
>
{view === "preview" ? (
<CodeIcon className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
)}
<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>` + NodeViewContent must remain mounted so the user can keep
editing the code block contents. When the HTML preview is showing
we just visually hide it — ProseMirror still tracks it. */}
<pre
spellCheck={false}
className={cn(showHtmlPreview && "sr-only")}
aria-hidden={showHtmlPreview ? "true" : undefined}
>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
}
export { CodeBlockView };