Fix frozen pane drags across webviews (#2453)
- Keep Electron webviews in pointer passthrough for renderer-owned tab and pane drags so they cannot steal the pointer stream - Clean up terminal pane drag state on pointer cancel, lost capture, blur, or manager destroy to remove stale overlays and unfreeze input - Cover nested drag passthrough and pane-drag cancellation paths with tests
This commit is contained in:
parent
2fee04a05b
commit
ee27616388
|
|
@ -90,6 +90,31 @@ describe('webview registry drag listeners', () => {
|
|||
expect(unregisterGuestMock).toHaveBeenCalledWith({ browserPageId: 'page-2' })
|
||||
})
|
||||
|
||||
it('releases native drag passthrough when the last webview is destroyed', async () => {
|
||||
const { destroyPersistentWebview, registerPersistentWebview } =
|
||||
await import('./webview-registry')
|
||||
const firstWebview = createWebview()
|
||||
firstWebview.style.pointerEvents = 'auto'
|
||||
registerPersistentWebview('page-1', firstWebview)
|
||||
|
||||
const dragStart = addedListeners.find((entry) => entry.type === 'dragstart')?.listener
|
||||
if (typeof dragStart === 'function') {
|
||||
dragStart(new Event('dragstart'))
|
||||
} else {
|
||||
throw new Error('dragstart listener missing')
|
||||
}
|
||||
|
||||
expect(firstWebview.style.pointerEvents).toBe('none')
|
||||
|
||||
destroyPersistentWebview('page-1')
|
||||
|
||||
const secondWebview = createWebview()
|
||||
secondWebview.style.pointerEvents = 'auto'
|
||||
registerPersistentWebview('page-2', secondWebview)
|
||||
|
||||
expect(secondWebview.style.pointerEvents).toBe('auto')
|
||||
})
|
||||
|
||||
it('keeps one listener set across repeated registrations', async () => {
|
||||
const { registerPersistentWebview } = await import('./webview-registry')
|
||||
|
||||
|
|
@ -99,6 +124,50 @@ describe('webview registry drag listeners', () => {
|
|||
expect(addedListeners).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps webviews in passthrough until every renderer drag releases', async () => {
|
||||
const { acquireWebviewsDragPassthrough, registerPersistentWebview } =
|
||||
await import('./webview-registry')
|
||||
const activeWebview = createWebview()
|
||||
activeWebview.style.pointerEvents = 'auto'
|
||||
const lockedWebview = createWebview()
|
||||
lockedWebview.style.pointerEvents = 'none'
|
||||
registerPersistentWebview('page-1', activeWebview)
|
||||
registerPersistentWebview('page-2', lockedWebview)
|
||||
|
||||
const releaseFirstDrag = acquireWebviewsDragPassthrough()
|
||||
const releaseSecondDrag = acquireWebviewsDragPassthrough()
|
||||
|
||||
expect(activeWebview.style.pointerEvents).toBe('none')
|
||||
expect(lockedWebview.style.pointerEvents).toBe('none')
|
||||
|
||||
releaseFirstDrag()
|
||||
|
||||
expect(activeWebview.style.pointerEvents).toBe('none')
|
||||
expect(lockedWebview.style.pointerEvents).toBe('none')
|
||||
|
||||
releaseSecondDrag()
|
||||
releaseSecondDrag()
|
||||
|
||||
expect(activeWebview.style.pointerEvents).toBe('auto')
|
||||
expect(lockedWebview.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('applies active passthrough to webviews registered mid-drag', async () => {
|
||||
const { acquireWebviewsDragPassthrough, registerPersistentWebview } =
|
||||
await import('./webview-registry')
|
||||
const releaseDrag = acquireWebviewsDragPassthrough()
|
||||
const webview = createWebview()
|
||||
webview.style.pointerEvents = 'auto'
|
||||
|
||||
registerPersistentWebview('page-1', webview)
|
||||
|
||||
expect(webview.style.pointerEvents).toBe('none')
|
||||
|
||||
releaseDrag()
|
||||
|
||||
expect(webview.style.pointerEvents).toBe('auto')
|
||||
})
|
||||
|
||||
it('moves focus back to the renderer before detaching the focused webview', async () => {
|
||||
const { moveFocusToRendererBeforeWebviewDetach } = await import('./webview-registry')
|
||||
const webview = createWebview()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ export const MAX_PARKED_WEBVIEWS = 6
|
|||
let hiddenContainer: HTMLDivElement | null = null
|
||||
const DRAG_LISTENER_KEY = '__orcaBrowserPaneDragListeners'
|
||||
let dragListenersAttached = false
|
||||
let nativeDragPassthroughRelease: (() => void) | null = null
|
||||
const dragPassthroughTokens = new Set<symbol>()
|
||||
const dragPassthroughPreviousPointerEvents = new Map<Electron.WebviewTag, string>()
|
||||
|
||||
type DragListenerRegistry = {
|
||||
dragstart: () => void
|
||||
|
|
@ -39,6 +42,8 @@ function removeDragListeners(): void {
|
|||
window.removeEventListener('drop', existingListeners.drop, true)
|
||||
delete listenerHost[DRAG_LISTENER_KEY]
|
||||
dragListenersAttached = false
|
||||
nativeDragPassthroughRelease?.()
|
||||
nativeDragPassthroughRelease = null
|
||||
}
|
||||
|
||||
function ensureDragListeners(): void {
|
||||
|
|
@ -79,21 +84,80 @@ export function getHiddenContainer(): HTMLDivElement {
|
|||
return hiddenContainer
|
||||
}
|
||||
|
||||
export function setWebviewsDragPassthrough(passthrough: boolean): void {
|
||||
function applyWebviewsDragPassthrough(): void {
|
||||
const passthrough = dragPassthroughTokens.size > 0
|
||||
for (const webview of webviewRegistry.values()) {
|
||||
webview.style.pointerEvents = passthrough ? 'none' : ''
|
||||
if (passthrough) {
|
||||
if (!dragPassthroughPreviousPointerEvents.has(webview)) {
|
||||
dragPassthroughPreviousPointerEvents.set(webview, webview.style.pointerEvents)
|
||||
}
|
||||
webview.style.pointerEvents = 'none'
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = dragPassthroughPreviousPointerEvents.get(webview)
|
||||
if (previous !== undefined) {
|
||||
webview.style.pointerEvents = previous
|
||||
dragPassthroughPreviousPointerEvents.delete(webview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireWebviewsDragPassthrough(): () => void {
|
||||
// Why: renderer-owned pointer drags (dnd-kit tab drags, terminal pane
|
||||
// reorders) do not emit HTML dragstart/dragend, but Electron webviews can
|
||||
// still steal the pointer stream unless they are temporarily transparent.
|
||||
const token = Symbol('webview-drag-passthrough')
|
||||
let released = false
|
||||
dragPassthroughTokens.add(token)
|
||||
applyWebviewsDragPassthrough()
|
||||
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
dragPassthroughTokens.delete(token)
|
||||
applyWebviewsDragPassthrough()
|
||||
}
|
||||
}
|
||||
|
||||
export function setWebviewsDragPassthrough(passthrough: boolean): void {
|
||||
if (passthrough) {
|
||||
if (!nativeDragPassthroughRelease) {
|
||||
nativeDragPassthroughRelease = acquireWebviewsDragPassthrough()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
nativeDragPassthroughRelease?.()
|
||||
nativeDragPassthroughRelease = null
|
||||
}
|
||||
|
||||
function applyCurrentDragPassthroughToWebview(webview: Electron.WebviewTag): void {
|
||||
if (dragPassthroughTokens.size === 0) {
|
||||
return
|
||||
}
|
||||
if (!dragPassthroughPreviousPointerEvents.has(webview)) {
|
||||
dragPassthroughPreviousPointerEvents.set(webview, webview.style.pointerEvents)
|
||||
}
|
||||
webview.style.pointerEvents = 'none'
|
||||
}
|
||||
|
||||
export function registerPersistentWebview(
|
||||
browserTabId: string,
|
||||
webview: Electron.WebviewTag
|
||||
): void {
|
||||
webviewRegistry.set(browserTabId, webview)
|
||||
applyCurrentDragPassthroughToWebview(webview)
|
||||
ensureDragListeners()
|
||||
}
|
||||
|
||||
export function unregisterPersistentWebview(browserTabId: string): void {
|
||||
const webview = webviewRegistry.get(browserTabId)
|
||||
if (webview) {
|
||||
dragPassthroughPreviousPointerEvents.delete(webview)
|
||||
}
|
||||
webviewRegistry.delete(browserTabId)
|
||||
if (webviewRegistry.size === 0) {
|
||||
removeDragListeners()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* oxlint-disable max-lines -- Why: the drag-split hook co-locates drop-zone
|
||||
* resolution, same-group reordering, and cross-group handoff so state
|
||||
* transitions stay readable in one place. */
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
useHoveredTabInsertion,
|
||||
type HoveredTabInsertion
|
||||
} from './tab-insertion'
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
|
||||
|
||||
export type { HoveredTabInsertion }
|
||||
|
||||
|
|
@ -203,6 +204,7 @@ export function useTabDragSplit({
|
|||
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
|
||||
const [activeDrag, setActiveDrag] = useState<TabDragItemData | null>(null)
|
||||
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
|
||||
const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null)
|
||||
const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragCenter)
|
||||
|
||||
// Why: hidden worktrees stay mounted so their PTYs survive worktree
|
||||
|
|
@ -216,11 +218,26 @@ export function useTabDragSplit({
|
|||
})
|
||||
const sensors = useSensors(pointerSensor)
|
||||
|
||||
const releaseWebviewDragPassthrough = useCallback(() => {
|
||||
releaseWebviewDragPassthroughRef.current?.()
|
||||
releaseWebviewDragPassthroughRef.current = null
|
||||
}, [])
|
||||
|
||||
const acquireWebviewDragPassthrough = useCallback(() => {
|
||||
// Why: dnd-kit tab drags are pointer-driven, so the native drag listeners
|
||||
// in webview-registry never fire. Put webviews in passthrough explicitly.
|
||||
releaseWebviewDragPassthrough()
|
||||
releaseWebviewDragPassthroughRef.current = acquireWebviewsDragPassthrough()
|
||||
}, [releaseWebviewDragPassthrough])
|
||||
|
||||
useEffect(() => () => releaseWebviewDragPassthrough(), [releaseWebviewDragPassthrough])
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
releaseWebviewDragPassthrough()
|
||||
setActiveDrag(null)
|
||||
setHoveredDropTarget(null)
|
||||
tabInsertion.clear()
|
||||
}, [tabInsertion])
|
||||
}, [releaseWebviewDragPassthrough, tabInsertion])
|
||||
|
||||
const updateHoveredPane = useCallback(
|
||||
(event: DragMoveEvent | DragOverEvent) => {
|
||||
|
|
@ -279,8 +296,9 @@ export function useTabDragSplit({
|
|||
}
|
||||
|
||||
setActiveDrag(dragData)
|
||||
acquireWebviewDragPassthrough()
|
||||
},
|
||||
[clearDragState, worktreeId]
|
||||
[acquireWebviewDragPassthrough, clearDragState, worktreeId]
|
||||
)
|
||||
|
||||
const onDragMove = useCallback(
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import {
|
|||
type SplitTerminalPaneDetail,
|
||||
type CloseTerminalPaneDetail
|
||||
} from '@/constants/terminal'
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
|
||||
|
||||
type UseTerminalPaneLifecycleDeps = {
|
||||
tabId: string
|
||||
|
|
@ -433,6 +434,8 @@ export function useTerminalPaneLifecycle({
|
|||
const fileOpenLinkHint = getTerminalFileOpenHint()
|
||||
const urlOpenLinkHint = getTerminalUrlOpenHint()
|
||||
|
||||
let releaseWebviewDragPassthrough: (() => void) | null = null
|
||||
|
||||
const manager = new PaneManager(container, {
|
||||
// Why: `spawnHints` carries the resolved cwd from Cmd+D / context-menu
|
||||
// Split actions so the new PTY inherits the source pane's live cwd.
|
||||
|
|
@ -779,6 +782,15 @@ export function useTerminalPaneLifecycle({
|
|||
persistLayoutSnapshot()
|
||||
}
|
||||
},
|
||||
onPaneDragActiveChange: (active) => {
|
||||
if (active) {
|
||||
releaseWebviewDragPassthrough?.()
|
||||
releaseWebviewDragPassthrough = acquireWebviewsDragPassthrough()
|
||||
return
|
||||
}
|
||||
releaseWebviewDragPassthrough?.()
|
||||
releaseWebviewDragPassthrough = null
|
||||
},
|
||||
terminalOptions: () => {
|
||||
const currentSettings = settingsRef.current
|
||||
const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight)
|
||||
|
|
@ -1076,6 +1088,8 @@ export function useTerminalPaneLifecycle({
|
|||
panePtyBindings.clear()
|
||||
paneTransports.clear()
|
||||
manager.destroy()
|
||||
releaseWebviewDragPassthrough?.()
|
||||
releaseWebviewDragPassthrough = null
|
||||
managerRef.current = null
|
||||
if (e2eConfig.exposeStore) {
|
||||
window.__paneManagers?.delete(tabId)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { attachPaneDrag, createDragReorderState } from './pane-drag-reorder'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
|
||||
const detachPaneFromTree = vi.hoisted(() => vi.fn())
|
||||
const insertPaneNextTo = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./pane-tree-ops', () => ({
|
||||
detachPaneFromTree,
|
||||
insertPaneNextTo
|
||||
}))
|
||||
|
||||
type FakeListener = (event: PointerEvent) => void
|
||||
|
||||
class FakeClassList {
|
||||
private readonly values = new Set<string>()
|
||||
|
||||
constructor(initial: readonly string[] = []) {
|
||||
for (const value of initial) {
|
||||
this.values.add(value)
|
||||
}
|
||||
}
|
||||
|
||||
add(value: string): void {
|
||||
this.values.add(value)
|
||||
}
|
||||
|
||||
remove(value: string): void {
|
||||
this.values.delete(value)
|
||||
}
|
||||
|
||||
contains(value: string): boolean {
|
||||
return this.values.has(value)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
readonly classList: FakeClassList
|
||||
readonly style: Record<string, string> = {}
|
||||
readonly dataset: Record<string, string> = {}
|
||||
private readonly listeners = new Map<string, Set<FakeListener>>()
|
||||
private readonly capturedPointerIds = new Set<number>()
|
||||
removed = false
|
||||
|
||||
constructor(
|
||||
classNames: readonly string[] = [],
|
||||
private readonly rect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
|
||||
) {
|
||||
this.classList = new FakeClassList(classNames)
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: FakeListener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<FakeListener>()
|
||||
listeners.add(listener)
|
||||
this.listeners.set(type, listeners)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: FakeListener): void {
|
||||
this.listeners.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
dispatchPointer(type: string, event: Partial<PointerEvent>): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event as PointerEvent)
|
||||
}
|
||||
}
|
||||
|
||||
setPointerCapture(pointerId: number): void {
|
||||
this.capturedPointerIds.add(pointerId)
|
||||
}
|
||||
|
||||
releasePointerCapture(pointerId: number): void {
|
||||
this.capturedPointerIds.delete(pointerId)
|
||||
}
|
||||
|
||||
hasPointerCapture(pointerId: number): boolean {
|
||||
return this.capturedPointerIds.has(pointerId)
|
||||
}
|
||||
|
||||
getBoundingClientRect(): DOMRect {
|
||||
return this.rect as DOMRect
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
this.removed = true
|
||||
}
|
||||
}
|
||||
|
||||
function pointerEvent(args: Partial<PointerEvent>): PointerEvent {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
pointerId: 1,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
...args
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
function createPane(id: number, container: FakeElement): ManagedPaneInternal {
|
||||
const leafId =
|
||||
`${id}${id}${id}${id}${id}${id}${id}${id}-${id}${id}${id}${id}-4${id}${id}${id}-8${id}${id}${id}-${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}` as TerminalLeafId
|
||||
container.dataset.paneId = String(id)
|
||||
container.dataset.leafId = leafId
|
||||
return {
|
||||
id,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal: {} as never,
|
||||
container: container as unknown as HTMLElement,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
gpuRenderingEnabled: true,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
fitAddon: {} as never,
|
||||
searchAddon: {} as never,
|
||||
serializeAddon: {} as never,
|
||||
unicode11Addon: {} as never,
|
||||
webLinksAddon: {} as never,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('attachPaneDrag', () => {
|
||||
let appendedElements: FakeElement[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appendedElements = []
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => new FakeElement(['pane-drop-overlay']),
|
||||
body: {
|
||||
appendChild: (element: FakeElement) => {
|
||||
appendedElements.push(element)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('cleans pane drag state when pointer capture is cancelled', () => {
|
||||
const handle = new FakeElement()
|
||||
const root = new FakeElement(['pane-manager-root'])
|
||||
const sourceContainer = new FakeElement(['pane'], {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const targetContainer = new FakeElement(['pane'], {
|
||||
left: 0,
|
||||
top: 100,
|
||||
right: 100,
|
||||
bottom: 200,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const sourcePane = createPane(1, sourceContainer)
|
||||
const targetPane = createPane(2, targetContainer)
|
||||
const panes = new Map<number, ManagedPaneInternal>([
|
||||
[sourcePane.id, sourcePane],
|
||||
[targetPane.id, targetPane]
|
||||
])
|
||||
const onDragActiveChange = vi.fn()
|
||||
const state = createDragReorderState()
|
||||
|
||||
attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, {
|
||||
getPanes: () => panes,
|
||||
getRoot: () => root as unknown as HTMLElement,
|
||||
getStyleOptions: () => ({}),
|
||||
isDestroyed: () => false,
|
||||
safeFit: vi.fn(),
|
||||
applyPaneOpacity: vi.fn(),
|
||||
applyDividerStyles: vi.fn(),
|
||||
refitPanesUnder: vi.fn(),
|
||||
onDragActiveChange
|
||||
})
|
||||
|
||||
handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 10 }))
|
||||
handle.dispatchPointer('pointermove', pointerEvent({ clientX: 50, clientY: 150 }))
|
||||
|
||||
expect(root.classList.contains('is-pane-dragging')).toBe(true)
|
||||
expect(sourceContainer.classList.contains('is-drag-source')).toBe(true)
|
||||
expect(state.currentDropTarget).toEqual({ paneId: targetPane.id, zone: 'top' })
|
||||
expect(appendedElements).toHaveLength(1)
|
||||
expect(onDragActiveChange).toHaveBeenCalledWith(true)
|
||||
|
||||
handle.dispatchPointer('pointercancel', pointerEvent({ pointerId: 1 }))
|
||||
|
||||
expect(root.classList.contains('is-pane-dragging')).toBe(false)
|
||||
expect(sourceContainer.classList.contains('is-drag-source')).toBe(false)
|
||||
expect(appendedElements[0].removed).toBe(true)
|
||||
expect(state.dragSourcePaneId).toBeNull()
|
||||
expect(state.currentDropTarget).toBeNull()
|
||||
expect(state.cleanupActiveDrag).toBeNull()
|
||||
expect(onDragActiveChange).toHaveBeenLastCalledWith(false)
|
||||
expect(detachPaneFromTree).not.toHaveBeenCalled()
|
||||
expect(insertPaneNextTo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ export type DragReorderState = {
|
|||
dragSourcePaneId: number | null
|
||||
dropOverlay: HTMLElement | null
|
||||
currentDropTarget: { paneId: number; zone: DropZone } | null
|
||||
cleanupActiveDrag: ((commitDrop: boolean) => void) | null
|
||||
}
|
||||
|
||||
export type DragReorderCallbacks = {
|
||||
|
|
@ -22,13 +23,15 @@ export type DragReorderCallbacks = {
|
|||
applyDividerStyles: () => void
|
||||
refitPanesUnder: (el: HTMLElement) => void
|
||||
onLayoutChanged?: () => void
|
||||
onDragActiveChange?: (active: boolean) => void
|
||||
}
|
||||
|
||||
export function createDragReorderState(): DragReorderState {
|
||||
return {
|
||||
dragSourcePaneId: null,
|
||||
dropOverlay: null,
|
||||
currentDropTarget: null
|
||||
currentDropTarget: null,
|
||||
cleanupActiveDrag: null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +45,7 @@ export function attachPaneDrag(
|
|||
let dragging = false
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let activePointerId: number | null = null
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
|
|
@ -52,17 +56,70 @@ export function attachPaneDrag(
|
|||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
activePointerId = e.pointerId
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
dragging = false
|
||||
|
||||
const cleanupDrag = (commitDrop: boolean): void => {
|
||||
const pointerId = activePointerId
|
||||
handle.removeEventListener('pointermove', onPointerMoveOuter)
|
||||
handle.removeEventListener('pointerup', onPointerUpOuter)
|
||||
handle.removeEventListener('pointercancel', onPointerCancelOuter)
|
||||
handle.removeEventListener('lostpointercapture', onLostPointerCaptureOuter)
|
||||
window.removeEventListener('blur', onWindowBlur, true)
|
||||
activePointerId = null
|
||||
state.cleanupActiveDrag = null
|
||||
if (pointerId !== null && handle.hasPointerCapture(pointerId)) {
|
||||
handle.releasePointerCapture(pointerId)
|
||||
}
|
||||
|
||||
if (!dragging) {
|
||||
return
|
||||
}
|
||||
|
||||
dragging = false
|
||||
callbacks.getRoot().classList.remove('is-pane-dragging')
|
||||
const sourcePane = callbacks.getPanes().get(paneId)
|
||||
if (sourcePane) {
|
||||
sourcePane.container.classList.remove('is-drag-source')
|
||||
}
|
||||
|
||||
try {
|
||||
if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) {
|
||||
handlePaneDrop(
|
||||
state.dragSourcePaneId,
|
||||
state.currentDropTarget.paneId,
|
||||
state.currentDropTarget.zone,
|
||||
state,
|
||||
callbacks
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Why: pointer capture can be lost when a terminal-pane drag crosses
|
||||
// an Electron webview. Always clear the visual/input drag state so the
|
||||
// terminal does not stay frozen behind the blue drop overlay.
|
||||
callbacks.onDragActiveChange?.(false)
|
||||
hideDropOverlay(state)
|
||||
state.dragSourcePaneId = null
|
||||
state.currentDropTarget = null
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMoveOuter = (ev: PointerEvent): void => {
|
||||
if (ev.pointerId !== activePointerId || callbacks.isDestroyed()) {
|
||||
if (callbacks.isDestroyed()) {
|
||||
cleanupDrag(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (!dragging && Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
|
||||
dragging = true
|
||||
state.dragSourcePaneId = paneId
|
||||
callbacks.getRoot().classList.add('is-pane-dragging')
|
||||
callbacks.onDragActiveChange?.(true)
|
||||
const sourcePane = callbacks.getPanes().get(paneId)
|
||||
if (sourcePane) {
|
||||
sourcePane.container.classList.add('is-drag-source')
|
||||
|
|
@ -75,41 +132,51 @@ export function attachPaneDrag(
|
|||
}
|
||||
|
||||
const onPointerUpOuter = (ev: PointerEvent): void => {
|
||||
handle.releasePointerCapture(ev.pointerId)
|
||||
handle.removeEventListener('pointermove', onPointerMoveOuter)
|
||||
handle.removeEventListener('pointerup', onPointerUpOuter)
|
||||
|
||||
if (dragging) {
|
||||
callbacks.getRoot().classList.remove('is-pane-dragging')
|
||||
const sourcePane = callbacks.getPanes().get(paneId)
|
||||
if (sourcePane) {
|
||||
sourcePane.container.classList.remove('is-drag-source')
|
||||
}
|
||||
|
||||
// Execute the drop
|
||||
if (state.currentDropTarget && state.dragSourcePaneId !== null) {
|
||||
handlePaneDrop(
|
||||
state.dragSourcePaneId,
|
||||
state.currentDropTarget.paneId,
|
||||
state.currentDropTarget.zone,
|
||||
state,
|
||||
callbacks
|
||||
)
|
||||
}
|
||||
|
||||
hideDropOverlay(state)
|
||||
state.dragSourcePaneId = null
|
||||
state.currentDropTarget = null
|
||||
if (ev.pointerId !== activePointerId) {
|
||||
return
|
||||
}
|
||||
cleanupDrag(true)
|
||||
}
|
||||
|
||||
const onPointerCancelOuter = (ev: PointerEvent): void => {
|
||||
if (ev.pointerId !== activePointerId) {
|
||||
return
|
||||
}
|
||||
cleanupDrag(false)
|
||||
}
|
||||
|
||||
const onLostPointerCaptureOuter = (ev: PointerEvent): void => {
|
||||
if (ev.pointerId !== activePointerId) {
|
||||
return
|
||||
}
|
||||
cleanupDrag(false)
|
||||
}
|
||||
|
||||
const onWindowBlur = (): void => {
|
||||
cleanupDrag(false)
|
||||
}
|
||||
|
||||
state.cleanupActiveDrag = cleanupDrag
|
||||
handle.addEventListener('pointermove', onPointerMoveOuter)
|
||||
handle.addEventListener('pointerup', onPointerUpOuter)
|
||||
handle.addEventListener('pointercancel', onPointerCancelOuter)
|
||||
handle.addEventListener('lostpointercapture', onLostPointerCaptureOuter)
|
||||
window.addEventListener('blur', onWindowBlur, true)
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerdown', onPointerDown)
|
||||
}
|
||||
|
||||
export function cancelActivePaneDrag(state: DragReorderState): void {
|
||||
if (state.cleanupActiveDrag) {
|
||||
state.cleanupActiveDrag(false)
|
||||
return
|
||||
}
|
||||
hideDropOverlay(state)
|
||||
state.dragSourcePaneId = null
|
||||
state.currentDropTarget = null
|
||||
}
|
||||
|
||||
/** Move a pane from its current position to a new position relative to a target pane. */
|
||||
export function handlePaneDrop(
|
||||
sourcePaneId: number,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ export type PaneManagerOptions = {
|
|||
onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void
|
||||
onActivePaneChange?: (pane: ManagedPane) => void
|
||||
onLayoutChanged?: () => void
|
||||
/** Why: Electron webviews can steal pointer streams from renderer-owned
|
||||
* pane drags unless callers temporarily put them in pointer passthrough. */
|
||||
onPaneDragActiveChange?: (active: boolean) => void
|
||||
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
|
||||
onLinkClick?: (event: MouseEvent | undefined, url: string) => void
|
||||
initialRenderingSuspended?: boolean
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
applyPaneOpacity,
|
||||
applyRootBackground
|
||||
} from './pane-divider'
|
||||
import { createDragReorderState, hideDropOverlay, handlePaneDrop } from './pane-drag-reorder'
|
||||
import { cancelActivePaneDrag, createDragReorderState, handlePaneDrop } from './pane-drag-reorder'
|
||||
import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle'
|
||||
import { shouldFollowMouseFocus } from './focus-follows-mouse'
|
||||
import {
|
||||
|
|
@ -257,7 +257,7 @@ export class PaneManager {
|
|||
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
hideDropOverlay(this.dragState)
|
||||
cancelActivePaneDrag(this.dragState)
|
||||
for (const pane of this.panes.values()) {
|
||||
disposePane(pane, this.panes)
|
||||
}
|
||||
|
|
@ -335,7 +335,8 @@ export class PaneManager {
|
|||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions),
|
||||
applyDividerStyles: () => applyDividerStyles(this.root, this.styleOptions),
|
||||
refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes),
|
||||
onLayoutChanged: this.options.onLayoutChanged
|
||||
onLayoutChanged: this.options.onLayoutChanged,
|
||||
onDragActiveChange: this.options.onPaneDragActiveChange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue