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:
Arvin Xu
2026-06-14 02:57:21 +08:00
committed by GitHub
parent fa58fd12a0
commit 6dcbd387f7
3 changed files with 99 additions and 15 deletions
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { import {
ActionIcon, ActionIcon,
Avatar, Avatar,
@@ -52,6 +54,10 @@ const TabItem = memo<TabItemProps>(
const isUnread = useTabUnread(tab); const isUnread = useTabUnread(tab);
const showUnreadDot = !isRunning && isUnread; const showUnreadDot = !isRunning && isUnread;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (!isActive) { if (!isActive) {
onActivate(id, tab.url); onActivate(id, tab.url);
@@ -102,10 +108,23 @@ const TabItem = memo<TabItemProps>(
<Flexbox <Flexbox
horizontal horizontal
align="center" align="center"
className={cx(electronStylish.nodrag, styles.tab, isActive && styles.tabActive)}
data-active={isActive ? 'true' : undefined} data-active={isActive ? 'true' : undefined}
gap={6} 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} onClick={handleClick}
{...attributes}
{...listeners}
> >
{meta.avatar ? ( {meta.avatar ? (
<span className={styles.avatarWrapper}> <span className={styles.avatarWrapper}>
@@ -1,5 +1,16 @@
'use client'; '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 { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { ActionIcon, ScrollArea } from '@lobehub/ui'; import { ActionIcon, ScrollArea } from '@lobehub/ui';
import { cx } from 'antd-style'; import { cx } from 'antd-style';
@@ -23,6 +34,9 @@ import TabItem from './TabItem';
const TAB_WIDTH = 180; const TAB_WIDTH = 180;
const TAB_GAP = 0; 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, // Fallback when the active route doesn't define createNewTab: open the home page,
// so the "+" button stays available on every page. // so the "+" button stays available on every page.
const DEFAULT_NEW_TAB_ACTION: NewTabAction = { const DEFAULT_NEW_TAB_ACTION: NewTabAction = {
@@ -35,6 +49,7 @@ const TabBar = () => {
const { t } = useTranslation('electron'); const { t } = useTranslation('electron');
const { allowed: canCreate, reason } = usePermission('create_content'); const { allowed: canCreate, reason } = usePermission('create_content');
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const scrolledActiveTabIdRef = useRef<string | null>(null);
const { tabs, activeTabId } = useResolvedTabs(); const { tabs, activeTabId } = useResolvedTabs();
const activateTab = useElectronStore((s) => s.activateTab); const activateTab = useElectronStore((s) => s.activateTab);
const addTab = useElectronStore((s) => s.addTab); const addTab = useElectronStore((s) => s.addTab);
@@ -42,6 +57,29 @@ const TabBar = () => {
const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs); const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs);
const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs); const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs);
const closeRightTabs = useElectronStore((s) => s.closeRightTabs); 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( const handleActivate = useCallback(
(id: string, url: string) => { (id: string, url: string) => {
@@ -114,6 +152,13 @@ const TabBar = () => {
const activeIndex = tabs.findIndex((tab) => tab.tab.id === activeTabId); const activeIndex = tabs.findIndex((tab) => tab.tab.id === activeTabId);
if (activeIndex < 0) return; 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 tabLeft = activeIndex * (TAB_WIDTH + TAB_GAP);
const tabRight = tabLeft + TAB_WIDTH; const tabRight = tabLeft + TAB_WIDTH;
const { scrollLeft, clientWidth } = viewport; const { scrollLeft, clientWidth } = viewport;
@@ -173,6 +218,13 @@ const TabBar = () => {
style: { alignItems: 'center', flexDirection: 'row', gap: TAB_GAP }, style: { alignItems: 'center', flexDirection: 'row', gap: TAB_GAP },
}} }}
> >
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<TabItem <TabItem
index={index} index={index}
@@ -187,6 +239,8 @@ const TabBar = () => {
onCloseRight={handleCloseRight} onCloseRight={handleCloseRight}
/> />
))} ))}
</SortableContext>
</DndContext>
{(newTabAction || !canCreate) && ( {(newTabAction || !canCreate) && (
<ActionIcon <ActionIcon
className={cx(electronStylish.nodrag, styles.newTabButton)} className={cx(electronStylish.nodrag, styles.newTabButton)}
@@ -92,6 +92,17 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
opacity: 0; opacity: 0;
} }
`, `,
tabDragging: css`
cursor: grabbing;
z-index: 1;
background-color: ${cssVar.colorBgElevated};
box-shadow: ${cssVar.boxShadowSecondary};
&::before,
& + &::before {
opacity: 0;
}
`,
tabActive: css` tabActive: css`
background-color: ${cssVar.colorBgElevated}; background-color: ${cssVar.colorBgElevated};