mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement drag-and-drop reordering for vault items and enhance sorting functionality
This commit is contained in:
@@ -1,6 +1,26 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import type { JSX, RefObject } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
defaultAnimateLayoutChanges,
|
||||
type AnimateLayoutChanges,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -35,6 +55,7 @@ interface VaultListPanelProps {
|
||||
sidebarFilter: SidebarFilter;
|
||||
isMobileLayout: boolean;
|
||||
mobileFabVisible: boolean;
|
||||
canReorder: boolean;
|
||||
createMenuOpen: boolean;
|
||||
createMenuRef: RefObject<HTMLDivElement>;
|
||||
sortMenuRef: RefObject<HTMLDivElement>;
|
||||
@@ -56,13 +77,156 @@ interface VaultListPanelProps {
|
||||
onBulkUnarchive: () => void;
|
||||
onOpenMove: () => void;
|
||||
onClearSelection: () => void;
|
||||
onReorderCipher: (activeId: string, overId: string) => void;
|
||||
onScroll: (top: number) => void;
|
||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
listSubtitle: (cipher: Cipher) => string;
|
||||
}
|
||||
|
||||
interface SortableCipherListItemProps {
|
||||
cipher: Cipher;
|
||||
selected: boolean;
|
||||
checked: boolean;
|
||||
canReorder: boolean;
|
||||
subtitle: string;
|
||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
}
|
||||
|
||||
interface CipherListItemBodyProps {
|
||||
cipher: Cipher;
|
||||
checked: boolean;
|
||||
canReorder: boolean;
|
||||
subtitle: string;
|
||||
dragButtonRef?: (element: HTMLButtonElement | null) => void;
|
||||
dragButtonAttributes?: JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
dragButtonListeners?: Record<string, unknown>;
|
||||
onToggleSelected?: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher?: (cipherId: string) => void;
|
||||
}
|
||||
|
||||
function CipherListItemBody(props: CipherListItemBodyProps) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={props.checked}
|
||||
disabled={!props.onToggleSelected}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) => props.onToggleSelected?.(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" disabled={!props.onSelectCipher} onClick={() => props.onSelectCipher?.(props.cipher.id)}>
|
||||
<div className="list-icon-wrap">
|
||||
<VaultListIcon cipher={props.cipher} />
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={props.cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{props.cipher.decName || t('txt_no_name')}</span>
|
||||
</span>
|
||||
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={props.dragButtonRef}
|
||||
className="btn btn-secondary small cipher-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
disabled={!props.canReorder}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
{...props.dragButtonAttributes}
|
||||
{...props.dragButtonListeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
||||
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false;
|
||||
|
||||
function SortableCipherListItem(props: SortableCipherListItemProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.cipher.id,
|
||||
disabled: !props.canReorder,
|
||||
animateLayoutChanges,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`list-item ${props.selected ? 'active' : ''}${isDragging ? ' is-dragging is-sorting-source' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return;
|
||||
props.onSelectCipher(props.cipher.id);
|
||||
}}
|
||||
>
|
||||
<CipherListItemBody
|
||||
cipher={props.cipher}
|
||||
checked={props.checked}
|
||||
canReorder={props.canReorder}
|
||||
subtitle={props.subtitle}
|
||||
dragButtonRef={setActivatorNodeRef}
|
||||
dragButtonAttributes={dragButtonAttributes}
|
||||
dragButtonListeners={listeners}
|
||||
onToggleSelected={props.onToggleSelected}
|
||||
onSelectCipher={props.onSelectCipher}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
const [activeDragId, setActiveDragId] = useState('');
|
||||
const [activeDragWidth, setActiveDragWidth] = useState<number | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 6,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 120,
|
||||
tolerance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const sortableCiphers = props.canReorder ? props.filteredCiphers : props.visibleCiphers;
|
||||
const virtualPadTop = props.canReorder ? 0 : props.virtualRange.padTop;
|
||||
const virtualPadBottom = props.canReorder ? 0 : props.virtualRange.padBottom;
|
||||
const activeDragCipher = activeDragId ? sortableCiphers.find((cipher) => cipher.id === activeDragId) || null : null;
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveDragId(String(event.active.id));
|
||||
setActiveDragWidth(event.active.rect.current.initial?.width || null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const activeId = String(event.active.id);
|
||||
const overId = event.over ? String(event.over.id) : '';
|
||||
setActiveDragId('');
|
||||
setActiveDragWidth(null);
|
||||
if (!overId || activeId === overId) return;
|
||||
props.onReorderCipher(activeId, overId);
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveDragId('');
|
||||
setActiveDragWidth(null);
|
||||
};
|
||||
|
||||
const createMenu = (
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
<button
|
||||
@@ -193,37 +357,35 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div
|
||||
key={cipher.id}
|
||||
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
props.onSelectCipher(cipher.id);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||
<div className="list-icon-wrap">
|
||||
<VaultListIcon cipher={cipher} />
|
||||
<div style={{ paddingTop: `${virtualPadTop}px`, paddingBottom: `${virtualPadBottom}px` }}>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}>
|
||||
<SortableContext items={sortableCiphers.map((cipher) => cipher.id)} strategy={verticalListSortingStrategy}>
|
||||
{sortableCiphers.map((cipher) => (
|
||||
<SortableCipherListItem
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
selected={props.selectedCipherId === cipher.id}
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
canReorder={props.canReorder}
|
||||
subtitle={props.listSubtitle(cipher)}
|
||||
onToggleSelected={props.onToggleSelected}
|
||||
onSelectCipher={props.onSelectCipher}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay adjustScale={false}>
|
||||
{activeDragCipher ? (
|
||||
<div className="list-item cipher-drag-overlay" style={activeDragWidth ? { width: `${activeDragWidth}px` } : undefined}>
|
||||
<CipherListItemBody
|
||||
cipher={activeDragCipher}
|
||||
checked={!!props.selectedMap[activeDragCipher.id]}
|
||||
canReorder={true}
|
||||
subtitle={props.listSubtitle(activeDragCipher)}
|
||||
/>
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
||||
</span>
|
||||
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
|
||||
Reference in New Issue
Block a user