feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
217
frontend/components/SnippetCodeView.tsx
Normal file
217
frontend/components/SnippetCodeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user