From 2b20c0f38e03701ae2341ecd35d70380a6bb363f Mon Sep 17 00:00:00 2001 From: fuqiuluo Date: Wed, 4 Nov 2026 00:41:57 +0800 Subject: [PATCH] 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 <61898844+fuqiuluo@users.noreply.github.com> --- src/editor/core/view/EditorArea.tsx | 19 +-- src/features/terminal/Terminal.module.css | 22 +++ src/features/terminal/TerminalView.tsx | 44 +++++- src/features/terminal/groups.test.ts | 48 +++++++ src/features/terminal/store.ts | 53 ++++++- src/ui/Tabs.module.css | 20 ++- src/ui/Tabs.test.tsx | 163 ++++++++++++++++++++-- src/ui/Tabs.tsx | 69 +++++++-- src/ui/index.ts | 2 + src/ui/pointerDrag.test.ts | 13 +- src/ui/pointerDrag.ts | 25 ++++ 11 files changed, 423 insertions(+), 55 deletions(-) diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index cc172aa56..68ca7cd89 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -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 }; diff --git a/src/features/terminal/Terminal.module.css b/src/features/terminal/Terminal.module.css index eb80f471c..7e69d829c 100644 --- a/src/features/terminal/Terminal.module.css +++ b/src/features/terminal/Terminal.module.css @@ -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; diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 78e1b03e3..097ae34a4 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -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(null); + const panelsRef = useRef(null); + const [dropZone, setDropZone] = useState(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 ( -
+
{corners && ( )} {active && } -
+
+ {dropZone &&
} {tabs.map((t) => (
{ 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", () => { diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index a82897505..e704e7751 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -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); diff --git a/src/ui/Tabs.module.css b/src/ui/Tabs.module.css index cfbcff89d..4f5180c8e 100644 --- a/src/ui/Tabs.module.css +++ b/src/ui/Tabs.module.css @@ -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) { diff --git a/src/ui/Tabs.test.tsx b/src/ui/Tabs.test.tsx index c81c642a9..b43336a1a 100644 --- a/src/ui/Tabs.test.tsx +++ b/src/ui/Tabs.test.tsx @@ -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 {}} 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(); + 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(); + 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( + <> + + + , + ); + 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); + }); +}); diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx index 078118fc5..60b4f9b68 100644 --- a/src/ui/Tabs.tsx +++ b/src/ui/Tabs.tsx @@ -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 | 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(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 (
{ @@ -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; diff --git a/src/ui/index.ts b/src/ui/index.ts index 669975184..6aac94421 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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"; diff --git a/src/ui/pointerDrag.test.ts b/src/ui/pointerDrag.test.ts index 12b68a7ed..8e1e1ed3c 100644 --- a/src/ui/pointerDrag.test.ts +++ b/src/ui/pointerDrag.test.ts @@ -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); diff --git a/src/ui/pointerDrag.ts b/src/ui/pointerDrag.ts index 590999632..6b5206afb 100644 --- a/src/ui/pointerDrag.ts +++ b/src/ui/pointerDrag.ts @@ -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`,