mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat: add draggable file tab reordering (#936)
This commit is contained in:
parent
50b72feffd
commit
e3423c2b7b
3 changed files with 375 additions and 3 deletions
|
|
@ -1,4 +1,10 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import {
|
||||
deleteProjectFile,
|
||||
|
|
@ -56,6 +62,7 @@ interface SketchState {
|
|||
}
|
||||
|
||||
const DESIGN_FILES_TAB = '__design_files__';
|
||||
type TabDropEdge = 'before' | 'after';
|
||||
|
||||
export function FileWorkspace({
|
||||
projectId,
|
||||
|
|
@ -88,8 +95,14 @@ export function FileWorkspace({
|
|||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [sketches, setSketches] = useState<Record<string, SketchState>>({});
|
||||
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
|
||||
const [draggedTabName, setDraggedTabName] = useState<string | null>(null);
|
||||
const [dragOverTab, setDragOverTab] = useState<{
|
||||
name: string;
|
||||
edge: TabDropEdge;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const tabsBarRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggedTabNameRef = useRef<string | null>(null);
|
||||
|
||||
const visibleFiles = useMemo(
|
||||
() => files.filter((file) => !isLiveArtifactImplementationPath(file.name)),
|
||||
|
|
@ -182,6 +195,29 @@ export function FileWorkspace({
|
|||
});
|
||||
}
|
||||
|
||||
function reorderPersistedTab(
|
||||
draggedName: string,
|
||||
targetName: string,
|
||||
edge: TabDropEdge,
|
||||
) {
|
||||
if (draggedName === targetName) return;
|
||||
if (!persistedTabs.includes(draggedName)) return;
|
||||
if (!persistedTabs.includes(targetName)) return;
|
||||
|
||||
const nextTabs = persistedTabs.filter((name) => name !== draggedName);
|
||||
const targetIndex = nextTabs.indexOf(targetName);
|
||||
if (targetIndex === -1) return;
|
||||
nextTabs.splice(edge === 'after' ? targetIndex + 1 : targetIndex, 0, draggedName);
|
||||
if (arraysEqual(nextTabs, persistedTabs)) return;
|
||||
onTabsStateChange({ tabs: nextTabs, active: tabsState.active });
|
||||
}
|
||||
|
||||
function clearTabDragState() {
|
||||
draggedTabNameRef.current = null;
|
||||
setDraggedTabName(null);
|
||||
setDragOverTab(null);
|
||||
}
|
||||
|
||||
async function handleFilePicked(ev: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = Array.from(ev.target.files ?? []);
|
||||
ev.target.value = '';
|
||||
|
|
@ -450,6 +486,14 @@ export function FileWorkspace({
|
|||
className="ws-tabs-bar"
|
||||
role="tablist"
|
||||
aria-label={t('workspace.designFiles')}
|
||||
onDragLeave={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
setDragOverTab(null);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
clearTabDragState();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -485,6 +529,44 @@ export function FileWorkspace({
|
|||
onClose={() => closeTab(name)}
|
||||
kind={kind}
|
||||
liveArtifact={liveArtifact}
|
||||
draggable={persistedTabs.includes(name)}
|
||||
dragging={draggedTabName === name}
|
||||
dragOverEdge={
|
||||
dragOverTab?.name === name && draggedTabName !== name
|
||||
? dragOverTab.edge
|
||||
: null
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', name);
|
||||
draggedTabNameRef.current = name;
|
||||
setDraggedTabName(name);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const currentDraggedName = draggedTabNameRef.current ?? draggedTabName;
|
||||
if (!currentDraggedName || currentDraggedName === name) return;
|
||||
if (!persistedTabs.includes(currentDraggedName)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
const edge = tabDropEdgeFromEvent(event);
|
||||
setDragOverTab((current) =>
|
||||
current?.name === name && current.edge === edge
|
||||
? current
|
||||
: { name, edge },
|
||||
);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setDragOverTab((current) => (current?.name === name ? null : current));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const draggedName = draggedTabNameRef.current || draggedTabName;
|
||||
if (draggedName) {
|
||||
reorderPersistedTab(draggedName, name, tabDropEdgeFromEvent(event));
|
||||
}
|
||||
clearTabDragState();
|
||||
}}
|
||||
onDragEnd={clearTabDragState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -618,6 +700,14 @@ function Tab({
|
|||
closable = true,
|
||||
kind,
|
||||
liveArtifact,
|
||||
draggable = false,
|
||||
dragging = false,
|
||||
dragOverEdge,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
|
|
@ -626,12 +716,27 @@ function Tab({
|
|||
closable?: boolean;
|
||||
kind?: ProjectFile['kind'] | 'live-artifact';
|
||||
liveArtifact?: LiveArtifactWorkspaceEntry;
|
||||
draggable?: boolean;
|
||||
dragging?: boolean;
|
||||
dragOverEdge?: TabDropEdge | null;
|
||||
onDragStart?: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||
onDragOver?: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||
onDragLeave?: () => void;
|
||||
onDrop?: (event: ReactDragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const iconName = kindIconName(kind);
|
||||
return (
|
||||
<div
|
||||
className={`ws-tab${kind === 'live-artifact' ? ' live-artifact-tab' : ''} ${active ? 'active' : ''}`}
|
||||
className={[
|
||||
'ws-tab',
|
||||
kind === 'live-artifact' ? 'live-artifact-tab' : '',
|
||||
active ? 'active' : '',
|
||||
draggable ? 'draggable' : '',
|
||||
dragging ? 'dragging' : '',
|
||||
dragOverEdge ? `drag-over-${dragOverEdge}` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onActivate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
|
@ -642,6 +747,12 @@ function Tab({
|
|||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
draggable={draggable}
|
||||
onDragStart={draggable ? onDragStart : undefined}
|
||||
onDragOver={draggable ? onDragOver : undefined}
|
||||
onDragLeave={draggable ? onDragLeave : undefined}
|
||||
onDrop={draggable ? onDrop : undefined}
|
||||
onDragEnd={draggable ? onDragEnd : undefined}
|
||||
>
|
||||
{iconName ? (
|
||||
<span className="tab-icon" aria-hidden>
|
||||
|
|
@ -674,6 +785,16 @@ function Tab({
|
|||
);
|
||||
}
|
||||
|
||||
function tabDropEdgeFromEvent(event: ReactDragEvent<HTMLDivElement>): TabDropEdge {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
return event.clientX > rect.left + rect.width / 2 ? 'after' : 'before';
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
export function scrollWorkspaceTabsWithWheel(
|
||||
tabBar: Pick<HTMLDivElement, 'clientWidth' | 'scrollLeft' | 'scrollWidth'>,
|
||||
event: Pick<globalThis.WheelEvent, 'ctrlKey' | 'deltaMode' | 'deltaX' | 'deltaY' | 'preventDefault'>,
|
||||
|
|
|
|||
|
|
@ -6789,6 +6789,21 @@ button.connector-action.is-loading {
|
|||
max-width: 320px;
|
||||
}
|
||||
.ws-tab:hover { background: var(--bg-subtle); color: var(--text); }
|
||||
.ws-tab.draggable { cursor: grab; }
|
||||
.ws-tab.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ws-tab.drag-over-before {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
.ws-tab.drag-over-after {
|
||||
box-shadow: inset -2px 0 0 var(--accent);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
.ws-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,99 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
|
||||
import { projectSplitClassName } from '../../src/components/ProjectView';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement | null = null;
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
host?.remove();
|
||||
host = null;
|
||||
});
|
||||
|
||||
function workspaceFile(name: string): ProjectFile {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
type: 'file',
|
||||
size: 100,
|
||||
mtime: 1700000000,
|
||||
kind: name.endsWith('.html') ? 'html' : 'text',
|
||||
mime: name.endsWith('.html') ? 'text/html' : 'text/plain',
|
||||
};
|
||||
}
|
||||
|
||||
function renderWorkspace(element: React.ReactElement) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(element);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
function getTabByName(container: HTMLElement, name: RegExp): HTMLElement {
|
||||
const tabs = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'));
|
||||
const tab = tabs.find((node) => name.test(node.textContent ?? ''));
|
||||
if (!tab) throw new Error(`Could not find tab matching ${name}`);
|
||||
return tab;
|
||||
}
|
||||
|
||||
function createDragDataTransfer() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
effectAllowed: 'move',
|
||||
dropEffect: 'move',
|
||||
getData: vi.fn((type: string) => store.get(type) ?? ''),
|
||||
setData: vi.fn((type: string, value: string) => {
|
||||
store.set(type, value);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchDragEvent(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
dataTransfer = createDragDataTransfer(),
|
||||
clientX = 0,
|
||||
relatedTarget: EventTarget | null = null,
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
clientX: { value: clientX },
|
||||
dataTransfer: { value: dataTransfer },
|
||||
relatedTarget: { value: relatedTarget },
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return dataTransfer;
|
||||
}
|
||||
|
||||
function stubTabRect(tab: HTMLElement, left = 0, width = 100) {
|
||||
tab.getBoundingClientRect = vi.fn(() => ({
|
||||
x: left,
|
||||
y: 0,
|
||||
left,
|
||||
top: 0,
|
||||
right: left + width,
|
||||
bottom: 20,
|
||||
width,
|
||||
height: 20,
|
||||
toJSON: () => ({}),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('FileWorkspace upload input', () => {
|
||||
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
|
||||
|
|
@ -82,6 +173,151 @@ describe('FileWorkspace upload input', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('FileWorkspace tab reordering', () => {
|
||||
it('persists a dragged file tab before the tab it is dropped on', () => {
|
||||
const onTabsStateChange = vi.fn();
|
||||
|
||||
const container = renderWorkspace(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[
|
||||
workspaceFile('analysis.html'),
|
||||
workspaceFile('notes.md'),
|
||||
workspaceFile('summary.html'),
|
||||
]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{
|
||||
tabs: ['analysis.html', 'notes.md', 'summary.html'],
|
||||
active: null,
|
||||
}}
|
||||
onTabsStateChange={onTabsStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const source = getTabByName(container, /summary\.html/i);
|
||||
const target = getTabByName(container, /analysis\.html/i);
|
||||
stubTabRect(target);
|
||||
|
||||
let dataTransfer = createDragDataTransfer();
|
||||
act(() => {
|
||||
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
||||
});
|
||||
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
|
||||
act(() => dispatchDragEvent(target, 'drop', dataTransfer));
|
||||
|
||||
expect(onTabsStateChange).toHaveBeenCalledWith({
|
||||
tabs: ['summary.html', 'analysis.html', 'notes.md'],
|
||||
active: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists a dragged file tab after the tab when dropped on its right side', () => {
|
||||
const onTabsStateChange = vi.fn();
|
||||
|
||||
const container = renderWorkspace(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[
|
||||
workspaceFile('analysis.html'),
|
||||
workspaceFile('notes.md'),
|
||||
workspaceFile('summary.html'),
|
||||
]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{
|
||||
tabs: ['analysis.html', 'notes.md', 'summary.html'],
|
||||
active: null,
|
||||
}}
|
||||
onTabsStateChange={onTabsStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const source = getTabByName(container, /analysis\.html/i);
|
||||
const target = getTabByName(container, /summary\.html/i);
|
||||
stubTabRect(target);
|
||||
|
||||
let dataTransfer = createDragDataTransfer();
|
||||
act(() => {
|
||||
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
||||
});
|
||||
act(() => dispatchDragEvent(target, 'drop', dataTransfer, 75));
|
||||
|
||||
expect(onTabsStateChange).toHaveBeenCalledWith({
|
||||
tabs: ['notes.md', 'summary.html', 'analysis.html'],
|
||||
active: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not persist when a tab is dropped on itself', () => {
|
||||
const onTabsStateChange = vi.fn();
|
||||
|
||||
const container = renderWorkspace(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{
|
||||
tabs: ['analysis.html', 'notes.md'],
|
||||
active: null,
|
||||
}}
|
||||
onTabsStateChange={onTabsStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tab = getTabByName(container, /analysis\.html/i);
|
||||
stubTabRect(tab);
|
||||
|
||||
let dataTransfer = createDragDataTransfer();
|
||||
act(() => {
|
||||
dataTransfer = dispatchDragEvent(tab, 'dragstart', dataTransfer);
|
||||
});
|
||||
act(() => dispatchDragEvent(tab, 'drop', dataTransfer));
|
||||
|
||||
expect(onTabsStateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the drop indicator when the drag leaves the tab bar', () => {
|
||||
const container = renderWorkspace(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{
|
||||
tabs: ['analysis.html', 'notes.md'],
|
||||
active: null,
|
||||
}}
|
||||
onTabsStateChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const source = getTabByName(container, /analysis\.html/i);
|
||||
const target = getTabByName(container, /notes\.md/i);
|
||||
const tabBar = container.querySelector<HTMLElement>('.ws-tabs-bar');
|
||||
if (!tabBar) throw new Error('Could not find tabs bar');
|
||||
stubTabRect(target);
|
||||
|
||||
let dataTransfer = createDragDataTransfer();
|
||||
act(() => {
|
||||
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
||||
});
|
||||
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
|
||||
|
||||
expect(target.className).toContain('drag-over-before');
|
||||
|
||||
act(() => dispatchDragEvent(tabBar, 'dragleave', dataTransfer, 0, document.body));
|
||||
|
||||
expect(target.className).not.toContain('drag-over-before');
|
||||
expect(target.className).not.toContain('drag-over-after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectSplitClassName', () => {
|
||||
it('marks the project split as focused so the chat pane can collapse globally', () => {
|
||||
expect(projectSplitClassName(false)).toBe('split');
|
||||
|
|
|
|||
Loading…
Reference in a new issue