From 08b1db2c9dc732dd84123a79c2313ecbb47b957a Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:19:02 -0700 Subject: [PATCH] use current document for mouse listeners --- src/browser/services/MouseService.test.ts | 93 +++++++++++++++++++++++ src/browser/services/MouseService.ts | 28 ++++--- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index b4655a1a1..bb48bc0b4 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -351,3 +351,96 @@ describe('MouseService mouseEventsRequireAlt', () => { assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); }); }); + +describe('MouseService multi-window', () => { + interface IRecordingDocument { + doc: Document; + added: string[]; + removed: string[]; + listeners: { [type: string]: EventListener[] }; + } + + function createRecordingDocument(): IRecordingDocument { + const added: string[] = []; + const removed: string[] = []; + const listeners: { [type: string]: EventListener[] } = {}; + const doc = { + addEventListener: (type: string, listener: EventListener) => { + added.push(type); + (listeners[type] ??= []).push(listener); + }, + removeEventListener: (type: string) => removed.push(type) + } as any; + return { doc, added, removed, listeners }; + } + + it('should manage protocol mouseup/mousemove listeners on the element\'s current document, not the document the terminal was opened with', () => { + const mouseStateService = new MouseStateService(); + const optionsService = new OptionsService({}); + const mouseService = new MouseService( + new MockRenderService(), + { + getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 }) + } as any, + mouseStateService, + { + triggerDataEvent: () => {}, + triggerBinaryEvent: () => {}, + decPrivateModes: { applicationCursorKeys: false } + } as any, + bufferService, + optionsService, + new TestSelectionService(), + logService, + new MockCoreBrowserService() + ); + + // Simulate a terminal that was opened with one document but whose element now lives in + // another document (eg. it was moved to a child window) + const openDocument = createRecordingDocument(); + const childDocument = createRecordingDocument(); + const elementListeners: { [type: string]: EventListener[] } = {}; + const element = { + classList: { + add: () => {}, + remove: () => {}, + contains: () => false + }, + addEventListener: (type: string, listener: EventListener) => { + (elementListeners[type] ??= []).push(listener); + }, + removeEventListener: () => {}, + ownerDocument: childDocument.doc + } as any; + + mouseService.bindMouse({ + element, + screenElement: createTestMouseTargetElement(), + document: openDocument.doc + }, disposable => disposable, () => {}); + mouseStateService.activeProtocol = 'ANY'; + + elementListeners['mousedown'][0]({ + type: 'mousedown', + button: 0, + buttons: 1, + altKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: () => {} + } as any); + assert.deepEqual(childDocument.added, ['mouseup', 'mousemove']); + assert.deepEqual(openDocument.added, []); + + childDocument.listeners['mouseup'][0]({ + type: 'mouseup', + button: 0, + buttons: 0, + altKey: false, + ctrlKey: false, + shiftKey: false + } as any); + assert.deepEqual(childDocument.removed, ['mouseup', 'mousemove']); + assert.deepEqual(openDocument.removed, []); + }); +}); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 61cbfa86c..4239f8d1b 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -21,6 +21,7 @@ interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; + listenerDocument: Document | null; } export class MouseService implements IMouseService { @@ -61,7 +62,7 @@ export class MouseService implements IMouseService { mousedrag: null, mousemove: null }; - const ctx: IMouseBindContext = { target, focus, requestedEvents }; + const ctx: IMouseBindContext = { target, focus, requestedEvents, listenerDocument: null }; const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = { mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), @@ -87,11 +88,12 @@ export class MouseService implements IMouseService { // Ensure document-level listeners are removed on dispose register(toDisposable(() => { + const listenerDocument = ctx.listenerDocument ?? document; if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); } if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); } })); @@ -199,12 +201,14 @@ export class MouseService implements IMouseService { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers + const listenerDocument = ctx.listenerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); } + ctx.listenerDocument = null; } } @@ -246,12 +250,15 @@ export class MouseService implements IMouseService { // of the terminal element. // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. + // Use the element's current document in case it moved to another window after open. + const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup); + listenerDocument.addEventListener('mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag); + listenerDocument.addEventListener('mousemove', ctx.requestedEvents.mousedrag); } + ctx.listenerDocument = listenerDocument; } private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void { @@ -398,8 +405,9 @@ export class MouseService implements IMouseService { } private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { - const { element, document } = ctx.target; + const { element } = ctx.target; const { requestedEvents } = ctx; + const listenerDocument = ctx.listenerDocument ?? ctx.target.document; // apply global changes on events if (events) { if (this._optionsService.rawOptions.logLevel === 'debug') { @@ -434,7 +442,7 @@ export class MouseService implements IMouseService { if (!(events & CoreMouseEventType.UP)) { if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); } requestedEvents.mouseup = null; } else { @@ -443,7 +451,7 @@ export class MouseService implements IMouseService { if (!(events & CoreMouseEventType.DRAG)) { if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); } requestedEvents.mousedrag = null; } else {