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';
|
'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};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user