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