refactor(app): replace tab drag handling with dndkit (#33880)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
parent
19e510f5d2
commit
929c4aaf29
@@ -31,6 +31,10 @@
|
|||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
|
"@dnd-kit/dom": "0.5.0",
|
||||||
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -1451,6 +1455,20 @@
|
|||||||
|
|
||||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||||
|
|
||||||
|
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
|
||||||
|
|
||||||
|
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
|
||||||
|
|
||||||
|
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
|
||||||
|
|
||||||
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||||
@@ -2287,6 +2305,8 @@
|
|||||||
|
|
||||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||||
|
|
||||||
|
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
|
||||||
|
|
||||||
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
|
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
|
||||||
|
|
||||||
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
|
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
"vite-plugin-solid": "catalog:"
|
"vite-plugin-solid": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
|
"@dnd-kit/dom": "0.5.0",
|
||||||
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
|
||||||
import {
|
|
||||||
canOpenTabRename,
|
|
||||||
captureTabPointerDown,
|
|
||||||
canStartTabDrag,
|
|
||||||
createTabDragPreview,
|
|
||||||
forwardTabRef,
|
|
||||||
isPrimaryPointerPressed,
|
|
||||||
isTabCloseTarget,
|
|
||||||
} from "./titlebar-tab-gesture"
|
|
||||||
|
|
||||||
describe("titlebar tab drag", () => {
|
|
||||||
const layout = {
|
|
||||||
listLeft: 100,
|
|
||||||
dividerWidth: 13,
|
|
||||||
tabWidthById: new Map([
|
|
||||||
["a", 40],
|
|
||||||
["b", 40],
|
|
||||||
["c", 40],
|
|
||||||
["d", 40],
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
|
|
||||||
test("moves across multiple tabs from one pointer update", () => {
|
|
||||||
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
|
||||||
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the current index inside the left hysteresis deadband", () => {
|
|
||||||
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes slot margins in captured divider width", () => {
|
|
||||||
const list = document.createElement("div")
|
|
||||||
const first = document.createElement("div")
|
|
||||||
const second = document.createElement("div")
|
|
||||||
const firstTab = document.createElement("div")
|
|
||||||
const secondTab = document.createElement("div")
|
|
||||||
first.dataset.titlebarTabSlot = ""
|
|
||||||
first.dataset.tabKey = "a"
|
|
||||||
second.dataset.titlebarTabSlot = ""
|
|
||||||
second.dataset.tabKey = "b"
|
|
||||||
second.style.marginLeft = "6px"
|
|
||||||
firstTab.dataset.titlebarTab = ""
|
|
||||||
secondTab.dataset.titlebarTab = ""
|
|
||||||
first.append(firstTab)
|
|
||||||
second.append(secondTab)
|
|
||||||
list.append(first, second)
|
|
||||||
document.body.append(list)
|
|
||||||
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
|
||||||
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
|
||||||
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
|
||||||
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
|
||||||
|
|
||||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
|
||||||
list.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses the list gap as the divider width", () => {
|
|
||||||
const list = document.createElement("div")
|
|
||||||
const first = document.createElement("div")
|
|
||||||
const second = document.createElement("div")
|
|
||||||
const firstTab = document.createElement("div")
|
|
||||||
const secondTab = document.createElement("div")
|
|
||||||
first.dataset.titlebarTabSlot = ""
|
|
||||||
first.dataset.tabKey = "a"
|
|
||||||
second.dataset.titlebarTabSlot = ""
|
|
||||||
second.dataset.tabKey = "b"
|
|
||||||
firstTab.dataset.titlebarTab = ""
|
|
||||||
secondTab.dataset.titlebarTab = ""
|
|
||||||
first.append(firstTab)
|
|
||||||
second.append(secondTab)
|
|
||||||
list.append(first, second)
|
|
||||||
list.style.columnGap = "13.5px"
|
|
||||||
document.body.append(list)
|
|
||||||
|
|
||||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
|
||||||
list.remove()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("titlebar tab gestures", () => {
|
|
||||||
test("excludes close controls from tab gestures", () => {
|
|
||||||
const close = document.createElement("div")
|
|
||||||
const button = document.createElement("button")
|
|
||||||
const link = document.createElement("a")
|
|
||||||
close.dataset.slot = "tab-close"
|
|
||||||
close.append(button)
|
|
||||||
expect(isTabCloseTarget(close)).toBe(true)
|
|
||||||
expect(isTabCloseTarget(button)).toBe(true)
|
|
||||||
expect(isTabCloseTarget(link)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forwards component refs", () => {
|
|
||||||
const element = document.createElement("div")
|
|
||||||
let received: HTMLDivElement | undefined
|
|
||||||
forwardTabRef((value) => (received = value), element)
|
|
||||||
expect(received).toBe(element)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not reopen rename while a save is pending", () => {
|
|
||||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
|
||||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the rendered tab content in the drag preview", () => {
|
|
||||||
const tab = document.createElement("div")
|
|
||||||
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
|
|
||||||
const preview = createTabDragPreview(tab)
|
|
||||||
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
|
|
||||||
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("captures the grab offset before navigation scrolls the tab", () => {
|
|
||||||
const tab = document.createElement("div")
|
|
||||||
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
|
|
||||||
|
|
||||||
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
|
|
||||||
startX: 100,
|
|
||||||
startY: 20,
|
|
||||||
grabOffsetX: 20,
|
|
||||||
grabOffsetY: 10,
|
|
||||||
width: 120,
|
|
||||||
element: tab,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects when the primary pointer button was released outside the window", () => {
|
|
||||||
expect(isPrimaryPointerPressed(1)).toBe(true)
|
|
||||||
expect(isPrimaryPointerPressed(3)).toBe(true)
|
|
||||||
expect(isPrimaryPointerPressed(0)).toBe(false)
|
|
||||||
expect(isPrimaryPointerPressed(2)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves native panning for touch pointers", () => {
|
|
||||||
expect(canStartTabDrag("mouse")).toBe(true)
|
|
||||||
expect(canStartTabDrag("pen")).toBe(true)
|
|
||||||
expect(canStartTabDrag("touch")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
export type TabDragLayout = {
|
|
||||||
tabWidthById: Map<string, number>
|
|
||||||
dividerWidth: number
|
|
||||||
listLeft: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ACTIVATION_DISTANCE = 4
|
|
||||||
export const HYSTERESIS_DEADBAND = 8
|
|
||||||
export const AUTOSCROLL_EDGE = 24
|
|
||||||
export const AUTOSCROLL_MAX_SPEED = 8
|
|
||||||
export const FLOATER_OVERSHOOT_MAX = 8
|
|
||||||
|
|
||||||
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
|
|
||||||
const dx = x2 - x1
|
|
||||||
const dy = y2 - y1
|
|
||||||
return Math.sqrt(dx * dx + dy * dy)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
|
||||||
const tabWidthById = new Map<string, number>()
|
|
||||||
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
|
|
||||||
for (const slot of slots) {
|
|
||||||
const id = slot.dataset.tabKey
|
|
||||||
if (!id) continue
|
|
||||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
|
||||||
if (!tab) continue
|
|
||||||
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
|
||||||
}
|
|
||||||
|
|
||||||
let dividerWidth = 0
|
|
||||||
if (order.length >= 2) {
|
|
||||||
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
|
||||||
const secondId = order[1]
|
|
||||||
for (const slot of slots) {
|
|
||||||
if (slot.dataset.tabKey !== secondId) continue
|
|
||||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
|
||||||
if (!tab) break
|
|
||||||
const style = getComputedStyle(slot)
|
|
||||||
dividerWidth =
|
|
||||||
gap ||
|
|
||||||
slot.getBoundingClientRect().width -
|
|
||||||
tab.getBoundingClientRect().width +
|
|
||||||
(Number.parseFloat(style.marginLeft) || 0) +
|
|
||||||
(Number.parseFloat(style.marginRight) || 0)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tabWidthById,
|
|
||||||
dividerWidth,
|
|
||||||
listLeft: list.getBoundingClientRect().left,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
|
|
||||||
layout.listLeft = list.getBoundingClientRect().left
|
|
||||||
}
|
|
||||||
|
|
||||||
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
|
|
||||||
const id = order[index]
|
|
||||||
if (!id) return 0
|
|
||||||
const tabWidth = layout.tabWidthById.get(id) ?? 0
|
|
||||||
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
|
|
||||||
let left = layout.listLeft
|
|
||||||
for (let i = 0; i < index; i++) {
|
|
||||||
left += slotWidthAt(order, i, layout)
|
|
||||||
}
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insertIndexFromVirtualLayout(
|
|
||||||
pointerX: number,
|
|
||||||
order: readonly string[],
|
|
||||||
draggedId: string,
|
|
||||||
currentIndex: number,
|
|
||||||
layout: TabDragLayout,
|
|
||||||
deadband = HYSTERESIS_DEADBAND,
|
|
||||||
) {
|
|
||||||
if (order.length === 0) return 0
|
|
||||||
|
|
||||||
const others = order.filter((id) => id !== draggedId)
|
|
||||||
let target = currentIndex
|
|
||||||
|
|
||||||
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
|
||||||
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
|
||||||
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
|
|
||||||
const fromIndex = order.indexOf(draggedId)
|
|
||||||
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
|
|
||||||
const next = [...order]
|
|
||||||
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
|
|
||||||
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
|
|
||||||
return final.some((key, index) => key !== initial[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeOvershoot(overshoot: number) {
|
|
||||||
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
|
|
||||||
const stripWidth = stripRight - stripLeft
|
|
||||||
if (width >= stripWidth) return stripLeft
|
|
||||||
|
|
||||||
const maxLeft = stripRight - width
|
|
||||||
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
|
|
||||||
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
|
|
||||||
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
|
|
||||||
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
|
|
||||||
const leftEdge = containerLeft + AUTOSCROLL_EDGE
|
|
||||||
const rightEdge = containerRight - AUTOSCROLL_EDGE
|
|
||||||
|
|
||||||
if (pointerX < leftEdge) {
|
|
||||||
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
|
|
||||||
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pointerX > rightEdge) {
|
|
||||||
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
|
|
||||||
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
|
|
||||||
|
describe("titlebar tab gestures", () => {
|
||||||
|
test("excludes close controls from tab gestures", () => {
|
||||||
|
const close = document.createElement("div")
|
||||||
|
const button = document.createElement("button")
|
||||||
|
const link = document.createElement("a")
|
||||||
|
close.dataset.slot = "tab-close"
|
||||||
|
close.append(button)
|
||||||
|
expect(isTabCloseTarget(close)).toBe(true)
|
||||||
|
expect(isTabCloseTarget(button)).toBe(true)
|
||||||
|
expect(isTabCloseTarget(link)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forwards component refs", () => {
|
||||||
|
const element = document.createElement("div")
|
||||||
|
let received: HTMLDivElement | undefined
|
||||||
|
forwardTabRef((value) => (received = value), element)
|
||||||
|
expect(received).toBe(element)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not reopen rename while a save is pending", () => {
|
||||||
|
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||||
|
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves native panning for touch pointers", () => {
|
||||||
|
expect(canStartTabDrag("mouse")).toBe(true)
|
||||||
|
expect(canStartTabDrag("pen")).toBe(true)
|
||||||
|
expect(canStartTabDrag("touch")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) {
|
|||||||
return pointerType !== "touch"
|
return pointerType !== "touch"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPrimaryPointerPressed(buttons: number) {
|
|
||||||
return (buttons & 1) !== 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
startX: clientX,
|
|
||||||
startY: clientY,
|
|
||||||
grabOffsetX: clientX - rect.left,
|
|
||||||
grabOffsetY: clientY - rect.top,
|
|
||||||
width: rect.width,
|
|
||||||
element,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||||
if (typeof ref === "function") ref(element)
|
if (typeof ref === "function") ref(element)
|
||||||
}
|
}
|
||||||
@@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
|||||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||||
return !dragging && !editing && !committing
|
return !dragging && !editing && !committing
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTabDragPreview(element: HTMLDivElement) {
|
|
||||||
return element.cloneNode(true) as HTMLDivElement
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-list] {
|
[data-titlebar-tab-list] {
|
||||||
gap: 13.5px;
|
gap: 13.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export function TabNavItem(props: {
|
|||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
tabKey: string
|
|
||||||
dragActive: boolean
|
|
||||||
onPointerDown: (event: PointerEvent) => void
|
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
@@ -172,13 +169,6 @@ export function TabNavItem(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
data-titlebar-tab-slot
|
|
||||||
data-tab-key={props.tabKey}
|
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
|
||||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
|
||||||
onPointerDown={props.onPointerDown}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
tabRoot = el
|
tabRoot = el
|
||||||
@@ -189,6 +179,7 @@ export function TabNavItem(props: {
|
|||||||
data-title-overflow={titleOverflowing()}
|
data-title-overflow={titleOverflowing()}
|
||||||
data-editing={editing()}
|
data-editing={editing()}
|
||||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
|
classList={{ invisible: props.hidden }}
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-pressed={props.pressed}
|
||||||
@@ -266,7 +257,10 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<div
|
||||||
|
data-slot="tab-close"
|
||||||
|
class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"
|
||||||
|
>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
@@ -280,7 +274,6 @@ export function TabNavItem(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +288,6 @@ export function DraftTabItem(props: {
|
|||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
tabKey: string
|
|
||||||
dragActive: boolean
|
|
||||||
onPointerDown: (event: PointerEvent) => void
|
|
||||||
}) {
|
}) {
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -305,13 +295,6 @@ export function DraftTabItem(props: {
|
|||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
data-titlebar-tab-slot
|
|
||||||
data-tab-key={props.tabKey}
|
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
|
||||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
|
||||||
onPointerDown={props.onPointerDown}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={(el) => forwardTabRef(props.ref, el)}
|
ref={(el) => forwardTabRef(props.ref, el)}
|
||||||
data-titlebar-tab
|
data-titlebar-tab
|
||||||
@@ -319,7 +302,8 @@ export function DraftTabItem(props: {
|
|||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-pressed={props.pressed}
|
||||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
|
classList={{ invisible: props.hidden }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
@@ -370,6 +354,5 @@ export function DraftTabItem(props: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import {
|
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createResource,
|
|
||||||
createRoot,
|
|
||||||
createSignal,
|
|
||||||
For,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
} from "solid-js"
|
|
||||||
import { Portal } from "solid-js/web"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
|
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||||
|
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||||
|
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||||
|
import { arrayMove } from "@dnd-kit/helpers"
|
||||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||||
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { createTabPromptState } from "@/context/prompt"
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import {
|
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
captureTabPointerDown,
|
|
||||||
canStartTabDrag,
|
const sortableTransition = { duration: 0 }
|
||||||
createTabDragPreview,
|
|
||||||
isPrimaryPointerPressed,
|
|
||||||
isTabCloseTarget,
|
|
||||||
} from "./titlebar-tab-gesture"
|
|
||||||
import {
|
|
||||||
ACTIVATION_DISTANCE,
|
|
||||||
autoscrollSpeed,
|
|
||||||
captureTabDragLayout,
|
|
||||||
clampFloaterLeft,
|
|
||||||
draftOrderChanged,
|
|
||||||
insertIndexFromVirtualLayout,
|
|
||||||
movePlaceholder,
|
|
||||||
pointerDistance,
|
|
||||||
syncLayoutScroll,
|
|
||||||
type TabDragLayout,
|
|
||||||
} from "@/components/titlebar-tab-drag"
|
|
||||||
|
|
||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
|
index: () => number
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
activeServerKey: ServerConnection.Key
|
activeServerKey: ServerConnection.Key
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
dragActive: boolean
|
|
||||||
dragged: () => boolean
|
|
||||||
pressed: () => boolean
|
|
||||||
serverCtx: () => ServerCtx | undefined
|
serverCtx: () => ServerCtx | undefined
|
||||||
suppressNavigation: () => boolean
|
|
||||||
onPointerDown: (event: PointerEvent) => void
|
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const sortable = useSortable({
|
||||||
|
get id() {
|
||||||
|
return props.id
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return props.index()
|
||||||
|
},
|
||||||
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
@@ -100,11 +81,17 @@ function SessionTabSlot(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
|
classList={{ hidden: !session() }}
|
||||||
|
>
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
tabKey={props.id}
|
ref={(el) => {
|
||||||
dragActive={props.dragActive}
|
ref = el
|
||||||
onPointerDown={props.onPointerDown}
|
}}
|
||||||
ref={ref}
|
|
||||||
href={tabHref(props.tab)}
|
href={tabHref(props.tab)}
|
||||||
server={props.tab.server}
|
server={props.tab.server}
|
||||||
session={session}
|
session={session}
|
||||||
@@ -123,10 +110,50 @@ function SessionTabSlot(props: {
|
|||||||
active={props.active()}
|
active={props.active()}
|
||||||
activeServer={props.tab.server === props.activeServerKey}
|
activeServer={props.tab.server === props.activeServerKey}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
suppressNavigation={props.suppressNavigation}
|
dragging={sortable.isDragSource()}
|
||||||
pressed={props.pressed()}
|
|
||||||
hidden={props.dragged() || !session()}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftTabSlot(props: {
|
||||||
|
tab: Extract<Tab, { type: "draft" }>
|
||||||
|
id: string
|
||||||
|
index: () => number
|
||||||
|
active: () => boolean
|
||||||
|
title: string
|
||||||
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const sortable = useSortable({
|
||||||
|
get id() {
|
||||||
|
return props.id
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return props.index()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
|
>
|
||||||
|
<DraftTabItem
|
||||||
|
ref={(el) => {
|
||||||
|
ref = el
|
||||||
|
}}
|
||||||
|
href={tabHref(props.tab)}
|
||||||
|
title={props.title}
|
||||||
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
active={props.active()}
|
||||||
|
dragging={sortable.isDragSource()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,52 +169,12 @@ export function TitlebarTabStrip(props: {
|
|||||||
}) {
|
}) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [drag, setDrag] = createStore({
|
|
||||||
active: false,
|
|
||||||
draggedId: undefined as string | undefined,
|
|
||||||
placeholderIndex: 0,
|
|
||||||
draftOrder: [] as string[],
|
|
||||||
initialOrder: [] as string[],
|
|
||||||
draggedWidth: 0,
|
|
||||||
pointerX: 0,
|
|
||||||
grabOffsetX: 0,
|
|
||||||
floaterTop: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [gesture, setGesture] = createStore({
|
|
||||||
pending: undefined as
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
startX: number
|
|
||||||
startY: number
|
|
||||||
grabOffsetX: number
|
|
||||||
grabOffsetY: number
|
|
||||||
pointerId: number
|
|
||||||
width: number
|
|
||||||
element: HTMLDivElement
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
|
|
||||||
const [pressedId, setPressedId] = createSignal<string | undefined>()
|
|
||||||
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
|
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let listRef!: HTMLDivElement
|
let listRef!: HTMLDivElement
|
||||||
let dragLayout: TabDragLayout | undefined
|
|
||||||
let dragPointerId: number | undefined
|
|
||||||
let autoscrollFrame: number | undefined
|
|
||||||
let resizeFrame: number | undefined
|
let resizeFrame: number | undefined
|
||||||
let dragPreview: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const tabIds = () => props.tabs.map(tabKey)
|
const tabIds = () => props.tabs.map(tabKey)
|
||||||
|
|
||||||
const displayTabs = createMemo(() => {
|
|
||||||
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
|
|
||||||
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
|
|
||||||
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
|
||||||
})
|
|
||||||
|
|
||||||
function refreshOverflow() {
|
function refreshOverflow() {
|
||||||
if (!scrollRef) return
|
if (!scrollRef) return
|
||||||
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||||
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
|
|||||||
resizeFrame = requestAnimationFrame(() => {
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
resizeFrame = undefined
|
resizeFrame = undefined
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
if (!drag.active || !listRef) return
|
|
||||||
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
|
|
||||||
updateInsertIndex()
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function syncScroll() {
|
|
||||||
if (!scrollRef || !listRef || !dragLayout) return
|
|
||||||
syncLayoutScroll(listRef, dragLayout)
|
|
||||||
setStripScrollLeft(scrollRef.scrollLeft)
|
|
||||||
updateInsertIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAutoscroll() {
|
|
||||||
if (autoscrollFrame === undefined) return
|
|
||||||
cancelAnimationFrame(autoscrollFrame)
|
|
||||||
autoscrollFrame = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function tickAutoscroll() {
|
|
||||||
if (!drag.active || !scrollRef) return
|
|
||||||
|
|
||||||
const strip = scrollRef.getBoundingClientRect()
|
|
||||||
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
|
|
||||||
|
|
||||||
if (speed !== 0) {
|
|
||||||
scrollRef.scrollLeft += speed
|
|
||||||
syncScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAutoscroll() {
|
|
||||||
stopAutoscroll()
|
|
||||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPlaceholderIndex(nextIndex: number) {
|
|
||||||
const id = drag.draggedId
|
|
||||||
if (!id) return
|
|
||||||
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
|
|
||||||
setDrag({
|
|
||||||
draftOrder: next,
|
|
||||||
placeholderIndex: nextIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateInsertIndex() {
|
|
||||||
if (!drag.active || !dragLayout) return
|
|
||||||
const draggedId = drag.draggedId
|
|
||||||
if (!draggedId) return
|
|
||||||
const nextIndex = insertIndexFromVirtualLayout(
|
|
||||||
drag.pointerX,
|
|
||||||
drag.draftOrder,
|
|
||||||
draggedId,
|
|
||||||
drag.placeholderIndex,
|
|
||||||
dragLayout,
|
|
||||||
)
|
|
||||||
if (nextIndex === drag.placeholderIndex) return
|
|
||||||
applyPlaceholderIndex(nextIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDrag(id: string) {
|
|
||||||
const order = tabIds()
|
|
||||||
const index = order.indexOf(id)
|
|
||||||
const pending = gesture.pending
|
|
||||||
if (index === -1 || !pending || !listRef || !scrollRef) return
|
|
||||||
|
|
||||||
dragLayout = captureTabDragLayout(listRef, order)
|
|
||||||
dragPreview = createTabDragPreview(pending.element)
|
|
||||||
dragPointerId = pending.pointerId
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
|
|
||||||
setDrag({
|
|
||||||
active: true,
|
|
||||||
draggedId: id,
|
|
||||||
placeholderIndex: index,
|
|
||||||
draftOrder: order,
|
|
||||||
initialOrder: order,
|
|
||||||
draggedWidth: pending.width,
|
|
||||||
pointerX: pending.startX,
|
|
||||||
grabOffsetX: pending.grabOffsetX,
|
|
||||||
floaterTop: pending.startY - pending.grabOffsetY,
|
|
||||||
})
|
|
||||||
setPressedId(undefined)
|
|
||||||
setStripScrollLeft(scrollRef.scrollLeft)
|
|
||||||
startAutoscroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
function endDrag(commit: boolean) {
|
|
||||||
const initial = drag.initialOrder
|
|
||||||
const final = drag.draftOrder
|
|
||||||
const moved = drag.active
|
|
||||||
|
|
||||||
if (commit && moved && draftOrderChanged(initial, final)) {
|
|
||||||
props.onReorder(final)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moved) setSuppressNavigation(true)
|
|
||||||
|
|
||||||
setDrag({
|
|
||||||
active: false,
|
|
||||||
draggedId: undefined,
|
|
||||||
placeholderIndex: 0,
|
|
||||||
draftOrder: [],
|
|
||||||
initialOrder: [],
|
|
||||||
draggedWidth: 0,
|
|
||||||
pointerX: 0,
|
|
||||||
grabOffsetX: 0,
|
|
||||||
floaterTop: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
dragLayout = undefined
|
|
||||||
dragPreview = undefined
|
|
||||||
dragPointerId = undefined
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
stopAutoscroll()
|
|
||||||
refreshOverflow()
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerDown(id: string, event: PointerEvent) {
|
|
||||||
if (event.button !== 0 || drag.active) return
|
|
||||||
if (!canStartTabDrag(event.pointerType)) return
|
|
||||||
if (isTabCloseTarget(event.target)) return
|
|
||||||
const target = event.currentTarget as HTMLDivElement
|
|
||||||
const tabEl = target.matches("[data-titlebar-tab]")
|
|
||||||
? target
|
|
||||||
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
|
||||||
if (!tabEl) return
|
|
||||||
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
|
||||||
const tab = props.tabs.find((item) => tabKey(item) === id)
|
|
||||||
if (!tab) return
|
|
||||||
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
|
||||||
setSuppressNavigation(true)
|
|
||||||
props.onNavigate(tab, tabEl)
|
|
||||||
setPressedId(id)
|
|
||||||
setGesture("pending", {
|
|
||||||
id,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
...pointer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(event: PointerEvent) {
|
|
||||||
const pending = gesture.pending
|
|
||||||
if (pending && event.pointerId !== pending.pointerId) return
|
|
||||||
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
|
||||||
if (!isPrimaryPointerPressed(event.buttons)) {
|
|
||||||
if (drag.active) endDrag(true)
|
|
||||||
if (pending) {
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending && !drag.active) {
|
|
||||||
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
|
||||||
startDrag(pending.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!drag.active) return
|
|
||||||
|
|
||||||
setDrag("pointerX", event.clientX)
|
|
||||||
syncScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp(event: PointerEvent) {
|
|
||||||
if (drag.active) {
|
|
||||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
|
||||||
setDrag("pointerX", event.clientX)
|
|
||||||
syncScroll()
|
|
||||||
endDrag(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = gesture.pending
|
|
||||||
if (pending && event.pointerId !== pending.pointerId) return
|
|
||||||
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerCancel(event: PointerEvent) {
|
|
||||||
if (drag.active) {
|
|
||||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
|
||||||
endDrag(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gesture.pending) return
|
|
||||||
if (gesture.pending.pointerId !== event.pointerId) return
|
|
||||||
setGesture("pending", undefined)
|
|
||||||
setPressedId(undefined)
|
|
||||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const cleanups = [
|
|
||||||
makeEventListener(window, "pointermove", onPointerMove),
|
|
||||||
makeEventListener(window, "pointerup", onPointerUp),
|
|
||||||
makeEventListener(window, "pointercancel", onPointerCancel),
|
|
||||||
]
|
|
||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(stopAutoscroll)
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||||
})
|
})
|
||||||
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
|
|||||||
refreshOverflow()
|
refreshOverflow()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!drag.active || !scrollRef) return
|
|
||||||
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
|
|
||||||
})
|
|
||||||
|
|
||||||
const floaterStyle = () => {
|
|
||||||
stripScrollLeft()
|
|
||||||
const strip = scrollRef?.getBoundingClientRect()
|
|
||||||
const left = strip
|
|
||||||
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
|
|
||||||
: drag.pointerX - drag.grabOffsetX
|
|
||||||
|
|
||||||
return {
|
|
||||||
position: "fixed" as const,
|
|
||||||
top: `${drag.floaterTop}px`,
|
|
||||||
left: `${left}px`,
|
|
||||||
width: `${drag.draggedWidth}px`,
|
|
||||||
"z-index": "10000",
|
|
||||||
"pointer-events": "none" as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const draggedTab = createMemo(() => {
|
|
||||||
const id = drag.draggedId
|
|
||||||
if (!id) return
|
|
||||||
return props.tabs.find((tab) => tabKey(tab) === id)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||||
<div
|
<div
|
||||||
data-slot="titlebar-tabs-scroll"
|
data-slot="titlebar-tabs-scroll"
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
|
<DragDropProvider
|
||||||
<For each={displayTabs()}>
|
sensors={[
|
||||||
|
PointerSensor.configure({
|
||||||
|
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||||
|
preventActivation: (event) =>
|
||||||
|
!canStartTabDrag(event.pointerType) ||
|
||||||
|
isTabCloseTarget(event.target) ||
|
||||||
|
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||||
|
plugins={(defaults) => [
|
||||||
|
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||||
|
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||||
|
Feedback.configure({ dropAnimation: null }),
|
||||||
|
]}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
const source = event.operation.source
|
||||||
|
if (!source) return
|
||||||
|
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||||
|
if (!tab) return
|
||||||
|
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||||
|
props.onNavigate(tab, tabEl ?? undefined)
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const current = tabIds()
|
||||||
|
const source = event.operation.source
|
||||||
|
if (event.canceled || !isSortable(source)) return
|
||||||
|
|
||||||
|
const { initialIndex, index } = source
|
||||||
|
if (initialIndex !== index) {
|
||||||
|
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||||
|
<For each={props.tabs}>
|
||||||
{(tab, index) => {
|
{(tab, index) => {
|
||||||
const id = tabKey(tab)
|
const id = tabKey(tab)
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||||
|
|
||||||
const dragged = () => drag.active && drag.draggedId === id
|
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
@@ -479,46 +264,38 @@ export function TitlebarTabStrip(props: {
|
|||||||
<SessionTabSlot
|
<SessionTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
|
index={index}
|
||||||
active={() => props.currentTab() === tab}
|
active={() => props.currentTab() === tab}
|
||||||
activeServerKey={props.activeServerKey}
|
activeServerKey={props.activeServerKey}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
dragActive={drag.active}
|
|
||||||
dragged={dragged}
|
|
||||||
pressed={() => pressedId() === id}
|
|
||||||
serverCtx={serverCtx}
|
serverCtx={serverCtx}
|
||||||
suppressNavigation={() => suppressNavigation()}
|
onNavigate={(element) => {
|
||||||
onPointerDown={(event) => {
|
ref = element
|
||||||
if (dragged()) return
|
props.onNavigate(tab, element)
|
||||||
onPointerDown(id, event)
|
|
||||||
}}
|
}}
|
||||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraftTabItem
|
<DraftTabSlot
|
||||||
tabKey={id}
|
tab={tab}
|
||||||
dragActive={drag.active}
|
id={id}
|
||||||
onPointerDown={(event) => {
|
index={index}
|
||||||
if (dragged()) return
|
active={() => props.currentTab() === tab}
|
||||||
onPointerDown(id, event)
|
|
||||||
}}
|
|
||||||
ref={ref}
|
|
||||||
href={tabHref(tab)}
|
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={() => props.onNavigate(tab, ref)}
|
onNavigate={(element) => {
|
||||||
|
ref = element
|
||||||
|
props.onNavigate(tab, element)
|
||||||
|
}}
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
suppressNavigation={() => suppressNavigation()}
|
|
||||||
active={props.currentTab() === tab}
|
|
||||||
pressed={pressedId() === id}
|
|
||||||
hidden={dragged()}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
</DragDropProvider>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-slot="titlebar-tabs-fade-left"
|
data-slot="titlebar-tabs-fade-left"
|
||||||
@@ -531,16 +308,6 @@ export function TitlebarTabStrip(props: {
|
|||||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
|
||||||
{(_) => (
|
|
||||||
<Portal>
|
|
||||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
|
||||||
{dragPreview}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user