feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -0,0 +1,217 @@
"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>
);
}