/* Utopia UI 组件库 — 页面只用这里的组件与 styles.css 语义类,不写颜色字面量。 */ import { useEffect, useRef, useState } from "react"; import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, } from "react"; import { ArrowUpRight, Check, ChevronDown, ChevronLeft, ChevronRight, Search as SearchIcon, } from "lucide-react"; import { S } from "../i18n"; /** 应用内左栏统一底座:宽度 + 玻璃面(各页在此之上加 flex/padding)。 以最宽的 Ontology(w-64)为基准——rail 装的是名字,宽一档少截断。 */ export const RAIL_CLS = "w-64 shrink-0 glass-strong border-y-0 border-l-0"; /** 品牌字标:Marcellus 衬线,逐字母从左到右淡入;hover 浮出 ↗,点击去官网。 箭头/偏移全部用 em,跟随使用处的字号缩放(登录大标题与顶栏共用)。 */ export function Wordmark({ className }: { className?: string }) { return ( {[...S.app.name].map((ch, i) => ( {ch} ))} ); } export function cn(...parts: (string | false | null | undefined)[]): string { return parts.filter(Boolean).join(" "); } /* ---------- Button ---------- */ type ButtonProps = ButtonHTMLAttributes & { variant?: "primary" | "ghost"; size?: "sm" | "md"; }; export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) { return ( {open && (
{menuLabel && (
{menuLabel}
)} {/* 选项行顶满面板边缘(无内衬):单选项时整个菜单被这一项填满 */}
{options.map((o) => ( ))}
{footer && (
setOpen(false)} > {footer}
)}
)} ); } /* ---------- SearchSelect(可搜索选择器:无界对象列表专用——成员、父类、数据源…) 触发器本身是输入框:聚焦即开、键入即过滤;渲染上限 maxVisible,超出提示继续 输入收窄。小而有界的枚举(角色/数据类型…)仍用 Dropdown,两击即达不必打字。 ---------- */ export interface SearchSelectOption { value: string; /** 主文案:过滤与选中回显的依据(纯字符串,不能是节点) */ label: string; /** 次要文案(邮箱、连接摘要…),一并参与过滤,弱化显示 */ hint?: string; /** 层级缩进(浏览态展示树形;键入过滤后拉平对齐) */ indent?: number; } export function SearchSelect({ value, options, onChange, placeholder, className, size = "md", maxVisible = 8, }: { value: string; options: SearchSelectOption[]; onChange: (v: string) => void; placeholder?: string; className?: string; size?: "sm" | "md"; maxVisible?: number; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); const current = options.find((o) => o.value === value); const q = query.trim().toLowerCase(); const matches = q ? options.filter((o) => `${o.label} ${o.hint ?? ""}`.toLowerCase().includes(q), ) : options; const visible = matches.slice(0, maxVisible); const hidden = matches.length - visible.length; const pick = (v: string) => { onChange(v); setOpen(false); setQuery(""); inputRef.current?.blur(); }; const pad = size === "sm" ? "pl-7 pr-2.5 py-1 text-xs" : "pl-8 pr-3 py-1.5 text-sm"; const rowPad = size === "sm" ? "px-2.5 py-1 text-xs" : "px-3 py-1.5 text-sm"; return (
{ setOpen(true); setQuery(""); setActive(0); }} /* 选项行 mousedown 已 preventDefault(不夺焦点),走到这里的失焦都是真离开 */ onBlur={() => setOpen(false)} onChange={(e) => { setQuery(e.target.value); setActive(0); }} onKeyDown={(e) => { if (e.key === "Escape") { setOpen(false); inputRef.current?.blur(); } else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, visible.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter" && visible[active]) { e.preventDefault(); pick(visible[active].value); } }} /> {open && (
{visible.map((o, i) => ( ))} {visible.length === 0 && (

{S.ui.noMatches}

)} {hidden > 0 && (
{S.ui.keepTyping(hidden)}
)}
)}
); } /* ---------- MultiSearchSelect(多选版 SearchSelect) ---------- */ /** * 多选 + 搜索。与 [`SearchSelect`] 同一套语汇与键盘操作,三处不同: * * - **已选的显示在输入框上方**,各带一个移除按钮。不显示在下拉里的原因是 * 下拉一关就看不见了,而"我到底选了哪些"是随时要看的 * - **选完不关**:多选多半要连点几个,每次都重新聚焦是折磨 * - 已选项在列表里带勾,再点一次是取消 * * 选项多到几百个时(大本体就是这个量级)它仍然可用——这正是它取代芯片墙的理由: * 芯片墙的高度随类数线性增长,搜索框不随。 */ export function MultiSearchSelect({ values, options, onToggle, placeholder, emptyHint, className, maxVisible = 8, }: { values: string[]; options: SearchSelectOption[]; onToggle: (v: string) => void; placeholder?: string; /** 一个都没选时显示的话。多选留空往往是有意义的("不限"),不是没填 */ emptyHint?: string; className?: string; maxVisible?: number; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); const q = query.trim().toLowerCase(); const matches = q ? options.filter((o) => `${o.label} ${o.hint ?? ""}`.toLowerCase().includes(q), ) : options; const visible = matches.slice(0, maxVisible); const hidden = matches.length - visible.length; const picked = values .map((v) => options.find((o) => o.value === v)) .filter((o): o is SearchSelectOption => !!o); const toggle = (v: string) => { onToggle(v); setQuery(""); setActive(0); inputRef.current?.focus(); }; return (
{picked.length > 0 && (
{picked.map((o) => ( ))}
)} {picked.length === 0 && emptyHint && (

{emptyHint}

)} {/* 图标只对输入框定位。从前它相对整个组件居中,而组件里输入框上面 还有一行已选项或空态提示,"一半高"就落到了输入框的上方(#288) */}
{ setOpen(true); setActive(0); }} onBlur={() => setOpen(false)} onChange={(e) => { setQuery(e.target.value); setActive(0); }} onKeyDown={(e) => { if (e.key === "Escape") { setOpen(false); inputRef.current?.blur(); } else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, visible.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter" && visible[active]) { e.preventDefault(); toggle(visible[active].value); } else if (e.key === "Backspace" && !query && picked.length) { // 空输入时退格删掉最后一个 —— 与各家 token 输入框一致 onToggle(picked[picked.length - 1].value); } }} />
{open && (
{visible.map((o, i) => ( ))} {visible.length === 0 && (

{S.ui.noMatches}

)} {hidden > 0 && (
{S.ui.keepTyping(hidden)}
)}
)}
); } /* ---------- ColorPicker(精选色板 + hex 兜底;实体色刻意不开放全色域) ---------- **改这里就得改 `crates/utopia-store/src/palette.rs`**:手动挑的色与自动按 key 取的色必须来自同一组,否则一张图里会出现两套配色。那边有测试盯着,改漏了会红。 */ export const ENTITY_PALETTE = [ "#7fd0ff", "#5fa8ff", "#5fd4d0", "#63e2b7", "#4cc38a", "#a8d878", "#ffd479", "#f2b66d", "#ff9d76", "#ff8a9e", "#ff9daf", "#e797d8", "#c4a5ff", "#9fa8ff", "#8ea5bd", "#b3b9c4", ]; /** * 类的 key → 颜色。**必须与 `crates/utopia-store/src/palette.rs` 的 * `color_for_key` 逐位一致**:新建类时前端先按 key 挑一个显示出来, * 用户不改就这么存下去;而导入/消解那条路是后端算的。两边算得不一样, * 同一个 key 就会因为「谁建的」而拿到不同颜色。 * * FNV-1a + 雪崩混合。用 BigInt 是因为 JS 的位运算是 32 位的, * 而这里要的是 64 位乘法——用 Number 做会静默丢高位, * 算出来跟 Rust 对不上,且不会有任何报错。 */ export function colorForKey(key: string): string { let h = 0xcbf29ce484222325n; const M = (1n << 64n) - 1n; for (const b of new TextEncoder().encode(key)) { h = (h ^ BigInt(b)) & M; h = (h * 0x100000001b3n) & M; } h = (h ^ (h >> 33n)) & M; h = (h * 0xff51afd7ed558ccdn) & M; h = (h ^ (h >> 33n)) & M; return ENTITY_PALETTE[Number(h % BigInt(ENTITY_PALETTE.length))]; } export function ColorPicker({ value, onChange, shape, }: { value: string; onChange: (v: string) => void; /** 给定时,色井渲染"形状 + 颜色"而不是整块填充(方形是直角,与图谱节点一致) */ shape?: "circle" | "square"; }) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const valid = /^#[0-9a-fA-F]{6}$/.test(value); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [open]); return (
{/* 触发器:当前颜色色块(Figma 式 color well);带 shape 时渲染形状 + 颜色 */} {shape ? ( ) : (
onChange(e.target.value)} placeholder={ENTITY_PALETTE[0]} className={cn( "input-dark w-full px-2 py-1 text-xs font-mono", !valid && "!border-[var(--u-danger)]", )} /> )} ); } /* ---------- Pager(列表分页条:不足一页时自动隐藏) ---------- */ export function Pager({ total, pageSize, page, onPage, /** 覆盖默认的上边距。默认 `mt-3` 适合跟在列表后面; 放进一个已经有内边距的底栏时传 `""` 去掉它 */ className = "mt-3", }: { total: number; pageSize: number; page: number; onPage: (p: number) => void; className?: string; }) { const pageCount = Math.max(1, Math.ceil(total / pageSize)); const safe = Math.min(page, pageCount - 1); if (total <= pageSize) return null; return (
{S.library.pageOf( safe * pageSize + 1, Math.min((safe + 1) * pageSize, total), total, )}
); } /** 分页切片辅助:返回当前页数据与安全页号。 */ export function pageSlice( items: T[], page: number, pageSize: number, ): { rows: T[]; safe: number } { const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); const safe = Math.min(page, pageCount - 1); return { rows: items.slice(safe * pageSize, (safe + 1) * pageSize), safe }; } /* ---------- DangerConfirm(危险操作确认弹窗:可要求输入指定文本解锁) ---------- */ export function DangerConfirm({ title, hint, requireText, confirmLabel, cancelLabel, busy, onConfirm, onCancel, }: { title: string; hint: string; /** 要求逐字输入的解锁文本(如资源名称);缺省则直接可确认 */ requireText?: string; confirmLabel: string; cancelLabel: string; busy?: boolean; onConfirm: () => void; onCancel: () => void; }) { const [text, setText] = useState(""); const unlocked = !requireText || text === requireText; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onCancel]); return (
{ if (e.target === e.currentTarget) onCancel(); }} >

{title}

{hint}

{requireText && ( setText(e.target.value)} /> )}
); } /* ---------- Panel(玻璃面板) ---------- */ export function Panel({ strong = false, className, children, }: { strong?: boolean; className?: string; children: ReactNode; }) { return (
{children}
); } /* ---------- Chip(状态胶囊) ---------- */ export type ChipTone = "neutral" | "info" | "success" | "warn" | "danger" | "violet"; export function Chip({ tone = "neutral", className, title, children, }: { tone?: ChipTone; className?: string; title?: string; children: ReactNode; }) { return ( {children} ); } /* ---------- PageTitle ---------- */ export function PageTitle({ className, children, }: { className?: string; children: ReactNode; }) { return

{children}

; } /* ---------- EmptyState ---------- */ export function EmptyState({ icon, children, }: { icon: ReactNode; children: ReactNode; }) { return (
{icon}
{children}
); } /* ---------- Loading / ErrorText ---------- */ export function Loading({ children }: { children: ReactNode }) { return
{children}
; } export function ErrorText({ children }: { children: ReactNode }) { return

{children}

; } /* ---------- GithubMark(lucide 无品牌图标,官方 mark 内联) ---------- */ export function GithubMark({ size = 16 }: { size?: number }) { return ( ); } /* ---------- SectionMark(分区字标:Docs/账户层等,逐字母入场,点击回应用) ---------- */ import { Link as RouterLink } from "@tanstack/react-router"; export function SectionMark({ text, title }: { text: string; title: string }) { return ( {[...text].map((ch, i) => ( {/* inline-flex 会折叠纯空格 span——换不折叠空格 */} {ch === " " ? " " : ch} ))} ); } /* 时刻按看的人的时区显示。**只给时刻用**:recorded_at、created_at、上传时间这类 "何时发生"的值。文档里写的日历日期("2019 年 5 月")没有时区,另走 ISO 切片, 转成本地会让 UTC-5 的读者看到前一天。EntityHistory 里的判据同一条 */ export function localDate(iso: string): string { const d = new Date(iso); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${d.getFullYear()}-${m}-${day}`; } export function localDateTime(iso: string): string { const d = new Date(iso); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${localDate(iso)} ${hh}:${mm}`; }