Files
jiang13-bbs/frontend/components/SnippetCodeView.tsx

218 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/lib/toast";
import { apiRecordSnippetCopy } from "@/lib/api";
import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
const LINE_H = 22; // px,与下方 leading 严格对齐
const PAD_Y = 14;
/** 从 shiki 双主题输出提取 pre 上的 CSS 变量与 code 内逐行 HTML */
function extractShiki(html: string): { vars: Record<string, string>; lines: string[] } | null {
const codeM = /<code[^>]*>([\s\S]*?)<\/code>/i.exec(html);
if (!codeM) return null;
const vars: Record<string, string> = {};
const preM = /<pre[^>]*\sstyle="([^"]*)"[^>]*>/i.exec(html);
if (preM) {
for (const m of preM[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+)/g)) {
vars[m[1]] = m[2].trim();
}
}
return { vars, lines: codeM[1].split("\n") };
}
function WindowDots() {
return (
<span className="inline-flex items-center gap-1.5 shrink-0" aria-hidden>
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#ff5f57" }} />
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#febc2e" }} />
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#28c840" }} />
</span>
);
}
/** 片段代码视图:编辑器窗格 + shiki 高亮 + 行号 + 复制 + 超长折叠 */
export default function SnippetCodeView({
code,
language,
snippetId,
defaultFold = true,
foldLines = 25,
}: {
code: string;
language: string;
snippetId: number;
defaultFold?: boolean;
foldLines?: number;
}) {
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(!defaultFold);
const [hl, setHl] = useState<{ vars: Record<string, string>; lines: string[] } | null>(null);
const [maxH, setMaxH] = useState<number | undefined>(undefined);
const bodyRef = useRef<HTMLDivElement>(null);
const plainLines = code.split("\n");
const lineCount = plainLines.length;
const canCollapse = defaultFold && lineCount > foldLines;
const collapsedH = PAD_Y + foldLines * LINE_H;
const rows = hl?.lines ?? plainLines;
const highlighted = hl !== null;
useEffect(() => {
let cancelled = false;
siteCodeToHtml(code, language)
.then((html) => {
if (cancelled) return;
const parsed = extractShiki(html);
if (parsed && parsed.lines.length > 0) {
setHl({ vars: parsed.vars, lines: parsed.lines.slice(0, lineCount) });
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [code, language, lineCount]);
useLayoutEffect(() => {
if (!canCollapse || !bodyRef.current) return;
setMaxH(expanded ? bodyRef.current.scrollHeight : collapsedH);
}, [expanded, canCollapse, collapsedH, hl]);
const onCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
toast("已复制到剪贴板", "ok");
apiRecordSnippetCopy(snippetId).catch(() => {});
window.setTimeout(() => setCopied(false), 1600);
} catch {
toast("复制失败,请手动选择文本");
}
};
const fileName = `snippet.${language === "plaintext" ? "txt" : language}`;
return (
<div
className="rounded-xl overflow-hidden"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{/* 窗格标题栏 */}
<div
className="flex items-center gap-2.5 px-3.5 h-10"
style={{ borderBottom: "1px solid var(--line)" }}
>
<WindowDots />
<span className="text-[11px] truncate" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
{fileName}
</span>
<span className="text-[10.5px] tabular-nums hidden sm:inline" style={{ color: "var(--ink-3)" }}>
{lineCount} 行
</span>
<span
className="ml-auto shrink-0 text-[9.5px] font-semibold uppercase tracking-[0.1em] px-1.5 py-0.5 rounded"
style={{ fontFamily: MONO, color: "var(--ink-3)", background: "var(--panel)" }}
>
{language || "text"}
</span>
<button
type="button"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
title="复制代码"
className="shrink-0 h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md text-[11.5px] font-medium transition-colors"
style={{
color: copied ? "var(--accent)" : "var(--ink-3)",
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{copied ? <Check size={12.5} /> : <Copy size={12.5} />}
<span className="hidden sm:inline">{copied ? "已复制" : "复制"}</span>
</button>
</div>
{/* 代码主体 */}
<div
ref={bodyRef}
className="relative overflow-hidden"
style={{
maxHeight: canCollapse ? maxH ?? collapsedH : undefined,
transition: "max-height 360ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
<pre
className={`j13-sn-code m-0 text-[13px] overflow-x-auto ${highlighted ? "" : "whitespace-pre"}`}
style={{
fontFamily: MONO,
lineHeight: `${LINE_H}px`,
padding: `${PAD_Y}px 0`,
color: "var(--ink-2)",
...(hl?.vars ?? {}),
} as React.CSSProperties}
>
{rows.map((ln, i) => (
<div key={i} className="flex min-w-max">
<span
className="shrink-0 w-10 pr-3 text-right select-none tabular-nums text-[11.5px]"
style={{
color: "var(--ink-3)",
opacity: 0.55,
marginRight: 12,
borderRight: "1px solid var(--line)",
lineHeight: `${LINE_H}px`,
}}
>
{i + 1}
</span>
{highlighted ? (
<span
className="pr-5"
style={{ lineHeight: `${LINE_H}px` }}
dangerouslySetInnerHTML={{ __html: ln || " " }}
/>
) : (
<span className="pr-5" style={{ lineHeight: `${LINE_H}px` }}>
{ln || " "}
</span>
)}
</div>
))}
</pre>
{canCollapse && !expanded && (
<div
className="absolute bottom-0 left-0 right-0 h-10 pointer-events-none"
style={{ background: "linear-gradient(to top, var(--panel-2), transparent)" }}
/>
)}
</div>
{canCollapse && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full h-9 flex items-center justify-center gap-1.5 text-[12px] font-medium transition-colors hover:bg-[var(--accent-soft)]"
style={{
color: expanded ? "var(--ink-3)" : "var(--accent)",
borderTop: "1px solid var(--line)",
}}
>
{expanded ? (
<>
<ChevronUp size={14} /> 收起
</>
) : (
<>
<ChevronDown size={14} /> 展开全部({lineCount} 行)
</>
)}
</button>
)}
</div>
);
}