From ee27616388da745462b7dd36c162504e14741304 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 20 May 2026 15:19:40 -0700 Subject: [PATCH] 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 --- .../browser-pane/webview-registry.test.ts | 69 ++++++ .../browser-pane/webview-registry.ts | 68 +++++- .../components/tab-group/useTabDragSplit.ts | 24 +- .../use-terminal-pane-lifecycle.ts | 14 ++ .../pane-manager/pane-drag-reorder.test.ts | 221 ++++++++++++++++++ .../src/lib/pane-manager/pane-drag-reorder.ts | 119 +++++++--- .../lib/pane-manager/pane-manager-types.ts | 3 + .../src/lib/pane-manager/pane-manager.ts | 7 +- 8 files changed, 491 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts diff --git a/src/renderer/src/components/browser-pane/webview-registry.test.ts b/src/renderer/src/components/browser-pane/webview-registry.test.ts index 07161d8c5..4ae6260b3 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.test.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.test.ts @@ -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() diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index e12eaabd8..d57f71b37 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -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() +const dragPassthroughPreviousPointerEvents = new Map() 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() diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 448c26832..eb3b3047d 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -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(null) const [hoveredDropTarget, setHoveredDropTarget] = useState(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( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c84cded8e..42dbc0baa 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts new file mode 100644 index 000000000..d2dc80e53 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -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() + + 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 = {} + readonly dataset: Record = {} + private readonly listeners = new Map>() + private readonly capturedPointerIds = new Set() + 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() + 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): 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 { + 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([ + [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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 295da95e9..21970b4c2 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -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, diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 0316edb1d..6082ce13a 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 onLinkClick?: (event: MouseEvent | undefined, url: string) => void initialRenderingSuspended?: boolean diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index d3e6dd0ad..b62443a9e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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 } } }