diff --git a/src/components/biz/editor/common-menubar.tsx b/src/components/biz/editor/common-menubar.tsx index 7a477bf..e3f093a 100644 --- a/src/components/biz/editor/common-menubar.tsx +++ b/src/components/biz/editor/common-menubar.tsx @@ -3,6 +3,7 @@ import { MenubarContent, MenubarItem, MenubarMenu, + MenubarSeparator, MenubarShortcut, MenubarTrigger, } from "@/components/ui/menubar"; @@ -10,6 +11,7 @@ import { ChevronDown } from "lucide-react"; export type CommonMenubarItem = | "read" + | "read-all" | "save" | "import-with-directory" | "import-with-files" @@ -29,10 +31,14 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) { onClickItem("read")} disabled={disabledItems?.read}> - 读取 ⌘R + 读取单个文件 ⌘R + onClickItem("read-all")} disabled={disabledItems?.read}> + 自动读取并更新结构 ⌘⇧R + + onClickItem("save")} disabled={disabledItems?.save}> - 保存 ⌘S + 保存所有文件 ⌘S @@ -54,7 +60,9 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) { 导出 - onClickItem("export")} disabled={disabledItems?.export}>导出 JSON + onClickItem("export")} disabled={disabledItems?.export}> + 手动导出 JSON + diff --git a/src/components/biz/editor/table.tsx b/src/components/biz/editor/table.tsx new file mode 100644 index 0000000..85983a6 --- /dev/null +++ b/src/components/biz/editor/table.tsx @@ -0,0 +1,388 @@ +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; + setSelected: React.Dispatch>>; + 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; + onDeleteEntry: (path: string) => Promise | void; + onDeleteSelected: (paths: string[]) => Promise | 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>(new Set()); + + useEffect(() => { + setVisibleLangs(new Set(languages)); + }, [languages]); + + const virtuosoRef = useRef(null); + const scrollerRootRef = useRef(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) => { + 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) => , + TableHead: (props: React.HTMLAttributes) => , + TableRow: (props: React.HTMLAttributes) => , + TableBody: (props: React.HTMLAttributes) => , + TableFoot: (props: React.HTMLAttributes) => , + }), [tableWidth]); + + const headerContent = useCallback(() => ( + + + + {displayedLanguages.map((lang) => ( + + ))} + + + ), [allSelected, displayedLanguages, entries, setSelected]); + + const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { + const handleCopy = () => { + copy(entry.path); + toast.success("复制成功"); + }; + + return ( + <> + + + {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 ( +
+ { + const next = new Set(); + if (checked) { + for (const en of entries) next.add(en.path); + } + setSelected(next); + }} + /> + 翻译条目名称{lang}操作
+ { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(entry.path); else next.delete(entry.path); + return next; + }); + }} + /> + + + + {isEditing ? ( +