Skip to content
This repository was archived by the owner on Jun 1, 2025. It is now read-only.

Commit 9ce8326

Browse files
authored
fix(extensions): CellExternalCopyBuffer onKeyDown event leak, fix #635 (#636)
1 parent 501908a commit 9ce8326

File tree

11 files changed

+145
-46
lines changed

11 files changed

+145
-46
lines changed

src/app/examples/grid-frozen.component.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Component, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core';
22
import { AngularGridInstance, Column, ColumnEditorDualInput, Editors, FieldType, formatNumber, Formatters, Filters, GridOption } from './../modules/angular-slickgrid';
33

4+
declare const Slick: any;
5+
46
@Component({
57
templateUrl: './grid-frozen.component.html',
68
styleUrls: ['./grid-frozen.component.scss'],
@@ -26,15 +28,19 @@ export class GridFrozenComponent implements OnInit, OnDestroy {
2628
frozenRowCount = 3;
2729
isFrozenBottom = false;
2830
gridObj: any;
31+
slickEventHandler: any;
32+
33+
constructor() {
34+
this.slickEventHandler = new Slick.EventHandler();
35+
}
2936

3037
ngOnInit(): void {
3138
this.prepareDataGrid();
3239
}
3340

3441
ngOnDestroy() {
35-
// unsubscribe every SlickGrid subscribed event (or use the Slick.EventHandler)
36-
this.gridObj.onMouseEnter.unsubscribe(this.highlightRow);
37-
this.gridObj.onMouseLeave.unsubscribe(this.highlightRow);
42+
// unsubscribe all SlickGrid events
43+
this.slickEventHandler.unsubscribeAll();
3844
this.highlightRow = null;
3945
}
4046

@@ -45,8 +51,8 @@ export class GridFrozenComponent implements OnInit, OnDestroy {
4551
// with frozen (pinned) grid, in order to see the entire row being highlighted when hovering
4652
// we need to do some extra tricks (that is because frozen grids use 2 separate div containers)
4753
// the trick is to use row selection to highlight when hovering current row and remove selection once we're not
48-
this.gridObj.onMouseEnter.subscribe(event => this.highlightRow(event, true));
49-
this.gridObj.onMouseLeave.subscribe(event => this.highlightRow(event, false));
54+
this.slickEventHandler.subscribe(this.gridObj.onMouseEnter, event => this.highlightRow(event, true));
55+
this.slickEventHandler.subscribe(this.gridObj.onMouseLeave, event => this.highlightRow(event, false));
5056
}
5157

5258
highlightRow(event: Event, isMouseEnter: boolean) {

src/app/modules/angular-slickgrid/editors/dualInputEditor.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { getDescendantProperty, setDeepValue } from '../services/utilities';
2-
import { floatValidator, integerValidator, textValidator } from '../editorValidators';
31
import {
2+
DOMEvent,
43
Column,
54
ColumnEditor,
65
ColumnEditorDualInput,
@@ -12,6 +11,9 @@ import {
1211
KeyCode,
1312
SlickEventHandler,
1413
} from '../models/index';
14+
import { BindingEventService } from '../services/bindingEvent.service';
15+
import { getDescendantProperty, setDeepValue } from '../services/utilities';
16+
import { floatValidator, integerValidator, textValidator } from '../editorValidators';
1517

1618
// using external non-typed js libraries
1719
declare const Slick: any;
@@ -21,6 +23,7 @@ declare const Slick: any;
2123
* KeyDown events are also handled to provide handling for Tab, Shift-Tab, Esc and Ctrl-Enter.
2224
*/
2325
export class DualInputEditor implements Editor {
26+
private _bindEventService: BindingEventService;
2427
private _eventHandler: SlickEventHandler;
2528
private _isValueSaveCalled = false;
2629
private _lastEventType: string | undefined;
@@ -44,8 +47,9 @@ export class DualInputEditor implements Editor {
4447
}
4548
this.grid = args.grid;
4649
this.gridOptions = (this.grid.getOptions() || {}) as GridOption;
47-
this.init();
4850
this._eventHandler = new Slick.EventHandler();
51+
this._bindEventService = new BindingEventService();
52+
this.init();
4953
this._eventHandler.subscribe(this.grid.onValidationError, () => this._isValueSaveCalled = true);
5054
}
5155

@@ -105,14 +109,14 @@ export class DualInputEditor implements Editor {
105109

106110
// the lib does not get the focus out event for some reason, so register it here
107111
if (this.hasAutoCommitEdit) {
108-
this._leftInput.addEventListener('focusout', (event: any) => this.handleFocusOut(event, 'leftInput'));
109-
this._rightInput.addEventListener('focusout', (event: any) => this.handleFocusOut(event, 'rightInput'));
112+
this._bindEventService.bind(this._leftInput, 'focusout', (event: DOMEvent<HTMLInputElement>) => this.handleFocusOut(event, 'leftInput'));
113+
this._bindEventService.bind(this._rightInput, 'focusout', (event: DOMEvent<HTMLInputElement>) => this.handleFocusOut(event, 'rightInput'));
110114
}
111115

112116
setTimeout(() => this._leftInput.select(), 50);
113117
}
114118

115-
handleFocusOut(event: any, position: 'leftInput' | 'rightInput') {
119+
handleFocusOut(event: DOMEvent<HTMLInputElement>, position: 'leftInput' | 'rightInput') {
116120
// when clicking outside the editable cell OR when focusing out of it
117121
const targetClassNames = event.relatedTarget && event.relatedTarget.className || '';
118122
if (targetClassNames.indexOf('dual-editor') === -1 && this._lastEventType !== 'focusout-right') {
@@ -134,12 +138,7 @@ export class DualInputEditor implements Editor {
134138
destroy() {
135139
// unsubscribe all SlickGrid events
136140
this._eventHandler.unsubscribeAll();
137-
138-
const columnId = this.columnDef && this.columnDef.id;
139-
const elements = document.querySelectorAll(`.dual-editor-text.editor-${columnId}`);
140-
if (elements.length > 0) {
141-
elements.forEach((elm) => elm.removeEventListener('focusout', this.handleFocusOut.bind(this)));
142-
}
141+
this._bindEventService.unbindAll();
143142
}
144143

145144
createInput(position: 'leftInput' | 'rightInput'): HTMLInputElement {

src/app/modules/angular-slickgrid/extensions/__tests__/cellExternalCopyManagerExtension.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { TestBed } from '@angular/core/testing';
22
import { TranslateService, TranslateModule } from '@ngx-translate/core';
3-
import { GridOption } from '../../models/gridOption.interface';
3+
44
import { CellExternalCopyManagerExtension } from '../cellExternalCopyManagerExtension';
55
import { ExtensionUtility } from '../extensionUtility';
66
import { SharedService } from '../../services/shared.service';
7-
import { EditCommand, Formatter, SelectedRange } from '../../models';
7+
import { EditCommand, ExcelCopyBufferOption, Formatter, GridOption, SelectedRange } from '../../models';
88
import { Formatters } from '../../formatters';
99

1010
declare const Slick: any;
@@ -89,7 +89,7 @@ describe('cellExternalCopyManagerExtension', () => {
8989

9090
it('should register the addon', () => {
9191
const pluginSpy = jest.spyOn(SharedService.prototype.grid, 'registerPlugin');
92-
const onRegisteredSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onExtensionRegistered');
92+
const onRegisteredSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onExtensionRegistered') as ExcelCopyBufferOption;
9393

9494
const instance = extension.register();
9595
const addonInstance = extension.getAddonInstance();
@@ -114,9 +114,9 @@ describe('cellExternalCopyManagerExtension', () => {
114114

115115
it('should call internal event handler subscribe and expect the "onCopyCells" option to be called when addon notify is called', () => {
116116
const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe');
117-
const onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onCopyCells');
118-
const onCancelSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onCopyCancelled');
119-
const onPasteSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onPasteCells');
117+
const onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onCopyCells');
118+
const onCancelSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onCopyCancelled');
119+
const onPasteSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onPasteCells');
120120

121121
const instance = extension.register();
122122
instance.onCopyCells.notify(mockSelectRangeEvent, new Slick.EventData(), gridStub);
@@ -133,9 +133,9 @@ describe('cellExternalCopyManagerExtension', () => {
133133

134134
it('should call internal event handler subscribe and expect the "onCopyCancelled" option to be called when addon notify is called', () => {
135135
const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe');
136-
const onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onCopyCells');
137-
const onCancelSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onCopyCancelled');
138-
const onPasteSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onPasteCells');
136+
const onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onCopyCells');
137+
const onCancelSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onCopyCancelled');
138+
const onPasteSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onPasteCells');
139139

140140
const instance = extension.register();
141141
instance.onCopyCancelled.notify(mockSelectRangeEvent, new Slick.EventData(), gridStub);
@@ -152,9 +152,9 @@ describe('cellExternalCopyManagerExtension', () => {
152152

153153
it('should call internal event handler subscribe and expect the "onPasteCells" option to be called when addon notify is called', () => {
154154
const handlerSpy = jest.spyOn(extension.eventHandler, 'subscribe');
155-
const onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onCopyCells');
156-
const onCancelSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onCopyCancelled');
157-
const onPasteSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions, 'onPasteCells');
155+
const onCopySpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onCopyCells');
156+
const onCancelSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onCopyCancelled');
157+
const onPasteSpy = jest.spyOn(SharedService.prototype.gridOptions.excelCopyBufferOptions as ExcelCopyBufferOption, 'onPasteCells');
158158

159159
const instance = extension.register();
160160
instance.onPasteCells.notify(mockSelectRangeEvent, new Slick.EventData(), gridStub);

src/app/modules/angular-slickgrid/extensions/cellExternalCopyManagerExtension.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
SlickEventHandler,
1111
} from '../models/index';
1212
import { ExtensionUtility } from './extensionUtility';
13+
import { BindingEventService } from '../services/bindingEvent.service';
1314
import { sanitizeHtmlToText } from '../services/utilities';
1415
import { SharedService } from '../services/shared.service';
1516

@@ -21,13 +22,15 @@ declare const $: any;
2122
export class CellExternalCopyManagerExtension implements Extension {
2223
private _addon: any;
2324
private _addonOptions: ExcelCopyBufferOption | null;
25+
private _bindingEventService: BindingEventService;
2426
private _cellSelectionModel: any;
2527
private _eventHandler: SlickEventHandler;
2628
private _commandQueue: EditCommand[];
2729
private _undoRedoBuffer: EditUndoRedoBuffer;
2830

2931
constructor(private extensionUtility: ExtensionUtility, private sharedService: SharedService) {
3032
this._eventHandler = new Slick.EventHandler();
33+
this._bindingEventService = new BindingEventService();
3134
}
3235

3336
get addonOptions(): ExcelCopyBufferOption | null {
@@ -49,19 +52,17 @@ export class CellExternalCopyManagerExtension implements Extension {
4952
dispose() {
5053
// unsubscribe all SlickGrid events
5154
this._eventHandler.unsubscribeAll();
55+
this._bindingEventService.unbindAll();
5256

5357
if (this._addon && this._addon.destroy) {
5458
this._addon.destroy();
5559
}
56-
57-
this.extensionUtility.nullifyFunctionNameStartingWithOn(this._addonOptions);
58-
this._addon = null;
59-
this._addonOptions = null;
60-
6160
if (this._cellSelectionModel && this._cellSelectionModel.destroy) {
6261
this._cellSelectionModel.destroy();
6362
}
64-
document.removeEventListener('keydown', this.hookUndoShortcutKey.bind(this));
63+
this.extensionUtility.nullifyFunctionNameStartingWithOn(this._addonOptions);
64+
this._addon = null;
65+
this._addonOptions = null;
6566
}
6667

6768
/** Get the instance of the SlickGrid addon (control or plugin). */
@@ -74,7 +75,7 @@ export class CellExternalCopyManagerExtension implements Extension {
7475
// dynamically import the SlickGrid plugin (addon) with RequireJS
7576
this.extensionUtility.loadExtensionDynamically(ExtensionName.cellExternalCopyManager);
7677
this.createUndoRedoBuffer();
77-
this.hookUndoShortcutKey();
78+
this._bindingEventService.bind(document.body, 'keydown', this.handleKeyDown.bind(this));
7879

7980
this._addonOptions = { ...this.getDefaultOptions(), ...this.sharedService.gridOptions.excelCopyBufferOptions } as ExcelCopyBufferOption;
8081
this._cellSelectionModel = new Slick.CellSelectionModel();
@@ -191,16 +192,14 @@ export class CellExternalCopyManagerExtension implements Extension {
191192
}
192193

193194
/** Hook an undo shortcut key hook that will redo/undo the copy buffer using Ctrl+(Shift)+Z keyboard events */
194-
private hookUndoShortcutKey() {
195-
document.addEventListener('keydown', (e: KeyboardEvent) => {
196-
const keyCode = e.keyCode || e.code;
197-
if (keyCode === 90 && (e.ctrlKey || e.metaKey)) {
198-
if (e.shiftKey) {
199-
this._undoRedoBuffer.redo(); // Ctrl + Shift + Z
200-
} else {
201-
this._undoRedoBuffer.undo(); // Ctrl + Z
202-
}
195+
private handleKeyDown(e: KeyboardEvent) {
196+
const keyCode = e.keyCode || e.code;
197+
if (keyCode === 90 && (e.ctrlKey || e.metaKey)) {
198+
if (e.shiftKey) {
199+
this._undoRedoBuffer.redo(); // Ctrl + Shift + Z
200+
} else {
201+
this._undoRedoBuffer.undo(); // Ctrl + Z
203202
}
204-
});
203+
}
205204
}
206205
}

src/app/modules/angular-slickgrid/formatters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export const Formatters = {
8282
* this.columnDefs = [{ id: 'username', field: 'user.firstName', ... }]
8383
* OR this.columnDefs = [{ id: 'username', field: 'user', params: { complexField: 'user.firstName' }, ... }]
8484
*/
85+
complex: complexObjectFormatter,
8586
complexObject: complexObjectFormatter,
8687

8788
/**
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface DOMEvent<T extends EventTarget> extends Event {
2+
target: T
3+
relatedTarget: T
4+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface ElementEventListener {
2+
element: Element;
3+
eventName: string;
4+
listener: EventListenerOrEventListenerObject;
5+
}

src/app/modules/angular-slickgrid/models/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export * from './currentSorter.interface';
3232
export * from './customFooterOption.interface';
3333
export * from './dataViewOption.interface';
3434
export * from './delimiterType.enum';
35+
export * from './domEvent.interface';
3536
export * from './draggableGrouping.interface';
3637
export * from './editCommand.interface';
3738
export * from './editor.interface';
@@ -40,6 +41,7 @@ export * from './editorArguments.interface';
4041
export * from './editorValidator.interface';
4142
export * from './editorValidatorOutput.interface';
4243
export * from './editUndoRedoBuffer.interface';
44+
export * from './elementEventListener.interface';
4345
export * from './elementPosition.interface';
4446
export * from './emitterType.enum';
4547
export * from './emptyWarning.interface';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { BindingEventService } from '../bindingEvent.service';
2+
3+
describe('BindingEvent Service', () => {
4+
let div: HTMLDivElement;
5+
let service: BindingEventService;
6+
7+
beforeEach(() => {
8+
service = new BindingEventService();
9+
div = document.createElement('div');
10+
document.body.appendChild(div);
11+
});
12+
13+
afterEach(() => {
14+
div.remove();
15+
service.unbindAll();
16+
jest.clearAllMocks();
17+
});
18+
19+
it('should be able to bind an event with listener to an element', () => {
20+
const mockElm = { addEventListener: jest.fn() } as unknown as HTMLElement;
21+
const mockCallback = jest.fn();
22+
const addEventSpy = jest.spyOn(mockElm, 'addEventListener');
23+
const elm = document.createElement('input');
24+
div.appendChild(elm);
25+
26+
service.bind(mockElm, 'click', mockCallback);
27+
28+
expect(addEventSpy).toHaveBeenCalledWith('click', mockCallback, undefined);
29+
});
30+
31+
it('should be able to bind an event with listener and options to an element', () => {
32+
const mockElm = { addEventListener: jest.fn() } as unknown as HTMLElement;
33+
const mockCallback = jest.fn();
34+
const addEventSpy = jest.spyOn(mockElm, 'addEventListener');
35+
const elm = document.createElement('input');
36+
div.appendChild(elm);
37+
38+
service.bind(mockElm, 'click', mockCallback, { capture: true, passive: true });
39+
40+
expect(addEventSpy).toHaveBeenCalledWith('click', mockCallback, { capture: true, passive: true });
41+
});
42+
43+
it('should call unbindAll and expect as many removeEventListener be called', () => {
44+
const mockElm = { addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as HTMLElement;
45+
const addEventSpy = jest.spyOn(mockElm, 'addEventListener');
46+
const removeEventSpy = jest.spyOn(mockElm, 'removeEventListener');
47+
const mockCallback1 = jest.fn();
48+
const mockCallback2 = jest.fn();
49+
50+
service = new BindingEventService();
51+
service.bind(mockElm, 'keyup', mockCallback1);
52+
service.bind(mockElm, 'click', mockCallback2, { capture: true, passive: true });
53+
service.unbindAll();
54+
55+
expect(addEventSpy).toHaveBeenCalledWith('keyup', mockCallback1, undefined);
56+
expect(addEventSpy).toHaveBeenCalledWith('click', mockCallback2, { capture: true, passive: true });
57+
expect(removeEventSpy).toHaveBeenCalledWith('keyup', mockCallback1);
58+
expect(removeEventSpy).toHaveBeenCalledWith('click', mockCallback2);
59+
});
60+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ElementEventListener } from '../models/elementEventListener.interface';
2+
3+
export class BindingEventService {
4+
private _boundedEvents: ElementEventListener[] = [];
5+
6+
/** Bind an event listener to any element */
7+
bind(element: Element, eventName: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) {
8+
element.addEventListener(eventName, listener, options);
9+
this._boundedEvents.push({ element, eventName, listener });
10+
}
11+
12+
/** Unbind all will remove every every event handlers that were bounded earlier */
13+
unbindAll() {
14+
while (this._boundedEvents.length > 0) {
15+
const boundedEvent = this._boundedEvents.pop() as ElementEventListener;
16+
const { element, eventName, listener } = boundedEvent;
17+
if (element && element.removeEventListener) {
18+
element.removeEventListener(eventName, listener);
19+
}
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)