mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Reorder AI Assistant tabs by dragging (#39664)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
506b99456f
commit
2f4bbfa7d2
|
|
@ -962,6 +962,7 @@ function handleNewThreadClick() {
|
|||
:project-id="thread.projectId"
|
||||
@close-tab="preview.closeTab"
|
||||
@open-tab="preview.openTab"
|
||||
@reorder-tab="preview.reorderTab"
|
||||
/>
|
||||
<div :class="$style.previewContent">
|
||||
<InstanceAiWorkflowPreview
|
||||
|
|
|
|||
|
|
@ -1378,6 +1378,54 @@ describe('useCanvasPreview', () => {
|
|||
expect(ctx.activeTabId.value).toBe('wf-1');
|
||||
});
|
||||
|
||||
test('reorders the tabs and keeps the active tab', () => {
|
||||
const ctx = setup();
|
||||
registerWorkflow(ctx.thread, 'wf-1');
|
||||
registerWorkflow(ctx.thread, 'wf-2');
|
||||
registerWorkflow(ctx.thread, 'wf-3');
|
||||
ctx.selectTab('wf-2');
|
||||
|
||||
ctx.reorderTab('wf-3', 0);
|
||||
|
||||
expect(ctx.openTabs.value.map((tab) => tab.id)).toEqual(['wf-3', 'wf-1', 'wf-2']);
|
||||
expect(ctx.activeTabId.value).toBe('wf-2');
|
||||
});
|
||||
|
||||
test('saves the new order and the active tab after a reorder', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const ctx = setup({
|
||||
tabsStorage: { load: vi.fn().mockResolvedValue(null), save },
|
||||
});
|
||||
await flushPromises();
|
||||
registerWorkflow(ctx.thread, 'wf-1');
|
||||
registerWorkflow(ctx.thread, 'wf-2');
|
||||
registerWorkflow(ctx.thread, 'wf-3');
|
||||
ctx.selectTab('wf-2');
|
||||
// Let the save from the selection finish, so only the reorder can save below.
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_TIME.API.AUTOSAVE);
|
||||
save.mockClear();
|
||||
|
||||
ctx.reorderTab('wf-3', 0);
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_TIME.API.AUTOSAVE);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
tabs: [
|
||||
{ type: 'workflow', id: 'wf-3', name: 'Workflow wf-3' },
|
||||
{ type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' },
|
||||
{ type: 'workflow', id: 'wf-2', name: 'Workflow wf-2' },
|
||||
],
|
||||
closedTabs: [],
|
||||
activeTab: { type: 'workflow', id: 'wf-2' },
|
||||
previewOpen: true,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows a resource picked from the project in a new tab', () => {
|
||||
const ctx = setup();
|
||||
registerWorkflow(ctx.thread, 'wf-1');
|
||||
|
|
|
|||
|
|
@ -215,6 +215,23 @@ describe('useOpenArtifactTabs', () => {
|
|||
expect(tabs.openTabs.value.map((tab) => tab.id)).toEqual(saved.tabs.map((tab) => tab.id));
|
||||
});
|
||||
|
||||
it('moves a tab to a new position and keeps the closed tabs', () => {
|
||||
const { tabs } = setup([
|
||||
workflowTab('wf-1'),
|
||||
workflowTab('wf-2'),
|
||||
workflowTab('wf-3'),
|
||||
workflowTab('wf-4'),
|
||||
]);
|
||||
tabs.closeTab('wf-4');
|
||||
|
||||
tabs.moveTab('wf-3', 0);
|
||||
expect(ids(tabs.openTabs.value)).toEqual(['wf-3', 'wf-1', 'wf-2']);
|
||||
|
||||
tabs.moveTab('wf-3', 99);
|
||||
expect(ids(tabs.openTabs.value)).toEqual(['wf-1', 'wf-2', 'wf-3']);
|
||||
expect(tabs.reopenTab('wf-4')).toBe(true);
|
||||
});
|
||||
|
||||
it('waits for the save delay and sends one save for many changes', async () => {
|
||||
const { storage, save, finishLoad } = createStorage();
|
||||
const { tabs } = setup([workflowTab('wf-1'), workflowTab('wf-2')], storage);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,312 @@
|
|||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { effectScope, type EffectScope } from 'vue';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { TAB_DRAG_IGNORE_ATTRIBUTE, useTabDragReorder } from '../composables/useTabDragReorder';
|
||||
|
||||
const GAP = 4;
|
||||
const pointer = { pointerId: 1, pointerType: 'mouse' };
|
||||
|
||||
// jsdom has no layout, so each tab gets a fixed box: [id, width].
|
||||
function createTabs(layout: Array<[string, number]>) {
|
||||
let left = 0;
|
||||
return layout.map(([id, width]) => {
|
||||
const element = document.createElement('div');
|
||||
element.dataset.tabItemId = id;
|
||||
const box = { left, width };
|
||||
element.getBoundingClientRect = () =>
|
||||
({ left: box.left, width: box.width, right: box.left + box.width }) as DOMRect;
|
||||
left += width + GAP;
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
describe('useTabDragReorder', () => {
|
||||
let scope: EffectScope;
|
||||
let frameCallbacks: FrameRequestCallback[] = [];
|
||||
|
||||
// Positions update once per animation frame; tests draw a frame on demand.
|
||||
function flushFrame() {
|
||||
const callbacks = frameCallbacks;
|
||||
frameCallbacks = [];
|
||||
callbacks.forEach((callback) => callback(0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
frameCallbacks = [];
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {
|
||||
frameCallbacks = [];
|
||||
});
|
||||
});
|
||||
|
||||
function setup(layout: Array<[string, number]>) {
|
||||
const elements = createTabs(layout);
|
||||
// Attached, so events on a tab bubble up to the window like in the app.
|
||||
elements.forEach((element) => document.body.appendChild(element));
|
||||
const onReorder = vi.fn();
|
||||
const onDragStart = vi.fn();
|
||||
scope = effectScope();
|
||||
const drag = scope.run(() =>
|
||||
useTabDragReorder({ getTabElements: () => elements, onReorder, onDragStart }),
|
||||
)!;
|
||||
// The tab bar binds each tab's pointerdown to the composable the same way.
|
||||
elements.forEach((element) =>
|
||||
element.addEventListener('pointerdown', (event) =>
|
||||
drag.onPointerDown(element.dataset.tabItemId ?? '', event),
|
||||
),
|
||||
);
|
||||
|
||||
function press(
|
||||
tabId: string,
|
||||
clientX: number,
|
||||
init: { button?: number; pointerType?: string; target?: Element } = {},
|
||||
) {
|
||||
const tab = elements.find((element) => element.dataset.tabItemId === tabId);
|
||||
const { target = tab, ...eventInit } = init;
|
||||
if (target) fireEvent.pointerDown(target, { clientX, ...pointer, ...eventInit });
|
||||
}
|
||||
const move = (clientX: number) => {
|
||||
fireEvent.pointerMove(window, { clientX, ...pointer });
|
||||
flushFrame();
|
||||
};
|
||||
const transforms = () =>
|
||||
Object.fromEntries(
|
||||
elements.map((element) => [element.dataset.tabItemId, element.style.transform]),
|
||||
);
|
||||
const release = () => fireEvent.pointerUp(window, pointer);
|
||||
|
||||
return { ...drag, elements, onReorder, onDragStart, press, move, release, transforms };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('does not start a drag for a small movement', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
|
||||
ctx.press('a', 50);
|
||||
ctx.move(53);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onDragStart).not.toHaveBeenCalled();
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the other tabs aside and drops the tab after a tab it passes to the right', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
['c', 100],
|
||||
]);
|
||||
|
||||
ctx.press('a', 50);
|
||||
ctx.move(110);
|
||||
|
||||
expect(ctx.onDragStart).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.draggedTabId.value).toBe('a');
|
||||
expect(ctx.transforms()).toEqual({ a: 'translateX(60px)', b: 'translateX(-104px)', c: '' });
|
||||
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).toHaveBeenCalledWith('a', 1);
|
||||
expect(ctx.draggedTabId.value).toBeUndefined();
|
||||
expect(ctx.transforms()).toEqual({ a: '', b: '', c: '' });
|
||||
expect(ctx.elements.map((element) => element.style.transition)).toEqual(['', '', '']);
|
||||
});
|
||||
|
||||
it('moves the tabs once per frame, however many pointer moves arrive', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
ctx.press('a', 50);
|
||||
|
||||
fireEvent.pointerMove(window, { clientX: 60, ...pointer });
|
||||
fireEvent.pointerMove(window, { clientX: 70, ...pointer });
|
||||
expect(frameCallbacks).toHaveLength(1);
|
||||
expect(ctx.transforms().a).toBe('');
|
||||
|
||||
flushFrame();
|
||||
expect(ctx.transforms().a).toBe('translateX(20px)');
|
||||
});
|
||||
|
||||
it('drops at the last pointer position when the pointer is released before the next frame', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
ctx.press('a', 50);
|
||||
|
||||
fireEvent.pointerMove(window, { clientX: 200, ...pointer });
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).toHaveBeenCalledWith('a', 1);
|
||||
});
|
||||
|
||||
it('drops the tab before a tab it passes to the left', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
['c', 100],
|
||||
]);
|
||||
|
||||
ctx.press('c', 250);
|
||||
ctx.move(140);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).toHaveBeenCalledWith('c', 1);
|
||||
});
|
||||
|
||||
it('moves the second tab to the first place when the first tab is narrower', () => {
|
||||
const ctx = setup([
|
||||
['narrow', 60],
|
||||
['wide', 200],
|
||||
['other', 100],
|
||||
]);
|
||||
|
||||
// Drag well past the start of the row. The tab stops at the first tab's left edge.
|
||||
ctx.press('wide', 150);
|
||||
ctx.move(-200);
|
||||
|
||||
expect(ctx.transforms().wide).toBe('translateX(-64px)');
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).toHaveBeenCalledWith('wide', 0);
|
||||
});
|
||||
|
||||
it('moves a tab to the last place when the last tab is narrower', () => {
|
||||
const ctx = setup([
|
||||
['wide', 200],
|
||||
['middle', 100],
|
||||
['narrow', 60],
|
||||
]);
|
||||
|
||||
ctx.press('wide', 100);
|
||||
ctx.move(1000);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).toHaveBeenCalledWith('wide', 2);
|
||||
});
|
||||
|
||||
describe('when the tabs change during a drag', () => {
|
||||
it('drops next to the passed tab when a tab opens before it', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
['c', 100],
|
||||
]);
|
||||
ctx.press('a', 50);
|
||||
ctx.move(160);
|
||||
|
||||
// The agent opens a tab at the start while the pointer is down.
|
||||
const [opened] = createTabs([['new', 100]]);
|
||||
ctx.elements.unshift(opened);
|
||||
ctx.release();
|
||||
|
||||
// "a" passed "b", so it lands after "b": index 2 in [new, a, b, c].
|
||||
expect(ctx.onReorder).toHaveBeenCalledWith('a', 2);
|
||||
});
|
||||
|
||||
it('does not reorder when the passed tab closes', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
['c', 100],
|
||||
]);
|
||||
ctx.press('a', 50);
|
||||
ctx.move(160);
|
||||
|
||||
ctx.elements.splice(1, 1);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reorder when the dragged tab closes', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
ctx.press('a', 50);
|
||||
ctx.move(160);
|
||||
|
||||
ctx.elements.splice(0, 1);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reorder when the tab is dropped in its own place', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
|
||||
ctx.press('a', 50);
|
||||
ctx.move(70);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onDragStart).toHaveBeenCalled();
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels the drag when Escape is pressed', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
|
||||
ctx.press('a', 50);
|
||||
ctx.move(200);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
expect(ctx.draggedTabId.value).toBeUndefined();
|
||||
expect(ctx.transforms()).toEqual({ a: '', b: '' });
|
||||
});
|
||||
|
||||
it('does not start a drag from the close button', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.setAttribute(TAB_DRAG_IGNORE_ATTRIBUTE, '');
|
||||
ctx.elements[0].appendChild(closeButton);
|
||||
|
||||
ctx.press('a', 90, { target: closeButton });
|
||||
ctx.move(200);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onDragStart).not.toHaveBeenCalled();
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start a drag from touch or from a secondary button', () => {
|
||||
const ctx = setup([
|
||||
['a', 100],
|
||||
['b', 100],
|
||||
]);
|
||||
|
||||
ctx.press('a', 50, { pointerType: 'touch' });
|
||||
ctx.move(200);
|
||||
ctx.release();
|
||||
ctx.press('a', 50, { button: 2 });
|
||||
ctx.move(200);
|
||||
ctx.release();
|
||||
|
||||
expect(ctx.onDragStart).not.toHaveBeenCalled();
|
||||
expect(ctx.onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ import { DEBOUNCE_TIME, HOVER_DELAY } from '@/app/constants/durations';
|
|||
import type { ArtifactTab } from '../useCanvasPreview';
|
||||
import { hasTabSummary, useArtifactTabSummaries } from '../useArtifactTabSummaries';
|
||||
import { useProjectResourceSearch } from '../composables/useProjectResourceSearch';
|
||||
import { TAB_DRAG_IGNORE_ATTRIBUTE, useTabDragReorder } from '../composables/useTabDragReorder';
|
||||
|
||||
// Experiment cleanup: remove with openWorkflowInAssistant.
|
||||
import ManualEditorButton from '@/experiments/openWorkflowInAssistant/components/ManualEditorButton.vue';
|
||||
|
|
@ -54,6 +55,7 @@ const emit = defineEmits<{
|
|||
toggleExpanded: [];
|
||||
closeTab: [tabId: string];
|
||||
openTab: [tab: ArtifactTab];
|
||||
reorderTab: [tabId: string, toIndex: number];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -208,6 +210,7 @@ let isPointerOnTab = false;
|
|||
|
||||
function showTabHoverCard(tab: ArtifactTab, event: MouseEvent) {
|
||||
if (!(event.currentTarget instanceof HTMLElement)) return;
|
||||
if (tabDrag.draggedTabId.value !== undefined) return;
|
||||
const target = { tabId: tab.id, reference: event.currentTarget };
|
||||
isPointerOnTab = true;
|
||||
stopCloseTimer();
|
||||
|
|
@ -248,6 +251,17 @@ function handleHoverCardOpenChange(open: boolean) {
|
|||
else if (!isPointerOnTab) scheduleHideTabHoverCard();
|
||||
}
|
||||
|
||||
// --- Reordering ---
|
||||
|
||||
const tabDrag = useTabDragReorder({
|
||||
getTabElements: () => {
|
||||
const tabList = getTabListElement();
|
||||
return tabList ? Array.from(tabList.querySelectorAll<HTMLElement>('[data-tab-item-id]')) : [];
|
||||
},
|
||||
onReorder: (tabId, toIndex) => emit('reorderTab', tabId, toIndex),
|
||||
onDragStart: () => hideTabHoverCard(),
|
||||
});
|
||||
|
||||
// --- New tab picker ---
|
||||
|
||||
const isPickerOpen = ref(false);
|
||||
|
|
@ -307,8 +321,15 @@ async function handleCopyLink(tab: ArtifactTab) {
|
|||
<ContextMenuTrigger as-child>
|
||||
<!-- The close button cannot sit inside the trigger button, so both share a wrapper. -->
|
||||
<div
|
||||
:class="[$style.tab, { [$style.tabActive]: tab.id === activeTabId }]"
|
||||
:class="[
|
||||
$style.tab,
|
||||
{
|
||||
[$style.tabActive]: tab.id === activeTabId,
|
||||
[$style.tabDragging]: tab.id === tabDrag.draggedTabId.value,
|
||||
},
|
||||
]"
|
||||
:data-tab-item-id="tab.id"
|
||||
@pointerdown="tabDrag.onPointerDown(tab.id, $event)"
|
||||
@mouseenter="showTabHoverCard(tab, $event)"
|
||||
@mouseleave="handleTabMouseLeave"
|
||||
@contextmenu="hideTabHoverCard"
|
||||
|
|
@ -338,6 +359,7 @@ async function handleCopyLink(tab: ArtifactTab) {
|
|||
variant="ghost"
|
||||
size="xsmall"
|
||||
:class="$style.closeButton"
|
||||
v-bind="{ [TAB_DRAG_IGNORE_ATTRIBUTE]: '' }"
|
||||
:aria-label="
|
||||
i18n.baseText('instanceAi.previewTabBar.closeTab', {
|
||||
interpolate: { name: tabName(tab) },
|
||||
|
|
@ -541,6 +563,12 @@ async function handleCopyLink(tab: ArtifactTab) {
|
|||
--tab--background: light-dark(var(--color--neutral-150), var(--color--neutral-800));
|
||||
}
|
||||
|
||||
// The drag composable moves the tabs through inline styles.
|
||||
&.tabDragging {
|
||||
z-index: 1;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
// Show the close button on hover and while it has keyboard focus.
|
||||
&:hover .closeSlot,
|
||||
.closeSlot:focus-within {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,19 @@ export function useOpenArtifactTabs({
|
|||
);
|
||||
}
|
||||
|
||||
/** Move an open tab to a new position in the tab order. */
|
||||
function moveTab(tabId: string, toIndex: number) {
|
||||
const tabs = [...openTabs.value];
|
||||
const fromIndex = tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (fromIndex === -1) return;
|
||||
const target = Math.max(0, Math.min(toIndex, tabs.length - 1));
|
||||
if (target === fromIndex) return;
|
||||
|
||||
const [moved] = tabs.splice(fromIndex, 1);
|
||||
tabs.splice(target, 0, moved);
|
||||
layout.value = { tabs: tabs.map(toStoredTab), closedTabs: currentLayout().closedTabs };
|
||||
}
|
||||
|
||||
// --- Storage ---
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
|
@ -267,6 +280,7 @@ export function useOpenArtifactTabs({
|
|||
closeTab,
|
||||
reopenTab,
|
||||
openTab,
|
||||
moveTab,
|
||||
saveTabs,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
import { onScopeDispose, ref } from 'vue';
|
||||
|
||||
// The pointer must move this far sideways before a press becomes a drag, so a
|
||||
// click still selects the tab.
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
const SLIDE_TRANSITION = 'transform 150ms ease';
|
||||
|
||||
/** Marks an element inside a tab, such as the close button, that does not start a drag. */
|
||||
export const TAB_DRAG_IGNORE_ATTRIBUTE = 'data-tab-drag-ignore';
|
||||
|
||||
interface TabBox {
|
||||
element: HTMLElement;
|
||||
id: string;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
tabId: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
boxes: TabBox[];
|
||||
fromIndex: number;
|
||||
targetIndex: number;
|
||||
}
|
||||
|
||||
const center = (box: TabBox) => box.left + box.width / 2;
|
||||
|
||||
/**
|
||||
* Drag tabs sideways to reorder them, like browser tabs. The dragged tab
|
||||
* follows the pointer and the other tabs move aside. The new order applies
|
||||
* when the pointer is released.
|
||||
*
|
||||
* The tab positions are written to the element styles once per frame, not
|
||||
* through reactive state, so a drag does not render the tab bar again.
|
||||
*/
|
||||
export function useTabDragReorder({
|
||||
getTabElements,
|
||||
onReorder,
|
||||
onDragStart,
|
||||
}: {
|
||||
/** The tab elements in display order. Each has a `data-tab-item-id` attribute. */
|
||||
getTabElements: () => HTMLElement[];
|
||||
onReorder: (tabId: string, toIndex: number) => void;
|
||||
onDragStart?: () => void;
|
||||
}) {
|
||||
const draggedTabId = ref<string>();
|
||||
let state: DragState | null = null;
|
||||
let frame: number | undefined;
|
||||
let pendingDeltaX = 0;
|
||||
|
||||
function measure(): TabBox[] {
|
||||
return getTabElements().map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { element, id: element.dataset.tabItemId ?? '', left: rect.left, width: rect.width };
|
||||
});
|
||||
}
|
||||
|
||||
function update(deltaX: number) {
|
||||
if (!state) return;
|
||||
const { boxes, fromIndex } = state;
|
||||
const dragged = boxes[fromIndex];
|
||||
const first = boxes[0];
|
||||
const last = boxes[boxes.length - 1];
|
||||
|
||||
// Keep the dragged tab inside the row of tabs.
|
||||
const minDelta = first.left - dragged.left;
|
||||
const maxDelta = last.left + last.width - (dragged.left + dragged.width);
|
||||
const delta = Math.max(minDelta, Math.min(deltaX, maxDelta));
|
||||
const draggedLeft = dragged.left + delta;
|
||||
const draggedRight = draggedLeft + dragged.width;
|
||||
|
||||
// The leading edge of the dragged tab passes a tab at its center. A center
|
||||
// check could not pass a first or last tab that is narrower than the dragged one.
|
||||
let targetIndex = fromIndex;
|
||||
for (let i = fromIndex + 1; i < boxes.length; i++) {
|
||||
if (draggedRight > center(boxes[i])) targetIndex = i;
|
||||
}
|
||||
for (let i = fromIndex - 1; i >= 0; i--) {
|
||||
if (draggedLeft < center(boxes[i])) targetIndex = i;
|
||||
}
|
||||
state.targetIndex = targetIndex;
|
||||
|
||||
// A tab that moves aside moves by the dragged tab's width plus the gap.
|
||||
const gap = boxes.length > 1 ? boxes[1].left - (boxes[0].left + boxes[0].width) : 0;
|
||||
const step = dragged.width + gap;
|
||||
boxes.forEach((box, i) => {
|
||||
let offset = 0;
|
||||
if (i === fromIndex) offset = delta;
|
||||
else if (i > fromIndex && i <= targetIndex) offset = -step;
|
||||
else if (i < fromIndex && i >= targetIndex) offset = step;
|
||||
box.element.style.transform = offset ? `translateX(${offset}px)` : '';
|
||||
});
|
||||
}
|
||||
|
||||
function startDrag() {
|
||||
if (!state) return;
|
||||
draggedTabId.value = state.tabId;
|
||||
state.boxes.forEach((box, i) => {
|
||||
// The other tabs slide aside. The dragged tab follows the pointer at once.
|
||||
box.element.style.transition = i === state?.fromIndex ? 'none' : SLIDE_TRANSITION;
|
||||
box.element.style.willChange = 'transform';
|
||||
});
|
||||
onDragStart?.();
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!state || event.pointerId !== state.pointerId) return;
|
||||
const deltaX = event.clientX - state.startX;
|
||||
if (draggedTabId.value === undefined) {
|
||||
if (Math.abs(deltaX) < DRAG_THRESHOLD_PX) return;
|
||||
startDrag();
|
||||
}
|
||||
event.preventDefault();
|
||||
// Pointer events can arrive faster than the screen refreshes, so draw once per frame.
|
||||
pendingDeltaX = deltaX;
|
||||
frame ??= requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
update(pendingDeltaX);
|
||||
});
|
||||
}
|
||||
|
||||
function stop(commit: boolean) {
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerCancel);
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = undefined;
|
||||
}
|
||||
|
||||
const finished = state;
|
||||
const wasDragging = draggedTabId.value !== undefined;
|
||||
state = null;
|
||||
draggedTabId.value = undefined;
|
||||
// Clear the transition together with the offsets, so the tabs land in
|
||||
// their new places at once instead of sliding back first.
|
||||
finished?.boxes.forEach(({ element }) => {
|
||||
element.style.transform = '';
|
||||
element.style.transition = '';
|
||||
element.style.willChange = '';
|
||||
});
|
||||
if (commit && wasDragging && finished && finished.targetIndex !== finished.fromIndex) {
|
||||
commitDrop(finished);
|
||||
}
|
||||
}
|
||||
|
||||
// The tabs can change while the pointer is down, for example when the agent
|
||||
// opens an artifact. So drop next to the tab the pointer passed, found in the
|
||||
// current tabs, instead of at a position from the start of the drag.
|
||||
function commitDrop({ tabId, boxes, targetIndex }: DragState) {
|
||||
const currentIds = getTabElements().map((element) => element.dataset.tabItemId);
|
||||
const toIndex = currentIds.indexOf(boxes[targetIndex].id);
|
||||
const fromIndex = currentIds.indexOf(tabId);
|
||||
if (toIndex === -1 || fromIndex === -1 || toIndex === fromIndex) return;
|
||||
onReorder(tabId, toIndex);
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (!state || event.pointerId !== state.pointerId) return;
|
||||
// Apply the last position that has not been drawn yet, so the drop matches the pointer.
|
||||
if (frame !== undefined) update(pendingDeltaX);
|
||||
stop(true);
|
||||
}
|
||||
|
||||
function onPointerCancel(event: PointerEvent) {
|
||||
if (state && event.pointerId === state.pointerId) stop(false);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || draggedTabId.value === undefined) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stop(false);
|
||||
}
|
||||
|
||||
function onPointerDown(tabId: string, event: PointerEvent) {
|
||||
// Touch drags scroll the tab row instead.
|
||||
if (event.button !== 0 || event.pointerType === 'touch' || state) return;
|
||||
if (event.target instanceof Element && event.target.closest(`[${TAB_DRAG_IGNORE_ATTRIBUTE}]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boxes = measure();
|
||||
const fromIndex = boxes.findIndex((box) => box.id === tabId);
|
||||
if (fromIndex === -1 || boxes.length < 2) return;
|
||||
|
||||
state = {
|
||||
tabId,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
boxes,
|
||||
fromIndex,
|
||||
targetIndex: fromIndex,
|
||||
};
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointercancel', onPointerCancel);
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
}
|
||||
|
||||
onScopeDispose(() => stop(false));
|
||||
|
||||
return { draggedTabId, onPointerDown };
|
||||
}
|
||||
|
|
@ -281,6 +281,11 @@ export function useCanvasPreview({ thread, initialAgentId, tabsStorage }: UseCan
|
|||
selectTab(tab.id);
|
||||
}
|
||||
|
||||
function reorderTab(tabId: string, toIndex: number) {
|
||||
tabs.moveTab(tabId, toIndex);
|
||||
tabs.saveTabs(activeTabId.value);
|
||||
}
|
||||
|
||||
function closeTab(tabId: string) {
|
||||
const nextTabId = tabs.closeTab(tabId);
|
||||
if (activeTabId.value === tabId) {
|
||||
|
|
@ -589,6 +594,7 @@ export function useCanvasPreview({ thread, initialAgentId, tabsStorage }: UseCan
|
|||
workflowRefreshKey,
|
||||
selectTab,
|
||||
openTab,
|
||||
reorderTab,
|
||||
closeTab,
|
||||
closePreview,
|
||||
openWorkflowPreview,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user