diff --git a/demo/main.js b/demo/main.js index 004a48f0..0b8c10f3 100644 --- a/demo/main.js +++ b/demo/main.js @@ -219,14 +219,14 @@ function initOptions(term) { // Attach listeners booleanOptions.forEach(o => { var input = document.getElementById(`opt-${o}`); - input.addEventListener('change', () => { + addDomListener(input, 'change', () => { console.log('change', o, input.checked); term.setOption(o, input.checked); }); }); numberOptions.forEach(o => { var input = document.getElementById(`opt-${o}`); - input.addEventListener('change', () => { + addDomListener(input, 'change', () => { console.log('change', o, input.value); if (o === 'cols' || o === 'rows') { updateTerminalSize(); @@ -237,13 +237,18 @@ function initOptions(term) { }); Object.keys(stringOptions).forEach(o => { var input = document.getElementById(`opt-${o}`); - input.addEventListener('change', () => { + addDomListener(input, 'change', () => { console.log('change', o, input.value); term.setOption(o, input.value); }); }); } +function addDomListener(element, type, handler) { + element.addEventListener(type, handler); + term._core.register({ dispose: () => element.removeEventListener(type, handler) }); +} + function updateTerminalSize() { var cols = parseInt(document.getElementById(`opt-cols`).value, 10); var rows = parseInt(document.getElementById(`opt-rows`).value, 10); diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index 4c0415de..c09f2ef9 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -21,6 +21,8 @@ class TestLinkifier extends Linkifier { } class TestMouseZoneManager implements IMouseZoneManager { + dispose(): void { + } public clears: number = 0; public zones: IMouseZone[] = []; add(zone: IMouseZone): void { diff --git a/src/Terminal.ts b/src/Terminal.ts index 51c3f189..2bf05d99 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -38,7 +38,7 @@ import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; import { CharMeasure } from './utils/CharMeasure'; import * as Browser from './shared/utils/Browser'; -import * as Dom from './ui/Lifecycle'; +import { addDisposableDomListener } from './ui/Lifecycle'; import * as Strings from './Strings'; import { MouseHelper } from './utils/MouseHelper'; import { clone } from './utils/Clone'; @@ -519,30 +519,30 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this._bindKeys(); // Bind clipboard functionality - on(this.element, 'copy', (event: ClipboardEvent) => { + this.register(addDisposableDomListener(this.element, 'copy', (event: ClipboardEvent) => { // If mouse events are active it means the selection manager is disabled and // copy should be handled by the host program. if (!this.hasSelection()) { return; } copyHandler(event, this, this.selectionManager); - }); + })); const pasteHandlerWrapper = (event: ClipboardEvent) => pasteHandler(event, this); - on(this.textarea, 'paste', pasteHandlerWrapper); - on(this.element, 'paste', pasteHandlerWrapper); + this.register(addDisposableDomListener(this.textarea, 'paste', pasteHandlerWrapper)); + this.register(addDisposableDomListener(this.element, 'paste', pasteHandlerWrapper)); // Handle right click context menus if (Browser.isFirefox) { // Firefox doesn't appear to fire the contextmenu event on right click - on(this.element, 'mousedown', (event: MouseEvent) => { + this.register(addDisposableDomListener(this.element, 'mousedown', (event: MouseEvent) => { if (event.button === 2) { rightClickHandler(event, this.textarea, this.selectionManager, this.options.rightClickSelectsWord); } - }); + })); } else { - on(this.element, 'contextmenu', (event: MouseEvent) => { + this.register(addDisposableDomListener(this.element, 'contextmenu', (event: MouseEvent) => { rightClickHandler(event, this.textarea, this.selectionManager, this.options.rightClickSelectsWord); - }); + })); } // Move the textarea under the cursor when middle clicking on Linux to ensure @@ -551,11 +551,11 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (Browser.isLinux) { // Use auxclick event over mousedown the latter doesn't seem to work. Note // that the regular click event doesn't fire for the middle mouse button. - on(this.element, 'auxclick', (event: MouseEvent) => { + this.register(addDisposableDomListener(this.element, 'auxclick', (event: MouseEvent) => { if (event.button === 1) { moveTextAreaUnderMouseCursor(event, this.textarea); } - }); + })); } } @@ -564,33 +564,33 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II */ private _bindKeys(): void { const self = this; - on(this.element, 'keydown', function (ev: KeyboardEvent): void { + this.register(addDisposableDomListener(this.element, 'keydown', function (ev: KeyboardEvent): void { if (document.activeElement !== this) { return; } self._keyDown(ev); - }, true); + }, true)); - on(this.element, 'keypress', function (ev: KeyboardEvent): void { + this.register(addDisposableDomListener(this.element, 'keypress', function (ev: KeyboardEvent): void { if (document.activeElement !== this) { return; } self._keyPress(ev); - }, true); + }, true)); - on(this.element, 'keyup', (ev: KeyboardEvent) => { + this.register(addDisposableDomListener(this.element, 'keyup', (ev: KeyboardEvent) => { if (!wasMondifierKeyOnlyEvent(ev)) { this.focus(); } - }, true); + }, true)); - on(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true); - on(this.textarea, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true); - on(this.textarea, 'compositionstart', () => this._compositionHelper.compositionstart()); - on(this.textarea, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper.compositionupdate(e)); - on(this.textarea, 'compositionend', () => this._compositionHelper.compositionend()); - this.on('refresh', () => this._compositionHelper.updateCompositionElements()); - this.on('refresh', (data) => this._queueLinkification(data.start, data.end)); + this.register(addDisposableDomListener(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true)); + this.register(addDisposableDomListener(this.textarea, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true)); + this.register(addDisposableDomListener(this.textarea, 'compositionstart', () => this._compositionHelper.compositionstart())); + this.register(addDisposableDomListener(this.textarea, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper.compositionupdate(e))); + this.register(addDisposableDomListener(this.textarea, 'compositionend', () => this._compositionHelper.compositionend())); + this.register(this.addDisposableListener('refresh', () => this._compositionHelper.updateCompositionElements())); + this.register(this.addDisposableListener('refresh', (data) => this._queueLinkification(data.start, data.end))); } /** @@ -641,7 +641,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II fragment.appendChild(this.screenElement); this._mouseZoneManager = new MouseZoneManager(this); - this.on('scroll', () => this._mouseZoneManager.clearAll()); + this.register(this._mouseZoneManager); + this.register(this.addDisposableListener('scroll', () => this._mouseZoneManager.clearAll())); this.linkifier.attachToDom(this._mouseZoneManager); this.textarea = document.createElement('textarea'); @@ -653,8 +654,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); this.textarea.tabIndex = 0; - this.textarea.addEventListener('focus', () => this._onTextAreaFocus()); - this.textarea.addEventListener('blur', () => this._onTextAreaBlur()); + this.register(addDisposableDomListener(this.textarea, 'focus', () => this._onTextAreaFocus())); + this.register(addDisposableDomListener(this.textarea, 'blur', () => this._onTextAreaBlur())); this._helperContainer.appendChild(this.textarea); this._compositionView = document.createElement('div'); @@ -676,34 +677,35 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.options.theme = null; this.viewport = new Viewport(this, this._viewportElement, this._viewportScrollArea, this.charMeasure); this.viewport.onThemeChanged(this.renderer.colorManager.colors); + this.register(this.viewport); - this.on('cursormove', () => this.renderer.onCursorMove()); - this.on('resize', () => this.renderer.onResize(this.cols, this.rows)); - this.on('blur', () => this.renderer.onBlur()); - this.on('focus', () => this.renderer.onFocus()); - this.on('dprchange', () => this.renderer.onWindowResize(window.devicePixelRatio)); + this.register(this.addDisposableListener('cursormove', () => this.renderer.onCursorMove())); + this.register(this.addDisposableListener('resize', () => this.renderer.onResize(this.cols, this.rows))); + this.register(this.addDisposableListener('blur', () => this.renderer.onBlur())); + this.register(this.addDisposableListener('focus', () => this.renderer.onFocus())); + this.register(this.addDisposableListener('dprchange', () => this.renderer.onWindowResize(window.devicePixelRatio))); // dprchange should handle this case, we need this as well for browsers that don't support the // matchMedia query. - this.register(Dom.addDisposableDomListener(window, 'resize', () => this.renderer.onWindowResize(window.devicePixelRatio))); - this.charMeasure.on('charsizechanged', () => this.renderer.onCharSizeChanged()); - this.renderer.on('resize', (dimensions) => this.viewport.syncScrollArea()); + this.register(addDisposableDomListener(window, 'resize', () => this.renderer.onWindowResize(window.devicePixelRatio))); + this.register(this.charMeasure.addDisposableListener('charsizechanged', () => this.renderer.onCharSizeChanged())); + this.register(this.renderer.addDisposableListener('resize', (dimensions) => this.viewport.syncScrollArea())); this.selectionManager = new SelectionManager(this, this.charMeasure); - this.element.addEventListener('mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e)); - this.selectionManager.on('refresh', data => this.renderer.onSelectionChanged(data.start, data.end)); - this.selectionManager.on('newselection', text => { + this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e))); + this.register(this.selectionManager.addDisposableListener('refresh', data => this.renderer.onSelectionChanged(data.start, data.end))); + this.register(this.selectionManager.addDisposableListener('newselection', text => { // If there's a new selection, put it into the textarea, focus and select it // in order to register it as a selection on the OS. This event is fired // only on Linux to enable middle click to paste selection. this.textarea.value = text; this.textarea.focus(); this.textarea.select(); - }); - this.on('scroll', () => { + })); + this.register(this.addDisposableListener('scroll', () => { this.viewport.syncScrollArea(); this.selectionManager.refresh(); - }); - this._viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); + })); + this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this.selectionManager.refresh())); this.mouseHelper = new MouseHelper(this.renderer); @@ -973,7 +975,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return button; } - on(el, 'mousedown', (ev: MouseEvent) => { + this.register(addDisposableDomListener(el, 'mousedown', (ev: MouseEvent) => { // Prevent the focus on the textarea from getting lost // and make sure we get focused on mousedown @@ -998,6 +1000,9 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return this.cancel(ev); } + // TODO: these event listeners should be managed by the disposable, the Terminal reference may + // be kept aroud if Terminal.dispose is fired when the mouse is down + // bind events if (this.normalMouse) on(this._document, 'mousemove', sendMove); @@ -1015,13 +1020,13 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } return this.cancel(ev); - }); + })); // if (this.normalMouse) { // on(this.document, 'mousemove', sendMove); // } - on(el, 'wheel', (ev: WheelEvent) => { + this.register(addDisposableDomListener(el, 'wheel', (ev: WheelEvent) => { if (!this.mouseEvents) { // Convert wheel events into up/down events when the buffer does not have scrollback, this // enables scrolling in apps hosted in the alt buffer such as vim or tmux. @@ -1046,27 +1051,27 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (this.x10Mouse || this._vt300Mouse || this._decLocator) return; sendButton(ev); ev.preventDefault(); - }); + })); // allow wheel scrolling in // the shell for example - on(el, 'wheel', (ev: WheelEvent) => { + this.register(addDisposableDomListener(el, 'wheel', (ev: WheelEvent) => { if (this.mouseEvents) return; this.viewport.onWheel(ev); return this.cancel(ev); - }); + })); - on(el, 'touchstart', (ev: TouchEvent) => { + this.register(addDisposableDomListener(el, 'touchstart', (ev: TouchEvent) => { if (this.mouseEvents) return; this.viewport.onTouchStart(ev); return this.cancel(ev); - }); + })); - on(el, 'touchmove', (ev: TouchEvent) => { + this.register(addDisposableDomListener(el, 'touchmove', (ev: TouchEvent) => { if (this.mouseEvents) return; this.viewport.onTouchMove(ev); return this.cancel(ev); - }); + })); } /** diff --git a/src/Types.ts b/src/Types.ts index 1de223f7..eed1e674 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Terminal as PublicTerminal, ITerminalOptions as IPublicTerminalOptions, IEventEmitter } from 'xterm'; +import { Terminal as PublicTerminal, ITerminalOptions as IPublicTerminalOptions, IEventEmitter, IDisposable } from 'xterm'; import { IColorSet, IRenderer } from './renderer/Types'; import { IMouseZoneManager } from './input/Types'; import { ICharset } from './core/Types'; @@ -86,7 +86,7 @@ export interface IInputHandlingTerminal extends IEventEmitter { tabSet(): void; } -export interface IViewport { +export interface IViewport extends IDisposable { scrollBarWidth: number; syncScrollArea(): void; getLinesScrolled(ev: WheelEvent): number; diff --git a/src/Viewport.ts b/src/Viewport.ts index f690c348..645d89b6 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -6,6 +6,8 @@ import { IColorSet } from './renderer/Types'; import { ITerminal, IViewport } from './Types'; import { CharMeasure } from './utils/CharMeasure'; +import { Disposable } from './common/Lifecycle'; +import { addDisposableDomListener } from './ui/Lifecycle'; const FALLBACK_SCROLL_BAR_WIDTH = 15; @@ -13,7 +15,7 @@ const FALLBACK_SCROLL_BAR_WIDTH = 15; * Represents the viewport of a terminal, the visible area within the larger buffer of output. * Logic for the virtual scroll bar is included in this object. */ -export class Viewport implements IViewport { +export class Viewport extends Disposable implements IViewport { public scrollBarWidth: number = 0; private _currentRowHeight: number = 0; private _lastRecordedBufferLength: number = 0; @@ -39,11 +41,13 @@ export class Viewport implements IViewport { private _scrollArea: HTMLElement, private _charMeasure: CharMeasure ) { + super(); + // Measure the width of the scrollbar. If it is 0 we can assume it's an OSX overlay scrollbar. // Unfortunately the overlay scrollbar would be hidden underneath the screen element in that case, // therefore we account for a standard amount to make it visible this.scrollBarWidth = (this._viewportElement.offsetWidth - this._scrollArea.offsetWidth) || FALLBACK_SCROLL_BAR_WIDTH; - this._viewportElement.addEventListener('scroll', this._onScroll.bind(this)); + this.register(addDisposableDomListener(this._viewportElement, 'scroll', this._onScroll.bind(this))); // Perform this async to ensure the CharMeasure is ready. setTimeout(() => this.syncScrollArea(), 0); diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts index 65fe74de..e791a982 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/input/MouseZoneManager.ts @@ -5,6 +5,8 @@ import { ITerminal } from '../Types'; import { IMouseZoneManager, IMouseZone } from './Types'; +import { Disposable } from '../common/Lifecycle'; +import { addDisposableDomListener } from '../ui/Lifecycle'; const HOVER_DURATION = 500; @@ -16,7 +18,7 @@ const HOVER_DURATION = 500; * needed to support was single-line links which never overlap. Improvements can * be made in the future. */ -export class MouseZoneManager implements IMouseZoneManager { +export class MouseZoneManager extends Disposable implements IMouseZoneManager { private _zones: IMouseZone[] = []; private _areZonesActive: boolean = false; @@ -30,13 +32,20 @@ export class MouseZoneManager implements IMouseZoneManager { constructor( private _terminal: ITerminal ) { - this._terminal.element.addEventListener('mousedown', e => this._onMouseDown(e)); + super(); + + this.register(addDisposableDomListener(this._terminal.element, 'mousedown', e => this._onMouseDown(e))); // These events are expensive, only listen to it when mouse zones are active this._mouseMoveListener = e => this._onMouseMove(e); this._clickListener = e => this._onClick(e); } + public dispose(): void { + super.dispose(); + this._deactivate(); + } + public add(zone: IMouseZone): void { this._zones.push(zone); if (this._zones.length === 1) { diff --git a/src/input/Types.ts b/src/input/Types.ts index 2bd805bf..89dfa1a4 100644 --- a/src/input/Types.ts +++ b/src/input/Types.ts @@ -3,7 +3,9 @@ * @license MIT */ -export interface IMouseZoneManager { +import { IDisposable } from 'xterm'; + +export interface IMouseZoneManager extends IDisposable { add(zone: IMouseZone): void; clearAll(start?: number, end?: number): void; } diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 589ddac8..ebb56ee2 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -346,6 +346,9 @@ export class MockRenderer implements IRenderer { } export class MockViewport implements IViewport { + dispose(): void { + throw new Error('Method not implemented.'); + } scrollBarWidth: number = 0; onThemeChanged(colors: IColorSet): void { throw new Error('Method not implemented.');