Implement drag and drop for reordering container tabs

This commit is contained in:
Hakan Shehu
2025-01-27 17:28:13 +01:00
parent 5c23bea5fa
commit 9147454937
15 changed files with 328 additions and 136 deletions

View File

@@ -7,7 +7,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { useEntryContainer } from '@/renderer/hooks/use-entry-container';
import { ChannelSettings } from '@/renderer/components/channels/channel-settings';
import { Conversation } from '@/renderer/components/messages/conversation';

View File

@@ -6,7 +6,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { ChatNotFound } from '@/renderer/components/chats/chat-not-found';
import { EntryCollaboratorsPopover } from '@/renderer/components/collaborators/entry-collaborators-popover';
import { Conversation } from '@/renderer/components/messages/conversation';

View File

@@ -7,7 +7,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { useEntryContainer } from '@/renderer/hooks/use-entry-container';
import { useEntryRadar } from '@/renderer/hooks/use-entry-radar';
import { DatabaseSettings } from '@/renderer/components/databases/database-settings';

View File

@@ -5,7 +5,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { FileNotFound } from '@/renderer/components/files/file-not-found';
import { useFileContainer } from '@/renderer/hooks/use-file-container';
import { FileSettings } from '@/renderer/components/files/file-settings';

View File

@@ -7,7 +7,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { useEntryContainer } from '@/renderer/hooks/use-entry-container';
import { useEntryRadar } from '@/renderer/hooks/use-entry-radar';
import { FolderSettings } from '@/renderer/components/folders/folder-settings';

View File

@@ -14,7 +14,7 @@ import {
DropdownMenuTrigger,
} from '@/renderer/components/ui/dropdown-menu';
import { useLayout } from '@/renderer/contexts/layout';
import { ContainerBreadcrumbItem } from '@/renderer/components/layouts/breadcrumbs/container-breadcrumb-item';
import { ContainerBreadcrumbItem } from '@/renderer/components/layouts/containers/container-breadcrumb-item';
interface ContainerBreadcrumbProps {
breadcrumb: string[];

View File

@@ -0,0 +1,36 @@
import { match } from 'ts-pattern';
import { getIdType, IdType } from '@colanode/core';
import { ContainerTab } from '@/shared/types/workspaces';
import { TabsContent } from '@/renderer/components/ui/tabs';
import { ChannelContainer } from '@/renderer/components/channels/channel-container';
import { ChatContainer } from '@/renderer/components/chats/chat-container';
import { DatabaseContainer } from '@/renderer/components/databases/database-container';
import { FileContainer } from '@/renderer/components/files/file-container';
import { FolderContainer } from '@/renderer/components/folders/folder-container';
import { PageContainer } from '@/renderer/components/pages/page-container';
import { RecordContainer } from '@/renderer/components/records/record-container';
interface ContainerTabContentProps {
tab: ContainerTab;
}
export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
return (
<TabsContent
value={tab.id}
key={tab.id}
className="h-full min-h-full w-full min-w-full m-0 pt-2"
>
{match(getIdType(tab.id))
.with(IdType.Channel, () => <ChannelContainer channelId={tab.id} />)
.with(IdType.Page, () => <PageContainer pageId={tab.id} />)
.with(IdType.Database, () => <DatabaseContainer databaseId={tab.id} />)
.with(IdType.Record, () => <RecordContainer recordId={tab.id} />)
.with(IdType.Chat, () => <ChatContainer chatId={tab.id} />)
.with(IdType.Folder, () => <FolderContainer folderId={tab.id} />)
.with(IdType.File, () => <FileContainer fileId={tab.id} />)
.otherwise(() => null)}
</TabsContent>
);
};

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { match } from 'ts-pattern';
import { getIdType, IdType } from '@colanode/core';
import { X } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import { TabsTrigger } from '@/renderer/components/ui/tabs';
import { ContainerTab } from '@/shared/types/workspaces';
import { cn } from '@/shared/lib/utils';
import { ChannelContainerTab } from '@/renderer/components/channels/channel-container-tab';
import { FileContainerTab } from '@/renderer/components/files/file-container-tab';
import { DatabaseContainerTab } from '@/renderer/components/databases/database-container-tab';
import { RecordContainerTab } from '@/renderer/components/records/record-container-tab';
import { FolderContainerTab } from '@/renderer/components/folders/folder-container-tab';
import { ChatContainerTab } from '@/renderer/components/chats/chat-container-tab';
import { PageContainerTab } from '@/renderer/components/pages/page-container-tab';
interface ContainerTabTriggerProps {
tab: ContainerTab;
onClose: () => void;
onMove: (before: string | null) => void;
}
export const ContainerTabTrigger = ({
tab,
onClose,
onMove,
}: ContainerTabTriggerProps) => {
const [, dragRef] = useDrag<string>({
type: 'container-tab',
item: tab.id,
canDrag: () => true,
end: (_item, monitor) => {
const dropResult = monitor.getDropResult<{ before: string | null }>();
if (!dropResult) {
return;
}
onMove(dropResult.before);
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [dropMonitor, dropRef] = useDrop({
accept: 'container-tab',
drop: () => ({
before: tab.id,
}),
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
const buttonRef = React.useRef<HTMLButtonElement>(null);
const dragDropRef = dragRef(dropRef(buttonRef));
return (
<TabsTrigger
value={tab.id}
key={tab.id}
className={cn(
'overflow-hidden rounded-b-none bg-muted py-2 data-[state=active]:z-10 data-[state=active]:shadow-none h-10 group/tab app-no-drag-region flex items-center justify-between gap-2',
tab.preview && 'italic',
dropMonitor.isOver &&
dropMonitor.canDrop &&
'border-l-2 border-blue-300'
)}
onAuxClick={(e) => {
if (e.button === 1) {
e.preventDefault();
onClose();
}
}}
ref={dragDropRef as React.LegacyRef<HTMLButtonElement>}
>
<div className="overflow-hidden truncate">
{match(getIdType(tab.id))
.with(IdType.Channel, () => (
<ChannelContainerTab channelId={tab.id} />
))
.with(IdType.Page, () => <PageContainerTab pageId={tab.id} />)
.with(IdType.Database, () => (
<DatabaseContainerTab databaseId={tab.id} />
))
.with(IdType.Record, () => <RecordContainerTab recordId={tab.id} />)
.with(IdType.Chat, () => <ChatContainerTab chatId={tab.id} />)
.with(IdType.Folder, () => <FolderContainerTab folderId={tab.id} />)
.with(IdType.File, () => <FileContainerTab fileId={tab.id} />)
.otherwise(() => null)}
</div>
<div
className="opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 transition-opacity duration-200 flex-shrink-0"
onClick={() => onClose()}
>
<X className="size-4 text-muted-foreground hover:text-primary" />
</div>
</TabsTrigger>
);
};

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { useDrop } from 'react-dnd';
import { ScrollArea, ScrollBar } from '@/renderer/components/ui/scroll-area';
import { Tabs, TabsList } from '@/renderer/components/ui/tabs';
import { ContainerTab } from '@/shared/types/workspaces';
import { ContainerTabTrigger } from '@/renderer/components/layouts/containers/container-tab-trigger';
import { ContainerTabContent } from '@/renderer/components/layouts/containers/container-tab-content';
import { cn } from '@/shared/lib/utils';
interface ContainerTabsProps {
tabs: ContainerTab[];
onTabChange: (value: string) => void;
onFocus: () => void;
onClose: (value: string) => void;
onMove: (tab: string, before: string | null) => void;
}
export const ContainerTabs = ({
tabs,
onTabChange,
onFocus,
onClose,
onMove,
}: ContainerTabsProps) => {
const activeTab = tabs.find((t) => t.active)?.id;
const [dropMonitor, dropRef] = useDrop({
accept: 'container-tab',
drop: () => ({
before: null,
}),
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
const buttonRef = React.useRef<HTMLDivElement>(null);
const dragDropRef = dropRef(buttonRef);
return (
<Tabs
defaultValue={tabs[0]?.id}
value={activeTab}
onValueChange={onTabChange}
onFocus={onFocus}
className="h-full min-h-full w-full min-w-full flex flex-col"
>
<ScrollArea>
<TabsList className="h-10 bg-slate-50 w-full justify-start p-0 app-drag-region">
{tabs.map((tab) => (
<ContainerTabTrigger
key={tab.id}
tab={tab}
onClose={() => onClose(tab.id)}
onMove={(before) => onMove(tab.id, before)}
/>
))}
<div
ref={dragDropRef as React.LegacyRef<HTMLDivElement>}
className={cn(
'h-full w-10',
dropMonitor.isOver &&
dropMonitor.canDrop &&
'border-l-2 border-blue-300'
)}
/>
</TabsList>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<div className="flex-grow overflow-hidden">
{tabs.map((tab) => (
<ContainerTabContent key={tab.id} tab={tab} />
))}
</div>
</Tabs>
);
};

View File

@@ -1,125 +0,0 @@
import { X } from 'lucide-react';
import { match } from 'ts-pattern';
import { getIdType, IdType } from '@colanode/core';
import { ScrollArea, ScrollBar } from '@/renderer/components/ui/scroll-area';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/renderer/components/ui/tabs';
import { cn } from '@/shared/lib/utils';
import { ChannelContainer } from '@/renderer/components/channels/channel-container';
import { ChatContainer } from '@/renderer/components/chats/chat-container';
import { DatabaseContainer } from '@/renderer/components/databases/database-container';
import { FileContainer } from '@/renderer/components/files/file-container';
import { FolderContainer } from '@/renderer/components/folders/folder-container';
import { PageContainer } from '@/renderer/components/pages/page-container';
import { RecordContainer } from '@/renderer/components/records/record-container';
import { ChannelContainerTab } from '@/renderer/components/channels/channel-container-tab';
import { DatabaseContainerTab } from '@/renderer/components/databases/database-container-tab';
import { FileContainerTab } from '@/renderer/components/files/file-container-tab';
import { FolderContainerTab } from '@/renderer/components/folders/folder-container-tab';
import { PageContainerTab } from '@/renderer/components/pages/page-container-tab';
import { RecordContainerTab } from '@/renderer/components/records/record-container-tab';
import { ChatContainerTab } from '@/renderer/components/chats/chat-container-tab';
import { ContainerTab } from '@/shared/types/workspaces';
interface LayoutTabsProps {
tabs: ContainerTab[];
onTabChange: (value: string) => void;
onFocus: () => void;
onClose: (value: string) => void;
}
export const LayoutTabs = ({
tabs,
onTabChange,
onFocus,
onClose,
}: LayoutTabsProps) => {
const activeTab = tabs.find((t) => t.active)?.id;
return (
<Tabs
defaultValue={tabs[0]?.id}
value={activeTab}
onValueChange={onTabChange}
onFocus={onFocus}
className="h-full min-h-full w-full min-w-full flex flex-col"
>
<ScrollArea>
<TabsList className="h-10 bg-slate-50 w-full justify-start p-0 app-drag-region">
{tabs.map((tab) => (
<TabsTrigger
value={tab.id}
key={tab.id}
className={cn(
'overflow-hidden rounded-b-none bg-muted py-2 data-[state=active]:z-10 data-[state=active]:shadow-none h-10 group/tab app-no-drag-region flex items-center justify-between gap-2',
tab.preview && 'italic'
)}
onAuxClick={(e) => {
if (e.button === 1) {
e.preventDefault();
onClose(tab.id);
}
}}
>
<div className="overflow-hidden truncate">
{match(getIdType(tab.id))
.with(IdType.Channel, () => (
<ChannelContainerTab channelId={tab.id} />
))
.with(IdType.Page, () => <PageContainerTab pageId={tab.id} />)
.with(IdType.Database, () => (
<DatabaseContainerTab databaseId={tab.id} />
))
.with(IdType.Record, () => (
<RecordContainerTab recordId={tab.id} />
))
.with(IdType.Chat, () => <ChatContainerTab chatId={tab.id} />)
.with(IdType.Folder, () => (
<FolderContainerTab folderId={tab.id} />
))
.with(IdType.File, () => <FileContainerTab fileId={tab.id} />)
.otherwise(() => null)}
</div>
<div
className="opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 transition-opacity duration-200 flex-shrink-0"
onClick={() => onClose(tab.id)}
>
<X className="size-4 text-muted-foreground hover:text-primary" />
</div>
</TabsTrigger>
))}
</TabsList>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<div className="flex-grow overflow-hidden">
{tabs.map((tab) => (
<TabsContent
value={tab.id}
key={tab.id}
className="h-full min-h-full w-full min-w-full m-0 pt-2"
>
{match(getIdType(tab.id))
.with(IdType.Channel, () => (
<ChannelContainer channelId={tab.id} />
))
.with(IdType.Page, () => <PageContainer pageId={tab.id} />)
.with(IdType.Database, () => (
<DatabaseContainer databaseId={tab.id} />
))
.with(IdType.Record, () => <RecordContainer recordId={tab.id} />)
.with(IdType.Chat, () => <ChatContainer chatId={tab.id} />)
.with(IdType.Folder, () => <FolderContainer folderId={tab.id} />)
.with(IdType.File, () => <FileContainer fileId={tab.id} />)
.otherwise(() => null)}
</TabsContent>
))}
</div>
</Tabs>
);
};

View File

@@ -1,6 +1,6 @@
import { Resizable } from 're-resizable';
import { LayoutTabs } from '@/renderer/components/layouts/layout-tabs';
import { ContainerTabs } from '@/renderer/components/layouts/containers/container-tabs';
import { Sidebar } from '@/renderer/components/layouts/sidebars/sidebar';
import { LayoutContext } from '@/renderer/contexts/layout';
import { useLayoutState } from '@/renderer/hooks/user-layout-state';
@@ -30,6 +30,8 @@ export const Layout = () => {
handlePreviewRight,
handleActivateLeft,
handleActivateRight,
handleMoveLeft,
handleMoveRight,
} = useLayoutState();
const shouldDisplayLeft = leftContainerMetadata.tabs.length > 0;
@@ -93,13 +95,14 @@ export const Layout = () => {
{shouldDisplayLeft && (
<div className="h-full max-h-screen w-full flex-grow overflow-hidden bg-white">
<LayoutTabs
<ContainerTabs
tabs={leftContainerMetadata.tabs}
onFocus={() => {
handleFocus('left');
}}
onClose={handleCloseLeft}
onTabChange={handleActivateLeft}
onMove={handleMoveLeft}
/>
</div>
)}
@@ -133,13 +136,14 @@ export const Layout = () => {
handleRightContainerResize(ref.offsetWidth);
}}
>
<LayoutTabs
<ContainerTabs
tabs={rightContainerMetadata.tabs}
onFocus={() => {
handleFocus('right');
}}
onTabChange={handleActivateRight}
onClose={handleCloseRight}
onMove={handleMoveRight}
/>
</Resizable>
)}

View File

@@ -10,7 +10,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { PageBody } from '@/renderer/components/pages/page-body';
interface PageContainerProps {

View File

@@ -9,7 +9,7 @@ import {
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/breadcrumbs/container-breadrumb';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { RecordBody } from '@/renderer/components/records/record-body';
import { RecordSettings } from '@/renderer/components/records/record-settings';

View File

@@ -391,6 +391,100 @@ export const useLayoutState = () => {
[activeContainer, handleActivateLeft, handleActivateRight]
);
const handleMoveLeft = useCallback(
(id: string, before: string | null) => {
const tabIndex = leftContainerMetadata.tabs.findIndex((t) => t.id === id);
if (tabIndex === -1) {
return;
}
const tab = leftContainerMetadata.tabs[tabIndex];
if (!tab) {
return;
}
if (before === null) {
const newTabs = [...leftContainerMetadata.tabs];
newTabs.splice(tabIndex, 1);
newTabs.push(tab);
replaceLeftContainerMetadata({
...leftContainerMetadata,
tabs: newTabs,
});
} else {
const beforeIndex = leftContainerMetadata.tabs.findIndex(
(t) => t.id === before
);
if (beforeIndex === -1 || tabIndex === beforeIndex - 1) {
return;
}
const newTabs = [...leftContainerMetadata.tabs];
newTabs.splice(tabIndex, 1);
const newIndex = newTabs.findIndex((t) => t.id === before);
newTabs.splice(newIndex, 0, tab);
replaceLeftContainerMetadata({
...leftContainerMetadata,
tabs: newTabs,
});
}
},
[leftContainerMetadata]
);
const handleMoveRight = useCallback(
(id: string, before: string | null) => {
const tabIndex = rightContainerMetadata.tabs.findIndex(
(t) => t.id === id
);
if (tabIndex === -1) {
return;
}
const tab = rightContainerMetadata.tabs[tabIndex];
if (!tab) {
return;
}
if (before === null) {
const newTabs = [...rightContainerMetadata.tabs];
newTabs.splice(tabIndex, 1);
newTabs.push(tab);
replaceRightContainerMetadata({
...rightContainerMetadata,
tabs: newTabs,
});
} else {
const beforeIndex = rightContainerMetadata.tabs.findIndex(
(t) => t.id === before
);
if (beforeIndex === -1 || tabIndex === beforeIndex - 1) {
return;
}
const newTabs = [...rightContainerMetadata.tabs];
newTabs.splice(tabIndex, 1);
const newIndex = newTabs.findIndex((t) => t.id === before);
newTabs.splice(newIndex, 0, tab);
replaceRightContainerMetadata({
...rightContainerMetadata,
tabs: newTabs,
});
}
},
[rightContainerMetadata]
);
const handleFocus = useCallback((side: 'left' | 'right') => {
setActiveContainer(side);
}, []);
@@ -416,5 +510,7 @@ export const useLayoutState = () => {
handleActivate,
handleActivateLeft,
handleActivateRight,
handleMoveLeft,
handleMoveRight,
};
};