fix(ui): reorder dragged tabs live with a placeholder; drop terminal tabs on a group to join or split (#1054)

Dragging a tab showed no floating copy: the ghost cloned the tab's classes, and .tab { position: relative } loaded after .ghost { position: fixed }. The ghost is now positioned inline, and a tab dragged within its strip stays as an empty slot the other tabs move around; the insertion line for tabs from another strip is drawn inside the edge tabs so the scroller doesn't clip it. A terminal group is now a drop target like an editor pane: the middle joins the group, an edge splits off a new group (dropZoneAt moves to ui and is shared).

Closes #1052

Co-authored-by: fuqiuluo <[email protected]>
Цей коміт міститься в:
fuqiuluoтаfuqiuluo authored and GitHub committed 2026-11-04 00:41:57 +08:00
1 джерело e20eb1acf8
коміт 2b20c0f38e
11 files changed
+423 -55

No files matched your search

+1 -18
Переглянути файл
@@ -46,6 +46,7 @@ import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type Virtual
import { useDocMeta } from "../model/document";
import { inputDirty } from "../model/customDocuments";
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
import { dropZoneAt } from "@/ui/pointerDrag";
import { disambiguateTitles } from "../model/tabTitles";
import { TextEditorHost } from "./TextEditorHost";
import { FindBar } from "./FindBar";
@@ -119,24 +120,6 @@ function LayoutView({ node }: { node: LayoutNode }): ReactNode {
type DropZone = SplitEdge | "center";
/** Where a tab dropped at (x, y) over an editor goes: an edge (new split) or the pane itself. */
export function dropZoneAt(
x: number,
y: number,
rect: { left: number; top: number; width: number; height: number },
): DropZone {
const fx = (x - rect.left) / Math.max(1, rect.width);
const fy = (y - rect.top) / Math.max(1, rect.height);
const edges: Array<[SplitEdge, number]> = [
["left", fx],
["right", 1 - fx],
["top", fy],
["bottom", 1 - fy],
];
const [edge, dist] = edges.reduce((a, b) => (b[1] < a[1] ? b : a));
return dist < 0.25 ? edge : "center";
}
function parseTabPayload(raw: string): { paneId: string; tabId: string } | null {
try {
const v = JSON.parse(raw) as { paneId?: unknown; tabId?: unknown };
+22
Переглянути файл
@@ -40,6 +40,28 @@
min-height: 0;
}
/* tab drag & drop target on a group: an edge splits, the middle joins (the editor's look) */
.dropZone {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: var(--accent-soft);
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
}
.drop-left {
right: 50%;
}
.drop-right {
left: 50%;
}
.drop-top {
bottom: 50%;
}
.drop-bottom {
top: 50%;
}
.panel {
position: absolute;
inset: 0;
+42 -2
Переглянути файл
@@ -6,15 +6,18 @@ import { ToolWindowHeader, useOwnToolWindowHeader } from "@/workbench/toolwindow
import { usePlatform } from "@/platform/platformInfo";
import { commands, isTauri } from "@/platform/ipc";
import {
dropZoneAt,
IconButton,
Input,
Menu,
Popup,
registerDropTarget,
showContextMenu,
SplitGroup,
SplitHandle,
SplitPanel,
Tabs,
type DropEdge,
type MenuItem,
type TabItem,
} from "@/ui";
@@ -22,9 +25,11 @@ import {
activateGroup,
activeSession,
getSession,
groupDropIndex,
groupTabs,
layoutGroups,
moveTab,
moveTabToSplit,
newTab,
publishFocus,
renameTab,
@@ -173,15 +178,50 @@ function TabGroup({ group, corners }: { group: string; corners: Corners | null }
const activeTab = useTerminals((s) => s.activeTab);
const tabs = useMemo(() => groupTabs(allTabs, group), [allTabs, group]);
const active = tabs.find((t) => t.id === activeTab);
// A tab dropped on this group, like an editor tab on a pane: an edge of the terminal
// splits off a new group, the middle (and the header's empty part) joins at the end.
// The strip, the innermost target, still picks the exact slot.
const ref = useRef<HTMLDivElement>(null);
const panelsRef = useRef<HTMLDivElement>(null);
const [dropZone, setDropZone] = useState<DropEdge | "center" | null>(null);
const focusRaf = useRef(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const zoneAt = (id: string, x: number, y: number): DropEdge | "center" | null => {
const { tabs } = useTerminals.getState();
const rect = panelsRef.current?.getBoundingClientRect();
const zone = rect && y >= rect.top ? dropZoneAt(x, y, rect) : "center";
if (zone === "center") return groupDropIndex(tabs, id, group) === null ? null : zone;
const tab = tabs.find((t) => t.id === id);
return tab && (tab.group !== group || groupTabs(tabs, group).length > 1) ? zone : null;
};
return registerDropTarget(el, {
accepts: (type) => type === TAB_MIME,
over: (data, x, y) => setDropZone(zoneAt(data.payload, x, y)),
leave: () => setDropZone(null),
drop: (data, x, y) => {
setDropZone(null);
const zone = zoneAt(data.payload, x, y);
if (!zone) return;
if (zone === "center") moveTab(data.payload, group, groupTabs(useTerminals.getState().tabs, group).length);
else moveTabToSplit(data.payload, group, zone);
focusActiveTab(focusRaf);
},
});
}, [group]);
return (
<div className={styles.group}>
<div ref={ref} className={styles.group}>
{corners && (
<ToolWindowHeader title={corners.title === group} actions={corners.actions === group} closeTarget={!!active}>
<GroupTabs group={group} />
</ToolWindowHeader>
)}
{active && <TerminalFindBar sessionKey={active.session} />}
<div className={styles.panels}>
<div ref={panelsRef} className={styles.panels}>
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} />}
{tabs.map((t) => (
<div
key={t.id}
+48
Переглянути файл
@@ -3,8 +3,11 @@ import {
closeAll,
closeTab,
getSession,
groupDropIndex,
groupTabs,
layoutGroups,
moveTab,
moveTabToSplit,
neighbourGroup,
newTab,
splitTab,
@@ -96,6 +99,51 @@ describe("terminal tab groups", () => {
moveTab(d, tab(d).group, 0);
expect(typeof state().layout).toBe("object");
});
it("drops a tab released on a group's header or terminal at the end, unless it is already there", () => {
const a = newTab("/p");
const b = splitTab(a.id, "horizontal")!;
const c = newTab("/p"); // opens in the active group, b's
expect(strip(tab(b).group)).toEqual([b, c.id]);
expect(groupDropIndex(state().tabs, b, a.group)).toBe(1);
expect(groupDropIndex(state().tabs, a.id, tab(b).group)).toBe(2);
expect(groupDropIndex(state().tabs, c.id, tab(b).group)).toBeNull();
expect(groupDropIndex(state().tabs, "gone", a.group)).toBeNull();
});
it("splits off a new group from a tab dropped on an edge of a group's terminal", () => {
const a = newTab("/p");
const b = splitTab(a.id, "horizontal")!;
const c = newTab("/p"); // in b's group
const A = a.group;
const B = tab(b).group;
const groups = () => layoutGroups(state().layout);
// Right edge: a new group beside a's, which becomes the active one.
expect(moveTabToSplit(c.id, A, "right")).toBe(true);
const G = tab(c.id).group;
expect(groups()).toEqual([A, G, B]);
expect(state().selected[G]).toBe(c.id);
expect(state().activeTab).toBe(c.id);
// Bottom edge nests a vertical split; the emptied group leaves the layout.
expect(moveTabToSplit(b, A, "bottom")).toBe(true);
const H = tab(b).group;
expect(groups()).toEqual([A, H, G]);
expect(state().layout).toMatchObject({
orientation: "horizontal",
children: [{ orientation: "vertical", children: [A, H] }, G],
});
// Left and top edges insert before the target.
expect(moveTabToSplit(c.id, A, "left")).toBe(true);
const K = tab(c.id).group;
expect(groups()).toEqual([K, A, H]);
// A group's only tab has nothing to split off from; with company it can.
expect(moveTabToSplit(c.id, K, "right")).toBe(false);
expect(groups()).toEqual([K, A, H]);
moveTab(b, K, 1);
expect(moveTabToSplit(b, K, "right")).toBe(true);
expect(groups()).toEqual([K, tab(b).group, A]);
expect(strip(K)).toEqual([c.id]);
});
});
describe("group layout", () => {
+49 -4
Переглянути файл
@@ -300,20 +300,23 @@ export function layoutGroups(node: GroupLayout): string[] {
return typeof node === "string" ? [node] : node.children.flatMap(layoutGroups);
}
/** Inserts `key` after `target`, nesting a new split (`id`) when the orientation differs from the parent's. */
/** Inserts `key` after `target` (or `before` it), nesting a new split (`id`) when the orientation differs from the parent's. */
export function splitLayout(
node: GroupLayout,
target: string,
key: string,
orientation: SplitOrientation,
id: string,
before = false,
): GroupLayout {
if (typeof node === "string") return node === target ? { id, orientation, children: [node, key] } : node;
if (typeof node === "string")
return node === target ? { id, orientation, children: before ? [key, node] : [node, key] } : node;
const i = node.children.indexOf(target);
if (i >= 0 && node.orientation === orientation) {
return { ...node, children: [...node.children.slice(0, i + 1), key, ...node.children.slice(i + 1)] };
const at = before ? i : i + 1;
return { ...node, children: [...node.children.slice(0, at), key, ...node.children.slice(at)] };
}
return { ...node, children: node.children.map((c) => splitLayout(c, target, key, orientation, id)) };
return { ...node, children: node.children.map((c) => splitLayout(c, target, key, orientation, id, before)) };
}
/** Removes `key`; splits left with one child collapse into it, and same-orientation children merge up. */
@@ -404,6 +407,48 @@ export function unsplit(group: string) {
* Drag & drop: moves tab `id` to insertion `index` of `group`'s strip. Within
* its own group that reorders it; into another it also becomes the selected tab.
*/
export type SplitEdge = "left" | "right" | "top" | "bottom";
/**
* Drag & drop: tab `id` dropped on an edge of `group`'s terminal becomes a new group
* on that side, like an editor tab dropped on the edge of a pane. A group's only tab
* dropped on its own edge stays: there is nothing to split off from.
*/
export function moveTabToSplit(id: string, group: string, edge: SplitEdge): boolean {
const s = useTerminals.getState();
const tab = s.tabs.find((t) => t.id === id);
if (!tab || !layoutGroups(s.layout).includes(group)) return false;
if (tab.group === group && groupTabs(s.tabs, group).length < 2) return false;
const rest = withoutTab(s, id);
const target = newGroupId();
const orientation: SplitOrientation = edge === "left" || edge === "right" ? "horizontal" : "vertical";
useTerminals.setState({
tabs: [...rest.tabs, { ...tab, group: target }],
layout: splitLayout(
rest.layout,
group,
target,
orientation,
`split${++splitSeq}`,
edge === "left" || edge === "top",
),
selected: { ...rest.selected, [target]: id },
activeTab: id,
});
return true;
}
/**
* Where a tab dropped on the middle of a group (its terminal, or the header's empty
* part) lands: at the end, like an editor tab dropped on a pane; `null` when it is
* already in that group, so a sloppy release doesn't reorder it.
*/
export function groupDropIndex(tabs: readonly TerminalTab[], id: string, group: string): number | null {
const tab = tabs.find((t) => t.id === id);
if (!tab || tab.group === group) return null;
return groupTabs(tabs, group).length;
}
export function moveTab(id: string, group: string, index: number) {
useTerminals.setState((s) => {
const tab = s.tabs.find((t) => t.id === id);
+13 -7
Переглянути файл
@@ -209,10 +209,10 @@
height: 2px;
}
.vertical .dropBefore::before {
top: -2px;
top: 0;
}
.vertical .dropAfter::after {
bottom: -2px;
bottom: 0;
}
/* Close button on the left of the label */
@@ -224,9 +224,14 @@
padding: 0 10px 0 2px;
}
/* drag & drop */
.dragging {
opacity: 0.5;
/* drag & drop: the dragged tab is an empty slot the other tabs make room for (IDEA) */
.tab.dragging,
.tab.dragging:hover {
background: var(--bg-toolbar-hover);
box-shadow: none;
}
.dragging > * {
visibility: hidden;
}
.dropBefore::before,
.dropAfter::after {
@@ -239,11 +244,12 @@
z-index: 1;
pointer-events: none;
}
/* Inside the tab: the scroller clips anything past the first and last tab. */
.dropBefore::before {
left: -2px;
left: 0;
}
.dropAfter::after {
right: -2px;
right: 0;
}
@media (prefers-reduced-motion: reduce) {
+154 -9
Переглянути файл
@@ -1,17 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { dropIndex, revealHorizontally, revealVertically, TabStripRegistry, Tabs, type TabStripHandle } from "./Tabs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import {
dropIndex,
revealHorizontally,
revealVertically,
TabStripRegistry,
Tabs,
type TabItem,
type TabStripHandle,
type TabsDnd,
} from "./Tabs";
import { setDragRequiresAlt } from "./pointerDrag";
import styles from "./Tabs.module.css";
describe("dropIndex", () => {
it("finds the insertion index from tab midpoints", () => {
const rects = [
{ left: 0, width: 100 },
{ left: 100, width: 50 },
{ left: 0, top: 0, width: 100, height: 24 },
{ left: 100, top: 0, width: 50, height: 24 },
];
expect(dropIndex(10, rects)).toBe(0);
expect(dropIndex(60, rects)).toBe(1);
expect(dropIndex(130, rects)).toBe(2);
expect(dropIndex(500, rects)).toBe(2);
expect(dropIndex(10, 12, rects)).toBe(0);
expect(dropIndex(60, 12, rects)).toBe(1);
expect(dropIndex(130, 12, rects)).toBe(2);
expect(dropIndex(500, 12, rects)).toBe(2);
// Above or below the tabs still counts as their row.
expect(dropIndex(60, -5, rects)).toBe(1);
expect(dropIndex(60, 40, rects)).toBe(1);
expect(dropIndex(10, 12, [])).toBe(0);
});
it("follows wrapped rows", () => {
const rects = [
{ left: 0, top: 0, width: 100, height: 24 },
{ left: 100, top: 0, width: 100, height: 24 },
{ left: 0, top: 26, width: 100, height: 24 },
];
expect(dropIndex(180, 12, rects)).toBe(2);
expect(dropIndex(20, 30, rects)).toBe(2);
expect(dropIndex(150, 30, rects)).toBe(3);
expect(dropIndex(150, 25, rects)).toBe(2);
});
});
@@ -151,3 +179,120 @@ describe("Tabs interactions", () => {
expect(screen.queryByRole("button", { name: "Show Hidden Tabs" })).toBeNull();
});
});
describe("Tabs drag & drop", () => {
const TYPE = "application/x-test-tab";
let hit: Element | null;
beforeEach(() => {
hit = null;
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => hit });
// Tabs are 100px wide in display order (`order` while a drag previews a move).
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
const strip = this.parentElement;
if (strip?.getAttribute("role") !== "tablist") return new DOMRect(0, 0, 1000, 600);
const slot = this.style.order === "" ? [...strip.children].indexOf(this) : Number(this.style.order);
return new DOMRect(slot * 100, 0, 100, 24);
});
setDragRequiresAlt(() => false);
});
afterEach(() => vi.restoreAllMocks());
function Strip({ label, initial, onDrop }: { label: string; initial: string[]; onDrop?: TabsDnd["onDrop"] }) {
const [ids, setIds] = useState(initial);
const items: TabItem[] = ids.map((id) => ({ id, label: id }));
const dnd: TabsDnd = {
type: TYPE,
payload: (id) => id,
onDrop:
onDrop ??
((id, index) =>
setIds((list) => {
const from = list.indexOf(id);
const rest = list.filter((it) => it !== id);
rest.splice(index > from ? index - 1 : index, 0, id);
return rest;
})),
};
return <Tabs aria-label={label} items={items} activeId={ids[0]!} onSelect={() => {}} dnd={dnd} />;
}
const strip = (label: string) => screen.getByRole("tablist", { name: label });
const tab = (label: string, name: string) => within(strip(label)).getByRole("tab", { name }).parentElement!;
// Labels in display order, the way the strip lays them out.
const shown = (label: string) =>
within(strip(label))
.getAllByRole("tab")
.map((b) => b.parentElement!)
.sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
.map((el) => el.textContent);
const pointer = (el: Element | Window, type: string, x: number) =>
fireEvent(el, new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: 12 }));
it("moves the other tabs out of the way while a tab is dragged, like IDEA (#1052)", () => {
render(<Strip label="Terminal sessions" initial={["Local", "Local (2)", "Local (3)"]} />);
const dragged = tab("Terminal sessions", "Local");
pointer(within(dragged).getByRole("tab"), "pointerdown", 50);
hit = strip("Terminal sessions");
pointer(window, "pointermove", 60);
expect(dragged).toHaveClass(styles.dragging!);
const ghost = document.body.lastElementChild as HTMLElement;
expect(ghost).toHaveAttribute("aria-hidden", "true");
expect(ghost.style.position).toBe("fixed");
expect(shown("Terminal sessions")).toEqual(["Local", "Local (2)", "Local (3)"]);
// Past the neighbour's midpoint the slot moves; it stays until the pointer crosses back.
pointer(window, "pointermove", 160);
expect(shown("Terminal sessions")).toEqual(["Local (2)", "Local", "Local (3)"]);
pointer(window, "pointermove", 120);
expect(shown("Terminal sessions")).toEqual(["Local (2)", "Local", "Local (3)"]);
expect(document.querySelector(`.${styles.dropBefore}, .${styles.dropAfter}`)).toBeNull();
pointer(window, "pointerup", 120);
expect(shown("Terminal sessions")).toEqual(["Local (2)", "Local", "Local (3)"]);
expect(
within(strip("Terminal sessions"))
.getAllByRole("tab")
.map((b) => b.textContent),
).toEqual(["Local (2)", "Local", "Local (3)"]);
expect(tab("Terminal sessions", "Local")).not.toHaveClass(styles.dragging!);
expect(document.body.lastElementChild).not.toBe(ghost);
});
it("puts the slot back when the pointer leaves the strip and drops nothing on Escape", () => {
const onDrop = vi.fn();
render(<Strip label="Tabs" initial={["a", "b", "c"]} onDrop={onDrop} />);
pointer(within(tab("Tabs", "a")).getByRole("tab"), "pointerdown", 50);
hit = strip("Tabs");
pointer(window, "pointermove", 260);
expect(shown("Tabs")).toEqual(["b", "c", "a"]);
hit = null;
pointer(window, "pointermove", 260);
expect(shown("Tabs")).toEqual(["a", "b", "c"]);
hit = strip("Tabs");
pointer(window, "pointermove", 260);
fireEvent.keyDown(window, { key: "Escape" });
pointer(window, "pointerup", 260);
expect(onDrop).not.toHaveBeenCalled();
expect(shown("Tabs")).toEqual(["a", "b", "c"]);
});
it("marks the insertion point for a tab dragged in from another strip", () => {
const onDrop = vi.fn();
render(
<>
<Strip label="Left" initial={["a", "b"]} />
<Strip label="Right" initial={["c", "d"]} onDrop={onDrop} />
</>,
);
pointer(within(tab("Left", "a")).getByRole("tab"), "pointerdown", 50);
hit = strip("Right");
pointer(window, "pointermove", 20);
expect(tab("Right", "c")).toHaveClass(styles.dropBefore!);
expect(shown("Right")).toEqual(["c", "d"]);
expect(shown("Left")).toEqual(["a", "b"]);
pointer(window, "pointermove", 180);
expect(tab("Right", "d")).toHaveClass(styles.dropAfter!);
pointer(window, "pointerup", 180);
expect(onDrop).toHaveBeenCalledWith("a", 2);
});
});
+55 -14
Переглянути файл
@@ -61,15 +61,36 @@ export interface TabsDnd {
onDropOutside?: (payload: string, x: number, y: number) => void;
}
/** Insertion index for a drop at `x` given the tabs' horizontal extents. Exported for tests. */
export function dropIndex(x: number, rects: ReadonlyArray<{ left: number; width: number }>): number {
/**
* Insertion index for a drop at (`x`, `y`) given the tabs' extents in reading
* order, possibly wrapped into rows. Exported for tests.
*/
export function dropIndex(
x: number,
y: number,
rects: ReadonlyArray<{ left: number; top: number; width: number; height: number }>,
): number {
const first = rects[0];
const last = rects[rects.length - 1];
if (!first || !last) return 0;
// Above or below the tabs counts as their first or last row.
const py = Math.max(first.top, Math.min(y, last.top + last.height - 1));
for (let i = 0; i < rects.length; i++) {
const r = rects[i]!;
if (x < r.left + r.width / 2) return i;
if (py < r.top || (py < r.top + r.height && x < r.left + r.width / 2)) return i;
}
return rects.length;
}
/** Display position of each id with `id` moved to insertion `index` (as passed to `onDrop`). */
function previewOrder(ids: readonly string[], id: string, index: number): Map<string, number> | null {
const from = ids.indexOf(id);
if (from < 0) return null;
const order = ids.filter((it) => it !== id);
order.splice(index > from ? index - 1 : index, 0, id);
return new Map(order.map((it, i) => [it, i]));
}
/** A tab strip as seen from its container: close the selected tab if it can be closed. */
export interface TabStripHandle {
closeSelected(): boolean;
@@ -163,19 +184,23 @@ export function Tabs({
useLayoutEffect(() => {
dropState.current = { items, dnd, vertical };
});
// Set when the drag starts, before the strip re-renders for the first `over`.
const draggingRef = useRef<string | null>(null);
const dndType = dnd?.type;
useEffect(() => {
const el = stripRef.current;
if (!el || !dndType) return;
const indexAt = (x: number, y: number) => {
const { items, vertical } = dropState.current;
return dropIndex(
vertical ? y : x,
items.map((it) => {
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
}),
);
// A tab of this strip is placed among the others as they are laid out now, around its slot.
const others = items.filter((it) => it.id !== draggingRef.current);
const rects = others.map((it) => {
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
const box = { left: r?.left ?? 0, top: r?.top ?? 0, width: r?.width ?? 0, height: r?.height ?? 0 };
return vertical ? { left: box.top, top: box.left, width: box.height, height: box.width } : box;
});
const next = others[vertical ? dropIndex(y, x, rects) : dropIndex(x, y, rects)];
return next ? items.indexOf(next) : items.length;
};
return registerDropTarget(el, {
accepts: (type) => type === dropState.current.dnd?.type,
@@ -248,6 +273,18 @@ export function Tabs({
run: () => onSelect(t.id),
}));
// IDEA: a tab dragged within its strip leaves an empty slot the other tabs make room for;
// a tab dragged in from another strip shows an insertion line instead.
const order =
dragging !== null && drop !== null
? previewOrder(
items.map((it) => it.id),
dragging,
drop,
)
: null;
const marker = dragging === null ? drop : null;
return (
<div
className={clsx(
@@ -282,11 +319,11 @@ export function Tabs({
selected && styles.selected,
closable && styles.closable,
dragging === item.id && styles.dragging,
drop === index && styles.dropBefore,
drop === items.length && index === items.length - 1 && styles.dropAfter,
marker === index && styles.dropBefore,
marker === items.length && index === items.length - 1 && styles.dropAfter,
item.tint && styles.tinted,
)}
style={item.tint ? ({ "--tab-tint": item.tint } as CSSProperties) : undefined}
style={{ "--tab-tint": item.tint, order: order?.get(item.id) } as CSSProperties}
onPointerDown={
dnd
? (e) => {
@@ -297,8 +334,12 @@ export function Tabs({
e.currentTarget,
{ type: dnd.type, payload: dnd.payload(item.id) },
{
onStart: () => setDragging(item.id),
onStart: () => {
draggingRef.current = item.id;
setDragging(item.id);
},
onEnd: (dragged, drop) => {
draggingRef.current = null;
setDragging(null);
if (!dragged || !drop || drop.accepted) return;
const outside = dnd.onDropOutside;
+2
Переглянути файл
@@ -11,10 +11,12 @@ export {
FILES_DRAG,
dragRequiresAlt,
dragSession,
dropZoneAt,
registerDropTarget,
setDragRequiresAlt,
startPointerDrag,
type DragSession,
type DropEdge,
} from "./pointerDrag";
export * from "./Tree";
export * from "./List";
+12 -1
Переглянути файл
@@ -1,8 +1,19 @@
import { describe, expect, it } from "vitest";
import { isOutsideWindow } from "./pointerDrag";
import { dropZoneAt, isOutsideWindow } from "./pointerDrag";
const viewport = { left: 0, top: 0, right: 1280, bottom: 800 };
describe("dropZoneAt", () => {
const rect = { left: 100, top: 100, width: 400, height: 200 };
it("picks the nearest edge within its outer quarter, else the middle", () => {
expect(dropZoneAt(300, 200, rect)).toBe("center");
expect(dropZoneAt(110, 200, rect)).toBe("left");
expect(dropZoneAt(490, 200, rect)).toBe("right");
expect(dropZoneAt(300, 110, rect)).toBe("top");
expect(dropZoneAt(300, 290, rect)).toBe("bottom");
});
});
describe("isOutsideWindow", () => {
it("treats the viewport interior as inside (issue #454: drag a tab out)", () => {
expect(isOutsideWindow(viewport, 640, 400)).toBe(false);
+25
Переглянути файл
@@ -64,6 +64,29 @@ export function isOutsideWindow(
return x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom;
}
export type DropEdge = "left" | "right" | "top" | "bottom";
/**
* Where a drop at (`x`, `y`) over `rect` lands: the nearest edge when within its
* outer quarter (a tab dropped there splits off a new pane), else the middle.
*/
export function dropZoneAt(
x: number,
y: number,
rect: { left: number; top: number; width: number; height: number },
): DropEdge | "center" {
const fx = (x - rect.left) / Math.max(1, rect.width);
const fy = (y - rect.top) / Math.max(1, rect.height);
const edges: Array<[DropEdge, number]> = [
["left", fx],
["right", 1 - fx],
["top", fy],
["bottom", 1 - fy],
];
const [edge, dist] = edges.reduce((a, b) => (b[1] < a[1] ? b : a));
return dist < 0.25 ? edge : "center";
}
export function dragSession(data: DragData): DragSession {
let current: DropTarget | null = null;
const enter = (t: DropTarget | null) => {
@@ -131,6 +154,8 @@ export function startPointerDrag(
ghost.classList.add(styles.ghost!);
ghost.setAttribute("aria-hidden", "true");
Object.assign(ghost.style, {
// Inline: the copied classes (a tab's `position: relative`) can load after `.ghost`.
position: "fixed",
left: `${r.left}px`,
top: `${r.top}px`,
width: `${r.width}px`,