Feat: 上下移动条目功能(非拖拽

This commit is contained in:
奇趣保罗 2025-11-26 18:01:17 +08:00
parent 71424c8770
commit cd4dd441a4
2 changed files with 91 additions and 5 deletions

View File

@ -118,7 +118,11 @@ export function insertEntrySibling(
): StructureNode { ): StructureNode {
const cloned = cloneNode(root); const cloned = cloneNode(root);
const info = findParentAndIndex(cloned, targetPath); const info = findParentAndIndex(cloned, targetPath);
if (!info) return cloned; if (!info) {
console.log("未能找到父节点", targetPath);
return cloned;
}
const { parent, index } = info; const { parent, index } = info;
if (!parent.children) parent.children = []; if (!parent.children) parent.children = [];
const exists = parent.children.some((c) => c.key === newKey); const exists = parent.children.some((c) => c.key === newKey);
@ -153,4 +157,22 @@ export function renameEntryAtPath(root: StructureNode, path: string, newKey: str
return { root: cloned, newPath: segments.join(".") }; return { root: cloned, newPath: segments.join(".") };
} }
export function moveEntryByOffset(root: StructureNode, path: string, offset: number): StructureNode {
const cloned = cloneNode(root);
if (!offset) return cloned;
const info = findParentAndIndex(cloned, path);
if (!info) return cloned;
const { parent, index } = info;
const siblings = parent.children ?? [];
if (siblings.length <= 1) return cloned;
const nextIndex = Math.max(0, Math.min(siblings.length - 1, index + offset));
if (nextIndex === index) return cloned;
const [node] = siblings.splice(index, 1);
siblings.splice(nextIndex, 0, node);
console.log("cloned", cloned);
return cloned;
}

View File

@ -19,7 +19,7 @@ import {
} from "@/lib/db"; } from "@/lib/db";
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Download, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Reply, Save, Settings, Trash2 } from "lucide-react"; import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Download, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Reply, Save, Settings, Trash2 } from "lucide-react";
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit"; import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure"; import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath, moveEntryByOffset } from "@/lib/i18n-structure";
import { ImportLanguageModal } from "@/components/biz/import-language-modal"; import { ImportLanguageModal } from "@/components/biz/import-language-modal";
import { ExportLanguageModal } from "@/components/biz/export-language-modal"; import { ExportLanguageModal } from "@/components/biz/export-language-modal";
import { EntryNameModal } from "@/components/biz/entry-name-modal"; import { EntryNameModal } from "@/components/biz/entry-name-modal";
@ -64,6 +64,8 @@ export default function Editor() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set()); const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
const [moveCountUp, setMoveCountUp] = useState(1);
const [moveCountDown, setMoveCountDown] = useState(1);
const [savingAll, setSavingAll] = useState(false); const [savingAll, setSavingAll] = useState(false);
const { copy } = useClipboard(); const { copy } = useClipboard();
@ -94,6 +96,22 @@ export default function Editor() {
requestAnimationFrame(() => tryFindAndAnimate(0)); requestAnimationFrame(() => tryFindAndAnimate(0));
} }
const handleMove = useCallback(async (path: string, offset: number) => {
if (!projectId || !structure) return;
try {
const nextRoot = moveEntryByOffset(structure.root, path, offset);
await upsertStructure({ projectId, root: nextRoot });
setStructure({ projectId, root: nextRoot });
const nextEntries = flattenEntries(nextRoot);
const idx = nextEntries.findIndex((e) => e.path === path);
if (idx >= 0) {
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
}
} catch (e) {
setPageError((e as Error)?.message ?? "移动失败");
}
}, [projectId, structure]);
function scrollToQuery(ev: React.FormEvent<HTMLFormElement>) { function scrollToQuery(ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -317,13 +335,13 @@ export default function Editor() {
); );
})} })}
<td className="sticky right-0 px-3 py-2 align-top bg-white"> <td className="sticky right-0 px-3 py-2 align-top bg-white">
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
<MoreVertical /> <MoreVertical />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44"> <DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}> <DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
<ArrowBigDownDash /> <ArrowBigDownDash />
@ -342,6 +360,52 @@ export default function Editor() {
<PencilLine /> <PencilLine />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
handleMove(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={() => handleMove(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 <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={async () => { onClick={async () => {
@ -374,7 +438,7 @@ export default function Editor() {
</td> </td>
</> </>
); );
}, [displayedLanguages, inline, projectId, structure, setStructure, setValuesByLang, copy, selected]); }, [displayedLanguages, inline, projectId, structure, setStructure, setValuesByLang, copy, selected, moveCountUp, moveCountDown, handleMove]);
useEffect(() => { useEffect(() => {
if (!projectId || languages.length === 0) return; if (!projectId || languages.length === 0) return;