mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
feat(renderer): drag-to-reorder tabs (dnd-kit)
This commit is contained in:
@@ -1,12 +1,92 @@
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useFileStore, type OpenTab } from '@/stores/file-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function SortableTab({
|
||||||
|
tab,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
tab: OpenTab;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: tab.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
data-testid={`tab-${tab.id}`}
|
||||||
|
className={cn(
|
||||||
|
'group flex h-full cursor-pointer select-none items-center gap-1 border-r border-border px-3 text-xs transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.dirty && (
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label="Unsaved changes"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[120px] truncate">{tab.title}</span>
|
||||||
|
<button
|
||||||
|
aria-label={`Close ${tab.title}`}
|
||||||
|
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose(tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const openTabs = useFileStore((s) => s.openTabs);
|
const openTabs = useFileStore((s) => s.openTabs);
|
||||||
const activeTabId = useFileStore((s) => s.activeTabId);
|
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||||
const setActiveTab = useFileStore((s) => s.setActiveTab);
|
const setActiveTab = useFileStore((s) => s.setActiveTab);
|
||||||
const closeTab = useFileStore((s) => s.closeTab);
|
const closeTab = useFileStore((s) => s.closeTab);
|
||||||
|
const reorderTabs = useFileStore((s) => s.reorderTabs);
|
||||||
|
|
||||||
|
// Require a small drag distance before activating — so a click on the tab
|
||||||
|
// body still triggers `onClick` (dnd-kit's PointerSensor default is 0).
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
if (openTabs.length === 0) {
|
if (openTabs.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -16,43 +96,32 @@ export function TabBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const fromIndex = openTabs.findIndex((t) => t.id === active.id);
|
||||||
|
const toIndex = openTabs.findIndex((t) => t.id === over.id);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
reorderTabs(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
|
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
|
||||||
<div className="flex h-full items-center px-1">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
{openTabs.map((tab) => {
|
<SortableContext items={openTabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
|
||||||
const isActive = tab.id === activeTabId;
|
<div className="flex h-full items-center px-1">
|
||||||
return (
|
{openTabs.map((tab) => (
|
||||||
<div
|
<SortableTab
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
role="tab"
|
tab={tab}
|
||||||
aria-selected={isActive}
|
isActive={tab.id === activeTabId}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
onSelect={setActiveTab}
|
||||||
className={cn(
|
onClose={closeTab}
|
||||||
'group flex h-full cursor-pointer items-center gap-1 border-r border-border px-3 text-xs transition-colors',
|
/>
|
||||||
isActive
|
))}
|
||||||
? 'bg-accent text-accent-foreground'
|
</div>
|
||||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
</SortableContext>
|
||||||
)}
|
</DndContext>
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.dirty && (
|
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-label="Unsaved changes" />
|
|
||||||
)}
|
|
||||||
<span className="truncate max-w-[120px]">{tab.title}</span>
|
|
||||||
<button
|
|
||||||
aria-label={`Close ${tab.title}`}
|
|
||||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
closeTab(tab.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,4 +90,27 @@ describe('TabBar', () => {
|
|||||||
const dirtyDot = screen.getByLabelText('Unsaved changes');
|
const dirtyDot = screen.getByLabelText('Unsaved changes');
|
||||||
expect(dirtyDot).toBeInTheDocument();
|
expect(dirtyDot).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reorders tabs when reorderTabs is called (drag end handler integration)', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/a.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
// Initial DOM order
|
||||||
|
const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||||
|
expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']);
|
||||||
|
|
||||||
|
// Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2
|
||||||
|
act(() => {
|
||||||
|
useFileStore.getState().reorderTabs(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||||
|
expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user