mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat: support drag-to-reorder for desktop tabs (#15787)
* ✨ feat: support drag-to-reorder for desktop tabs Make the Electron titlebar tabs draggable horizontally to reorder them, like Chrome tab dragging. Wires the existing `reorderTabs` store action to a @dnd-kit sortable context. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🐛 fix: preserve scroll position when reordering background tabs The active-tab auto-scroll effect depends on `tabs`, so reordering retriggered it and jumped the viewport back to the active tab. Guard it with a ref so it only scrolls when the active tab id actually changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
@@ -52,6 +54,10 @@ const TabItem = memo<TabItemProps>(
|
||||
const isUnread = useTabUnread(tab);
|
||||
const showUnreadDot = !isRunning && isUnread;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isActive) {
|
||||
onActivate(id, tab.url);
|
||||
@@ -102,10 +108,23 @@ const TabItem = memo<TabItemProps>(
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
className={cx(electronStylish.nodrag, styles.tab, isActive && styles.tabActive)}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
gap={6}
|
||||
ref={setNodeRef}
|
||||
className={cx(
|
||||
electronStylish.nodrag,
|
||||
styles.tab,
|
||||
isActive && styles.tabActive,
|
||||
isDragging && styles.tabDragging,
|
||||
)}
|
||||
style={{
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{meta.avatar ? (
|
||||
<span className={styles.avatarWrapper}>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
type Modifier,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { ActionIcon, ScrollArea } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
@@ -23,6 +34,9 @@ import TabItem from './TabItem';
|
||||
const TAB_WIDTH = 180;
|
||||
const TAB_GAP = 0;
|
||||
|
||||
// Tabs only reorder along the horizontal axis, so lock the drag transform to X.
|
||||
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
|
||||
|
||||
// Fallback when the active route doesn't define createNewTab: open the home page,
|
||||
// so the "+" button stays available on every page.
|
||||
const DEFAULT_NEW_TAB_ACTION: NewTabAction = {
|
||||
@@ -35,6 +49,7 @@ const TabBar = () => {
|
||||
const { t } = useTranslation('electron');
|
||||
const { allowed: canCreate, reason } = usePermission('create_content');
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const scrolledActiveTabIdRef = useRef<string | null>(null);
|
||||
const { tabs, activeTabId } = useResolvedTabs();
|
||||
const activateTab = useElectronStore((s) => s.activateTab);
|
||||
const addTab = useElectronStore((s) => s.addTab);
|
||||
@@ -42,6 +57,29 @@ const TabBar = () => {
|
||||
const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs);
|
||||
const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs);
|
||||
const closeRightTabs = useElectronStore((s) => s.closeRightTabs);
|
||||
const reorderTabs = useElectronStore((s) => s.reorderTabs);
|
||||
|
||||
const sensors = useSensors(
|
||||
// Require a small drag distance so a plain click still activates the tab.
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const tabIds = useMemo(() => tabs.map((tab) => tab.tab.id), [tabs]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const fromIndex = tabIds.indexOf(active.id as string);
|
||||
const toIndex = tabIds.indexOf(over.id as string);
|
||||
if (fromIndex < 0 || toIndex < 0) return;
|
||||
|
||||
reorderTabs(fromIndex, toIndex);
|
||||
},
|
||||
[tabIds, reorderTabs],
|
||||
);
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(id: string, url: string) => {
|
||||
@@ -114,6 +152,13 @@ const TabBar = () => {
|
||||
const activeIndex = tabs.findIndex((tab) => tab.tab.id === activeTabId);
|
||||
if (activeIndex < 0) return;
|
||||
|
||||
// Only scroll into view when the active tab itself changes. Reordering
|
||||
// background tabs keeps the same active tab, so skip it — otherwise every
|
||||
// drop would yank the viewport back to the active tab and lose the user's
|
||||
// scroll position.
|
||||
if (scrolledActiveTabIdRef.current === activeTabId) return;
|
||||
scrolledActiveTabIdRef.current = activeTabId;
|
||||
|
||||
const tabLeft = activeIndex * (TAB_WIDTH + TAB_GAP);
|
||||
const tabRight = tabLeft + TAB_WIDTH;
|
||||
const { scrollLeft, clientWidth } = viewport;
|
||||
@@ -173,20 +218,29 @@ const TabBar = () => {
|
||||
style: { alignItems: 'center', flexDirection: 'row', gap: TAB_GAP },
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
index={index}
|
||||
isActive={tab.tab.id === activeTabId}
|
||||
item={tab}
|
||||
key={tab.tab.id}
|
||||
totalCount={tabs.length}
|
||||
onActivate={handleActivate}
|
||||
onClose={handleClose}
|
||||
onCloseLeft={handleCloseLeft}
|
||||
onCloseOthers={handleCloseOthers}
|
||||
onCloseRight={handleCloseRight}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
index={index}
|
||||
isActive={tab.tab.id === activeTabId}
|
||||
item={tab}
|
||||
key={tab.tab.id}
|
||||
totalCount={tabs.length}
|
||||
onActivate={handleActivate}
|
||||
onClose={handleClose}
|
||||
onCloseLeft={handleCloseLeft}
|
||||
onCloseOthers={handleCloseOthers}
|
||||
onCloseRight={handleCloseRight}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{(newTabAction || !canCreate) && (
|
||||
<ActionIcon
|
||||
className={cx(electronStylish.nodrag, styles.newTabButton)}
|
||||
|
||||
@@ -92,6 +92,17 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
tabDragging: css`
|
||||
cursor: grabbing;
|
||||
z-index: 1;
|
||||
background-color: ${cssVar.colorBgElevated};
|
||||
box-shadow: ${cssVar.boxShadowSecondary};
|
||||
|
||||
&::before,
|
||||
& + &::before {
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
tabActive: css`
|
||||
background-color: ${cssVar.colorBgElevated};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user