218 lines
7.3 KiB
TypeScript
218 lines
7.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|