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,236 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, Search, X } from "lucide-react";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
/**
* 语言选择自定义下拉(统一站点样式):
* - variant="toolbar":广场工具台(含"全部语言"与清除)
* - variant="editor":新建/编辑片段表单(药丸形,语言必选)
*/
export default function SnippetLangSelect({
value,
onChange,
languages,
allowAll = true,
variant = "toolbar",
ariaLabel = "选择语言",
allLabel = "全部语言",
disabled = false,
}: {
value: string;
onChange: (v: string) => void;
languages: string[];
allowAll?: boolean;
variant?: "toolbar" | "editor";
ariaLabel?: string;
allLabel?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setQ("");
const t = window.setTimeout(() => inputRef.current?.focus(), 30);
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
window.clearTimeout(t);
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const filtered = useMemo(() => {
const kw = q.trim().toLowerCase();
return kw ? languages.filter((l) => l.includes(kw)) : languages;
}, [q, languages]);
const triggerCls =
variant === "editor"
? "j13-sn-search h-8 rounded-full pl-3 pr-2.5 text-[12.5px] gap-1.5"
: "j13-sn-search w-full h-9 rounded-lg px-3 text-[12.5px] gap-2";
const wrapCls = variant === "editor" ? "relative shrink-0" : "relative shrink-0 sm:w-44 w-full";
const menuCls =
variant === "editor"
? "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 w-56 rounded-xl overflow-hidden"
: "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 right-0 sm:right-auto sm:w-60 rounded-xl overflow-hidden";
return (
<div ref={ref} className={wrapCls}>
<button
type="button"
onClick={() => !disabled && setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
disabled={disabled}
className={`${triggerCls} flex items-center disabled:opacity-60 disabled:cursor-not-allowed`}
style={{ background: "var(--panel)", border: "1px solid transparent", color: "var(--ink)" }}
>
<span
className="w-[6px] h-[6px] rounded-full shrink-0"
style={{ background: value ? "var(--accent)" : "var(--ink-3)", opacity: value ? 1 : 0.5 }}
/>
<span
className="truncate text-left flex-1"
style={{ fontFamily: MONO, color: value ? "var(--ink)" : "var(--ink-3)" }}
>
{value || allLabel}
</span>
{allowAll && value ? (
<span
role="button"
tabIndex={-1}
aria-label="清除语言筛选"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="w-5 h-5 -mr-1 flex items-center justify-center rounded-md hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={11} />
</span>
) : (
<ArrowDownIcon className="shrink-0" rotated={open} />
)}
</button>
{open && (
<div
className={menuCls}
style={{
background: "var(--panel)",
border: "1px solid var(--line-2)",
boxShadow: "var(--shadow-lg)",
}}
role="listbox"
>
<div className="p-2" style={{ borderBottom: "1px solid var(--line)" }}>
<div className="relative">
<Search
size={12}
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="筛选语言…"
aria-label="筛选语言"
className="w-full h-8 rounded-md pl-7 pr-2 text-[12px] outline-none"
style={{ background: "var(--panel-2)", border: "1px solid transparent", color: "var(--ink)" }}
/>
</div>
</div>
<ul className="max-h-56 overflow-y-auto py-1">
{allowAll ? (
<LangOption
active={value === ""}
onClick={() => {
onChange("");
setOpen(false);
}}
label={allLabel}
dotDim
/>
) : null}
{filtered.length === 0 ? (
<li className="px-3 py-3 text-center text-[12px]" style={{ color: "var(--ink-3)" }}>
没有匹配的语言
</li>
) : (
filtered.map((l) => (
<LangOption
key={l}
active={value === l}
onClick={() => {
onChange(l);
setOpen(false);
}}
label={l}
mono
/>
))
)}
</ul>
</div>
)}
</div>
);
}
function ArrowDownIcon({ className = "", rotated }: { className?: string; rotated: boolean }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className={`${className} ${rotated ? "rotate-180" : ""}`}
style={{ color: "var(--ink-3)", transition: "transform 160ms ease" }}
aria-hidden
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function LangOption({
active,
onClick,
label,
mono,
dotDim,
}: {
active: boolean;
onClick: () => void;
label: string;
mono?: boolean;
dotDim?: boolean;
}) {
return (
<li>
<button
type="button"
role="option"
aria-selected={active}
onClick={onClick}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{
color: active ? "var(--accent)" : "var(--ink-2)",
background: active ? "var(--accent-soft)" : undefined,
fontFamily: mono ? MONO : undefined,
}}
>
<span
className="w-[5px] h-[5px] rounded-full shrink-0"
style={{
background: active ? "var(--accent)" : "var(--ink-3)",
opacity: active ? 1 : dotDim ? 0.5 : 0.3,
}}
/>
<span className="flex-1 truncate">{label}</span>
{active ? <Check size={12} className="shrink-0" /> : null}
</button>
</li>
);
}