feat: implement drag-and-drop reordering for vault items and enhance sorting functionality

This commit is contained in:
shuaiplus
2026-04-26 20:32:55 +08:00
parent 2f7e66ee69
commit f48f3d0c8e
6 changed files with 361 additions and 38 deletions
+94 -3
View File
@@ -8,6 +8,7 @@ import {
MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT,
VAULT_ORDER_STORAGE_KEY,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY,
cipherTypeKey,
@@ -72,6 +73,15 @@ export default function VaultPage(props: VaultPageProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [vaultOrderedIds, setVaultOrderedIds] = useState<string[]>(() => {
if (typeof localStorage === 'undefined') return [];
try {
const parsed = JSON.parse(String(localStorage.getItem(VAULT_ORDER_STORAGE_KEY) || '[]'));
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
} catch {
return [];
}
});
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
@@ -117,6 +127,7 @@ export default function VaultPage(props: VaultPageProps) {
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null);
const suppressNextSortScrollRef = useRef(false);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0);
@@ -151,7 +162,7 @@ export default function VaultPage(props: VaultPageProps) {
useEffect(() => {
try {
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
if (saved === 'edited' || saved === 'created' || saved === 'name') {
if (saved === 'manual' || saved === 'edited' || saved === 'created' || saved === 'name') {
setSortMode(saved);
}
} catch {
@@ -167,6 +178,36 @@ export default function VaultPage(props: VaultPageProps) {
}
}, [sortMode]);
useEffect(() => {
if (props.loading) return;
const cipherById = new Map(props.ciphers.map((cipher) => [cipher.id, cipher]));
const validIds = new Set(cipherById.keys());
setVaultOrderedIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
const existing = new Set(filtered);
const missing = props.ciphers
.filter((cipher) => !existing.has(cipher.id))
.sort((a, b) => {
const diff = creationTimeValue(b) - creationTimeValue(a);
if (diff !== 0) return diff;
return String(b.id || '').localeCompare(String(a.id || ''));
})
.map((cipher) => cipher.id);
const next = [...missing, ...filtered];
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
return next;
});
}, [props.ciphers, props.loading]);
useEffect(() => {
if (props.loading) return;
try {
localStorage.setItem(VAULT_ORDER_STORAGE_KEY, JSON.stringify(vaultOrderedIds));
} catch {
// ignore storage write failures
}
}, [vaultOrderedIds, props.loading]);
useEffect(() => {
try {
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
@@ -321,8 +362,18 @@ export default function VaultPage(props: VaultPageProps) {
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
});
const orderMap = new Map(vaultOrderedIds.map((id, index) => [id, index]));
next.sort((a, b) => {
if (sortMode === 'edited') {
if (sortMode === 'manual') {
const orderA = orderMap.get(a.id);
const orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) {
const diff = orderA - orderB;
if (diff !== 0) return diff;
}
if (orderA != null) return -1;
if (orderB != null) return 1;
} else if (sortMode === 'edited') {
const diff = sortTimeValue(b) - sortTimeValue(a);
if (diff !== 0) return diff;
} else if (sortMode === 'created') {
@@ -340,7 +391,7 @@ export default function VaultPage(props: VaultPageProps) {
});
return next;
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts, vaultOrderedIds]);
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
@@ -349,6 +400,10 @@ export default function VaultPage(props: VaultPageProps) {
}, [sidebarFilter]);
useEffect(() => {
if (suppressNextSortScrollRef.current) {
suppressNextSortScrollRef.current = false;
return;
}
setListScrollTop(0);
listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]);
@@ -359,6 +414,40 @@ export default function VaultPage(props: VaultPageProps) {
}
}, [sidebarFilter.kind, sortMode]);
const canReorderVaultList =
!searchQuery &&
sidebarFilter.kind !== 'duplicates' &&
sidebarFilter.kind !== 'trash' &&
sidebarFilter.kind !== 'archive' &&
!props.loading &&
!busy;
function handleReorderVaultCipher(activeId: string, overId: string): void {
if (!canReorderVaultList || activeId === overId) return;
const currentIds = filteredCiphers.map((cipher) => cipher.id);
const fromIndex = currentIds.indexOf(activeId);
const toIndex = currentIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
const nextVisibleIds = [...currentIds];
const [moved] = nextVisibleIds.splice(fromIndex, 1);
nextVisibleIds.splice(toIndex, 0, moved);
setVaultOrderedIds((prev) => {
const validIds = new Set(props.ciphers.map((cipher) => cipher.id));
const nextVisibleSet = new Set(nextVisibleIds);
const existingHiddenIds = prev.filter((id) => validIds.has(id) && !nextVisibleSet.has(id));
const fallbackHiddenIds = props.ciphers
.map((cipher) => cipher.id)
.filter((id) => validIds.has(id) && !nextVisibleSet.has(id) && !existingHiddenIds.includes(id));
const next = [...nextVisibleIds, ...existingHiddenIds, ...fallbackHiddenIds];
return next;
});
if (sortMode !== 'manual') {
suppressNextSortScrollRef.current = true;
setSortMode('manual');
}
}
useEffect(() => {
if (isCreating) return;
if (!filteredCiphers.length) {
@@ -910,6 +999,7 @@ function folderName(id: string | null | undefined): string {
sidebarFilter={sidebarFilter}
isMobileLayout={isMobileLayout}
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
canReorder={canReorderVaultList}
createMenuOpen={createMenuOpen}
createMenuRef={createMenuRef}
sortMenuRef={sortMenuRef}
@@ -956,6 +1046,7 @@ function folderName(id: string | null | undefined): string {
setMoveOpen(true);
}}
onClearSelection={() => setSelectedMap({})}
onReorderCipher={handleReorderVaultCipher}
onScroll={setListScrollTop}
onToggleSelected={(cipherId, checked) =>
setSelectedMap((prev) => ({