I18n-Translate-It/src/components/biz/editor/table.tsx

389 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
import { toast } from "sonner";
import { useClipboard } from "@/hooks/use-clipboard";
import { useTableOptionState } from "./use-table-option-state";
import type { FlatEntry } from "@/lib/i18n-structure";
import type { TranslationInlineEditHandlers } from "@/hooks/biz/use-translation-inline-edit";
interface EditorTableProps {
entries: FlatEntry[];
languages: string[];
selected: Set<string>;
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
inlineEdit: TranslationInlineEditHandlers;
onOpenAddEntry: (path: string, position: "above" | "below") => void;
onOpenAiTranslate: (path: string) => void;
onOpenBulkAiTranslate: () => void;
onOpenRenameEntry: (path: string) => void;
onMoveEntry: (path: string, offset: number) => Promise<void> | void;
onDeleteEntry: (path: string) => Promise<void> | void;
onDeleteSelected: (paths: string[]) => Promise<void> | void;
maxAiItems?: number;
}
export default function EditorTable({
entries,
languages,
selected,
setSelected,
inlineEdit,
onOpenAddEntry,
onOpenAiTranslate,
onOpenBulkAiTranslate,
onOpenRenameEntry,
onMoveEntry,
onDeleteEntry,
onDeleteSelected,
maxAiItems = 50,
}: EditorTableProps) {
const { copy } = useClipboard();
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
useEffect(() => {
setVisibleLangs(new Set(languages));
}, [languages]);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const scrollerRootRef = useRef<HTMLElement | Window | null>(null);
const {
query, setQuery,
caseSensitive, setCaseSensitive,
fullMatch, setFullMatch,
} = useTableOptionState();
const [moveCountUp, setMoveCountUp] = useState(1);
const [moveCountDown, setMoveCountDown] = useState(1);
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
const MAX_AI_ITEMS = maxAiItems;
const highlightRow = useCallback((index: number) => {
const tryFindAndAnimate = (attempt = 0) => {
const root = scrollerRootRef.current as HTMLElement | null;
if (!root) return;
const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null;
if (row) {
row.animate(
[
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
],
{ duration: 1200, easing: "ease-in-out" }
);
return;
}
if (attempt < 10) {
setTimeout(() => tryFindAndAnimate(attempt + 1), 50);
}
};
requestAnimationFrame(() => tryFindAndAnimate(0));
}, []);
const scrollToQuery = useCallback((ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (!query) return;
const idx = entries.findIndex((e) => {
const hay = caseSensitive ? e.path : e.path.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
return fullMatch ? hay === needle : hay.includes(needle);
});
if (idx >= 0) {
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
}
}, [caseSensitive, entries, fullMatch, highlightRow, query]);
const displayedLanguages = useMemo(() => languages.filter((l) => visibleLangs.has(l)), [languages, visibleLangs]);
const tableWidth = useMemo(() => (displayedLanguages.length * 200) + 36 + 60 + 300, [displayedLanguages.length]);
const virtuosoComponents = useMemo(() => ({
Table: ({ style, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} style={{ ...style, width: tableWidth }} />,
TableHead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead className="bg-muted" {...props} />,
TableRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr className="border-t" {...props} />,
TableBody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
}), [tableWidth]);
const headerContent = useCallback(() => (
<tr>
<th style={{ width: 36 }} className="sticky left-0 text-left px-3 py-2 bg-muted">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
const next = new Set<string>();
if (checked) {
for (const en of entries) next.add(en.path);
}
setSelected(next);
}}
/>
</th>
<th style={{ width: 300 }} className="text-left px-3 py-2"></th>
{displayedLanguages.map((lang) => (
<th key={lang} style={{ width: 200 }} className="text-left px-3 py-2">{lang}</th>
))}
<th style={{ width: 60 }} className="sticky right-0 text-left px-3 py-2 bg-muted"></th>
</tr>
), [allSelected, displayedLanguages, entries, setSelected]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
const handleCopy = () => {
copy(entry.path);
toast.success("复制成功");
};
return (
<>
<td className="sticky left-0 px-3 py-2 bg-white">
<Checkbox
checked={selected.has(entry.path)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(entry.path); else next.delete(entry.path);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono wrap-break-word">
<button type="button" className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path}
</button>
</td>
{displayedLanguages.map((lang) => {
const isEditing = inlineEdit.isEditingCell(entry.path, lang);
const isSaving = inlineEdit.isSavingCell(entry.path, lang);
const displayValue = inlineEdit.getDisplayValue(entry.path, lang);
return (
<td key={`${entry.path}:${lang}`} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Textarea
ref={inlineEdit.inputRef}
value={inlineEdit.editingValue}
onChange={(e) => inlineEdit.setEditingValue(e.target.value)}
onBlur={() => { void inlineEdit.saveEdit(); }}
onKeyDown={inlineEdit.handleKeyDown}
disabled={isSaving}
className="leading-normal px-2 py-0"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent"
onClick={() => inlineEdit.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-destructive"></span>}
</button>
)}
</td>
);
})}
<td className="sticky right-0 px-3 py-2 align-top bg-white">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={() => onOpenAddEntry(entry.path, "below")}>
<ArrowBigDownDash />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenAddEntry(entry.path, "above")}>
<ArrowBigUpDash />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenAiTranslate(entry.path)}>
<Languages />
AI
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenRenameEntry(entry.path)}>
<PencilLine />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, -moveCountUp);
}}
>
<ArrowBigUpDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountUp}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountUp(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, moveCountDown);
}}
>
<ArrowBigDownDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountDown}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountDown(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={async () => {
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
await onDeleteEntry(entry.path);
}}
>
<Trash2 />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</>
);
}, [copy, displayedLanguages, inlineEdit, moveCountDown, moveCountUp, onDeleteEntry, onMoveEntry, onOpenAddEntry, onOpenAiTranslate, onOpenRenameEntry, selected, setSelected]);
return (
<div className="h-full flex flex-col">
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}>
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
<Button
type="button"
variant={fullMatch ? "default" : "outline"}
onClick={() => setFullMatch((v) => !v)}
title="切换全量匹配/模糊匹配"
>
<Brackets />
{fullMatch ? "全量匹配" : "模糊匹配"}
</Button>
<Button
type="button"
variant={caseSensitive ? "default" : "outline"}
onClick={() => setCaseSensitive((v) => !v)}
title="切换大小写敏感"
>
<CaseSensitive />
{caseSensitive ? "区分大小写" : "忽略大小写"}
</Button>
<Button type="submit">
<LocateFixed />
</Button>
</form>
<div className="mb-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" title="过滤显示的语言列">
<Filter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => setVisibleLangs(new Set(languages))}>
</DropdownMenuItem>
<DropdownMenuSeparator />
{languages.map((lang) => {
const checked = visibleLangs.has(lang);
return (
<DropdownMenuCheckboxItem
key={lang}
onSelect={(e) => e.preventDefault()}
checked={checked}
onCheckedChange={(v) => {
setVisibleLangs((prev) => {
const next = new Set(prev);
if (v) next.add(lang); else next.delete(lang);
return next;
});
}}
>
{lang}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={selected.size === 0 || selected.size > MAX_AI_ITEMS}
onClick={onOpenBulkAiTranslate}
title={selected.size > MAX_AI_ITEMS ? `最多支持 ${MAX_AI_ITEMS}` : "对所选条目进行 AI 翻译"}
>
<Languages />
AI {selected.size}
</Button>
{selected.size > MAX_AI_ITEMS && (
<span className="text-xs text-red-600"> {MAX_AI_ITEMS} </span>
)}
<Button
variant="outline-destructive"
disabled={selected.size === 0}
onClick={async () => {
if (!confirm(`确认删除所选 ${selected.size} 个条目?此操作会移除所有语言下的这些键`)) return;
await onDeleteSelected(Array.from(selected));
}}
title="删除所选条目"
>
<Trash2 />
</Button>
</div>
<div className="flex-1 border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
fixedHeaderContent={headerContent}
itemContent={renderItemContent}
components={virtuosoComponents}
computeItemKey={(_index, entry) => entry.path}
scrollerRef={(el) => { scrollerRootRef.current = el; }}
/>
</div>
</div>
);
}