I18n-Translate-It/src/pages/editor.tsx

438 lines
17 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
getProject,
getStructure,
listLanguages,
type Project,
type ProjectStructure,
getLanguageTranslations,
upsertLanguageTranslations,
upsertStructure,
deleteEntryFromAllLanguages,
renameEntryInAllLanguages,
} from "@/lib/db";
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
import { EntryNameModal } from "@/components/biz/entry-name-modal";
import { AiTranslateModal } from "@/components/biz/ai-translate-modal";
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
export default function Editor() {
const { id: projectId } = useParams();
const [project, setProject] = useState<Project | null>(null);
const [structure, setStructure] = useState<ProjectStructure | null>(null);
const [languages, setLanguages] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [pageError, setPageError] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null);
const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const scrollerRootRef = useRef<HTMLElement | Window | null>(null);
const [query, setQuery] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [fullMatch, setFullMatch] = useState(false);
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
function highlightRow(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);
}
};
// 等一帧,确保滚动定位后的 DOM 稳定
requestAnimationFrame(() => tryFindAndAnimate(0));
}
function scrollToQuery(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) });
}
}
async function refresh() {
if (!projectId) return;
setLoading(true);
setPageError(null);
try {
const p = await getProject(projectId);
if (!p) throw new Error("项目不存在");
setProject(p);
const s = await getStructure(projectId);
if (s) setStructure(s);
const langs = await listLanguages(projectId);
setLanguages(langs);
} catch (e) {
setPageError((e as Error)?.message ?? "加载失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const entries: FlatEntry[] = useMemo(() => {
if (!structure) return [];
return flattenEntries(structure.root);
}, [structure]);
const [valuesByLang, setValuesByLang] = useState<Record<string, Record<string, string>>>({});
const inline = useTranslationInlineEdit({
projectId,
valuesByLang,
setValuesByLang,
onError: (m) => setPageError(m),
});
const colWidth = useMemo(() => `${100 / (languages.length + 2)}%`, [languages.length]);
const virtuosoComponents = useMemo(() => ({
Table: (props: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} />,
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} />,
}), []);
const headerContent = useCallback(() => (
<tr>
<th style={{ width: colWidth }} className="text-left px-3 py-2"></th>
{languages.map((lang) => (
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
))}
<th style={{ width: "5em" }} className="text-left px-3 py-2"></th>
</tr>
), [languages, colWidth]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
return (
<>
<td style={{ width: colWidth }} className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
{languages.map((lang) => {
const isEditing = inline.isEditingCell(entry.path, lang);
const isSaving = inline.isSavingCell(entry.path, lang);
const displayValue = inline.getDisplayValue(entry.path, lang);
return (
<td key={`${entry.path}:${lang}`} style={{ width: colWidth }} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Input
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="h-8"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-muted-foreground"></span>}
</button>
)}
</td>
);
})}
<td style={{ width: "5em" }} className="px-3 py-2 align-top">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
<ArrowBigDownDash />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
<ArrowBigUpDash />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setAiModal({ open: true, path: entry.path })}>
<Languages />
AI
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
<PencilLine />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={async () => {
if (!projectId || !structure) return;
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
try {
const nextRoot = removeEntryAtPath(structure.root, entry.path);
await upsertStructure({ projectId, root: nextRoot });
await deleteEntryFromAllLanguages(projectId, entry.path);
setStructure({ projectId, root: nextRoot });
setValuesByLang((old) => {
const copy: typeof old = {};
for (const [langKey, vals] of Object.entries(old)) {
const rest = { ...vals } as Record<string, string>;
delete rest[entry.path];
copy[langKey] = rest;
}
return copy;
});
} catch (e) {
setPageError((e as Error)?.message ?? "删除失败");
}
}}
>
<Trash2 />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</>
);
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]);
useEffect(() => {
if (!projectId || languages.length === 0) return;
let cancelled = false;
(async () => {
const all: Record<string, Record<string, string>> = {};
for (const lang of languages) {
const rec = await getLanguageTranslations(projectId, lang);
all[lang] = rec?.values ?? {};
}
if (!cancelled) setValuesByLang(all);
})();
return () => {
cancelled = true;
};
}, [projectId, languages]);
return (
<div className="h-screen flex flex-col">
<header className="h-14 border-b px-2 md:px-4">
<div className="grid grid-cols-3 h-full items-center">
<div className="flex items-center gap-2">
<Button asChild size="icon" variant="ghost" aria-label="返回">
<Link to="/">
<ArrowLeft className="size-5" />
</Link>
</Button>
</div>
<div className="text-center font-medium truncate">
{project?.name ?? "编辑器"}
</div>
<div className="flex items-center justify-end gap-2">
{!structure ? (
<Button onClick={() => { setImportOpen(true); }}></Button>
) : (
<Button variant="outline" onClick={() => { setImportOpen(true); }}> JSON</Button>
)}
{languages.length > 0 && (
<Button variant="outline" onClick={() => setExportOpen(true)}> JSON</Button>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-auto p-4 md:p-6">
{pageError && (
<div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div>
)}
{loading ? (
<div className="text-sm text-muted-foreground">...</div>
) : !structure ? (
<div className="rounded-md border border-dashed p-6">
<div className="text-sm text-muted-foreground">
JSON
</div>
<div className="mt-3">
<Button onClick={() => { setImportOpen(true); }}></Button>
</div>
</div>
) : (
<div>
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}>
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
<Button
variant={fullMatch ? "default" : "outline"}
onClick={() => setFullMatch((v) => !v)}
title="切换全量匹配/模糊匹配"
>
<Brackets />
{fullMatch ? "全量匹配" : "模糊匹配"}
</Button>
<Button
variant={caseSensitive ? "default" : "outline"}
onClick={() => setCaseSensitive((v) => !v)}
title="切换大小写敏感"
>
<CaseSensitive />
{caseSensitive ? "区分大小写" : "忽略大小写"}
</Button>
<Button variant="outline" type="submit">
<LocateFixed />
</Button>
</form>
<div className="border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
style={{ height: "60vh" }}
fixedHeaderContent={headerContent}
itemContent={renderItemContent}
components={virtuosoComponents}
computeItemKey={(_index, entry) => entry.path}
scrollerRef={(el) => { scrollerRootRef.current = el; }}
/>
</div>
</div>
)}
</main>
<ImportLanguageModal
open={importOpen}
onOpenChange={setImportOpen}
projectId={projectId ?? ""}
hasStructure={!!structure}
onImported={refresh}
/>
<ExportLanguageModal
open={exportOpen}
onOpenChange={setExportOpen}
languages={languages}
valuesByLang={valuesByLang}
orderedPaths={entries.map((e) => e.path)}
/>
<AiTranslateModal
open={!!aiModal?.open}
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
languages={languages}
path={aiModal?.path ?? ""}
onConfirm={async (translations) => {
if (!projectId || !aiModal) return;
const targetPath = aiModal.path;
const updates: Record<string, Record<string, string>> = {};
try {
await Promise.all(
languages.map(async (lang) => {
const prev = valuesByLang[lang] ?? {};
const next = { ...prev, [targetPath]: translations[lang] };
updates[lang] = next;
await upsertLanguageTranslations(projectId, lang, next);
})
);
setValuesByLang((old) => ({ ...old, ...updates }));
} catch (e) {
const msg = (e as Error)?.message ?? "保存翻译失败";
setPageError(msg);
throw e;
}
}}
/>
<EntryNameModal
open={!!addModal?.open}
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"}
placeholder="请输入条目名称(不含点)"
onConfirm={async (name) => {
if (!projectId || !structure || !addModal) return;
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
await upsertStructure({ projectId, root: nextRoot });
setStructure({ projectId, root: nextRoot });
}}
validate={(name) => {
// 同级重名校验在结构函数中也会做,这里做基础提示即可
if (name.includes('.')) return "名称不能包含 '.'";
return null;
}}
/>
<EntryNameModal
open={!!renameModal?.open}
onOpenChange={(v) => setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))}
title="重命名条目"
placeholder="请输入新名称(不含点)"
defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''}
onConfirm={async (newName) => {
if (!projectId || !structure || !renameModal) return;
const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName);
await upsertStructure({ projectId, root: nextRoot });
await renameEntryInAllLanguages(projectId, renameModal.path, newPath);
setStructure({ projectId, root: nextRoot });
setValuesByLang((old) => {
const copy: typeof old = {};
for (const [langKey, vals] of Object.entries(old)) {
if (Object.prototype.hasOwnProperty.call(vals, renameModal.path)) {
const { [renameModal.path]: val, ...rest } = vals;
copy[langKey] = { ...rest, [newPath]: val };
} else {
copy[langKey] = vals;
}
}
return copy;
});
}}
validate={(name) => {
if (name.includes('.')) return "名称不能包含 '.'";
return null;
}}
/>
</div>
);
}