diff --git a/package.json b/package.json index 7034b513..ee928650 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@ckeditor/ckeditor5-theme-lark": "^1.0.0-alpha.2" }, "devDependencies": { + "@ckeditor/ckeditor5-typing": "^1.0.0-alpha.2", "eslint": "^4.8.0", "eslint-config-ckeditor5": "^1.0.7", "husky": "^0.14.3", diff --git a/src/widget.js b/src/widget.js index 5770a61c..30c2fa1e 100644 --- a/src/widget.js +++ b/src/widget.js @@ -84,6 +84,14 @@ export default class Widget extends Plugin { // Handle custom keydown behaviour. this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } ); + + // Handle custom delete behaviour. + this.listenTo( viewDocument, 'delete', ( evt, data ) => { + if ( this._handleDelete( data.direction == 'forward' ) ) { + data.preventDefault(); + evt.stop(); + } + }, { priority: 'high' } ); } /** @@ -141,9 +149,7 @@ export default class Widget extends Plugin { // Checks if the keys were handled and then prevents the default event behaviour and stops // the propagation. - if ( isDeleteKeyCode( keyCode ) ) { - wasHandled = this._handleDelete( isForward ); - } else if ( isArrowKeyCode( keyCode ) ) { + if ( isArrowKeyCode( keyCode ) ) { wasHandled = this._handleArrowKeys( isForward ); } else if ( isSelectAllKeyCode( domEventData ) ) { wasHandled = this._selectAllNestedEditableContent() || this._selectAllContent(); @@ -356,14 +362,6 @@ function isArrowKeyCode( keyCode ) { keyCode == keyCodes.arrowdown; } -// Returns 'true' if provided key code represents one of the delete keys: delete or backspace. -// -// @param {Number} keyCode -// @returns {Boolean} -function isDeleteKeyCode( keyCode ) { - return keyCode == keyCodes.delete || keyCode == keyCodes.backspace; -} - // Returns 'true' if provided (DOM) key event data corresponds with the Ctrl+A keystroke. // // @param {module:engine/view/observer/keyobserver~KeyEventData} domEventData diff --git a/tests/widget.js b/tests/widget.js index 71645f76..e174f650 100644 --- a/tests/widget.js +++ b/tests/widget.js @@ -5,6 +5,7 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Widget from '../src/widget'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter'; import { toWidget } from '../src/utils'; @@ -22,7 +23,7 @@ describe( 'Widget', () => { let editor, model, doc, viewDocument; beforeEach( () => { - return VirtualTestEditor.create( { plugins: [ Widget ] } ) + return VirtualTestEditor.create( { plugins: [ Widget, Delete ] } ) .then( newEditor => { editor = newEditor; model = editor.model; @@ -245,431 +246,6 @@ describe( 'Widget', () => { } ); describe( 'keys handling', () => { - describe( 'delete and backspace', () => { - test( - 'should select widget when backspace is pressed', - '[]foo', - keyCodes.backspace, - '[]foo' - ); - - test( - 'should remove empty element after selecting widget when backspace is pressed', - '[]', - keyCodes.backspace, - '[]' - ); - - test( - 'should select widget when delete is pressed', - 'foo[]', - keyCodes.delete, - 'foo[]' - ); - - test( - 'should remove empty element after selecting widget when delete is pressed', - '[]', - keyCodes.delete, - '[]' - ); - - test( - 'should not respond to other keys', - '[]foo', - 65, - '[]foo' - ); - - test( - 'should do nothing on non-collapsed selection', - '[f]oo', - keyCodes.backspace, - '[f]oo' - ); - - test( - 'should do nothing on non-object elements', - 'foo[]bar', - keyCodes.backspace, - 'foo[]bar' - ); - - test( - 'should work correctly with modifier key: backspace + ctrl', - '[]foo', - { keyCode: keyCodes.backspace, ctrlKey: true }, - '[]foo' - ); - - test( - 'should work correctly with modifier key: backspace + alt', - '[]foo', - { keyCode: keyCodes.backspace, altKey: true }, - '[]foo' - ); - - test( - 'should work correctly with modifier key: backspace + shift', - '[]foo', - { keyCode: keyCodes.backspace, shiftKey: true }, - '[]foo' - ); - - test( - 'should work correctly with modifier key: delete + ctrl', - 'foo[]', - { keyCode: keyCodes.delete, ctrlKey: true }, - 'foo[]' - ); - - test( - 'should work correctly with modifier key: delete + alt', - 'foo[]', - { keyCode: keyCodes.delete, altKey: true }, - 'foo[]' - ); - - test( - 'should work correctly with modifier key: delete + shift', - 'foo[]', - { keyCode: keyCodes.delete, shiftKey: true }, - 'foo[]' - ); - - test( - 'should not modify backspace default behaviour in single paragraph boundaries', - '[]foo', - keyCodes.backspace, - '[]foo' - ); - - test( - 'should not modify delete default behaviour in single paragraph boundaries', - 'foo[]', - keyCodes.delete, - 'foo[]' - ); - - test( - 'should do nothing on selected widget preceded by a paragraph - backspace', - 'foo[]', - keyCodes.backspace, - 'foo[]' - ); - - test( - 'should do nothing on selected widget preceded by another widget - backspace', - '[]', - keyCodes.backspace, - '[]' - ); - - test( - 'should do nothing on selected widget before paragraph - backspace', - '[]foo', - keyCodes.backspace, - '[]foo' - ); - - test( - 'should do nothing on selected widget before another widget - backspace', - '[]', - keyCodes.backspace, - '[]' - ); - - test( - 'should do nothing on selected widget between paragraphs - backspace', - 'bar[]foo', - keyCodes.backspace, - 'bar[]foo' - ); - - test( - 'should do nothing on selected widget between other widgets - backspace', - '[]', - keyCodes.backspace, - '[]' - ); - - test( - 'should do nothing on selected widget preceded by a paragraph - delete', - 'foo[]', - keyCodes.delete, - 'foo[]' - ); - - test( - 'should do nothing on selected widget preceded by another widget - delete', - '[]', - keyCodes.delete, - '[]' - ); - - test( - 'should do nothing on selected widget before paragraph - delete', - '[]foo', - keyCodes.delete, - '[]foo' - ); - - test( - 'should do nothing on selected widget before another widget - delete', - '[]', - keyCodes.delete, - '[]' - ); - - test( - 'should do nothing on selected widget between paragraphs - delete', - 'bar[]foo', - keyCodes.delete, - 'bar[]foo' - ); - - test( - 'should do nothing on selected widget between other widgets - delete', - '[]', - keyCodes.delete, - '[]' - ); - - test( - 'should select inline objects - backspace', - 'foo[]bar', - keyCodes.backspace, - 'foo[]bar' - ); - - test( - 'should select inline objects - delete', - 'foo[]bar', - keyCodes.delete, - 'foo[]bar' - ); - - test( - 'should do nothing on selected inline objects - backspace', - 'foo[]bar', - keyCodes.backspace, - 'foo[]bar' - ); - - test( - 'should do nothing on selected inline objects - delete', - 'foo[]bar', - keyCodes.delete, - 'foo[]bar' - ); - - test( - 'should do nothing if selection is placed after first letter - backspace', - 'a[]', - keyCodes.backspace, - 'a[]' - ); - - test( - 'should do nothing if selection is placed before first letter - delete', - '[]a', - keyCodes.delete, - '[]a' - ); - - it( 'should prevent default behaviour and stop event propagation', () => { - const keydownHandler = sinon.spy(); - const domEventDataMock = { - keyCode: keyCodes.delete, - preventDefault: sinon.spy(), - }; - setModelData( model, 'foo[]' ); - viewDocument.on( 'keydown', keydownHandler ); - - viewDocument.fire( 'keydown', domEventDataMock ); - - expect( getModelData( model ) ).to.equal( 'foo[]' ); - sinon.assert.calledOnce( domEventDataMock.preventDefault ); - sinon.assert.notCalled( keydownHandler ); - } ); - - test( - 'should remove the entire empty element if it is next to a widget', - - 'foo' + - '' + - '
[]
' + - 'foo', - - keyCodes.backspace, - - 'foo[]foo' - ); - - test( - 'should remove the entire empty element (deeper structure) if it is next to a widget', - - 'foo' + - '' + - '
[]
' + - 'foo', - - keyCodes.backspace, - - 'foo' + - '[]' + - 'foo' - ); - - test( - 'should remove the entire empty element (deeper structure) if it is next to a widget (forward delete)', - - 'foo' + - '
[]
' + - '' + - 'foo', - - keyCodes.delete, - - 'foo' + - '[]' + - 'foo' - ); - - test( - 'should not remove the entire element which is not empty and the element is next to a widget', - - 'foo' + - '' + - '
[]
' + - 'foo', - - keyCodes.backspace, - - 'foo' + - '[]' + - '
' + - 'foo' - ); - - test( - 'should not remove the entire element which is not empty and the element is next to a widget (forward delete)', - - 'foo' + - '
Foo[]
' + - '' + - 'foo', - - keyCodes.delete, - - 'foo' + - '
Foo
' + - '[]' + - 'foo' - ); - - test( - 'should not remove the entire element (deeper structure) which is not empty and the element is next to a widget', - - 'foo' + - '' + - '
' + - '
' + - '
' + - '[]' + - '
' + - '
' + - '' + - '
' + - 'foo', - - keyCodes.backspace, - - 'foo' + - '[]' + - '
' + - '' + - '
' + - 'foo' - ); - - test( - 'should do nothing if the nested element is not empty and the element is next to a widget', - - 'foo' + - '' + - '
' + - '
' + - '
' + - 'Foo[]' + - '
' + - '
' + - '
' + - 'foo', - - keyCodes.backspace, - - 'foo' + - '' + - '
' + - '
' + - '
' + - 'Foo[]' + - '
' + - '
' + - '
' + - 'foo' - ); - - it( 'does nothing when editor when read only mode is enabled (delete)', () => { - setModelData( model, - 'foo' + - '' + - '
[]
' + - 'foo' - ); - - editor.isReadOnly = true; - - viewDocument.fire( 'keydown', new DomEventData( - viewDocument, - { target: document.createElement( 'div' ), preventDefault: () => {} }, - { keyCode: keyCodes.backspace } - ) ); - - expect( getModelData( model ) ).to.equal( - 'foo' + - '' + - '
[]
' + - 'foo' - ); - } ); - - it( 'does nothing when editor when read only mode is enabled (forward delete)', () => { - setModelData( model, - 'foo' + - '
[]
' + - '' + - 'foo' - ); - - editor.isReadOnly = true; - - viewDocument.fire( 'keydown', new DomEventData( - viewDocument, - { target: document.createElement( 'div' ), preventDefault: () => {} }, - { keyCode: keyCodes.delete } - ) ); - - expect( getModelData( model ) ).to.equal( - 'foo' + - '
[]
' + - '' + - 'foo' - ); - } ); - } ); - describe( 'arrows', () => { test( 'should move selection forward from selected object - right arrow', @@ -783,6 +359,13 @@ describe( 'Widget', () => { '[]foo' ); + test( + 'should do nothing if other key is pressed', + '[]foo', + keyCodes.a, + '[]foo' + ); + it( 'should prevent default behaviour when there is no correct location - document end', () => { const keydownHandler = sinon.spy(); const domEventDataMock = { @@ -1121,4 +704,407 @@ describe( 'Widget', () => { } ); } } ); + + describe( 'delete integration', () => { + function test( name, input, direction, expected ) { + it( name, () => { + setModelData( model, input ); + const scrollStub = sinon.stub( viewDocument, 'scrollToTheSelection' ); + + viewDocument.fire( 'delete', new DomEventData( + viewDocument, + { target: document.createElement( 'div' ), preventDefault: () => {} }, + { direction, unit: 'character', sequence: 0 } + ) ); + + expect( getModelData( model ) ).to.equal( expected ); + scrollStub.restore(); + } ); + } + + test( + 'should select widget when backspace is pressed', + '[]foo', + 'backward', + '[]foo' + ); + + test( + 'should remove empty element after selecting widget when backspace is pressed', + '[]', + 'backward', + '[]' + ); + + test( + 'should select widget when delete is pressed', + 'foo[]', + 'forward', + 'foo[]' + ); + + test( + 'should remove empty element after selecting widget when delete is pressed', + '[]', + 'forward', + '[]' + ); + + test( + 'should not select widget on non-collapsed selection', + '[f]oo', + 'backward', + '[]oo' + ); + + test( + 'should not affect non-object elements', + 'foo[]bar', + 'backward', + 'foo[]bar' + ); + + test( + 'should not modify backward delete default behaviour in single paragraph boundaries', + '[]foo', + 'backward', + '[]foo' + ); + + test( + 'should not modify forward delete default behaviour in single paragraph boundaries', + 'foo[]', + 'forward', + 'foo[]' + ); + + test( + 'should delete selected widget with paragraph before - backward', + 'foo[]', + 'backward', + 'foo[]' + ); + + test( + 'should delete selected widget with paragraph before - forward', + 'foo[]', + 'forward', + 'foo[]' + ); + + test( + 'should delete selected widget with paragraph after - backward', + '[]foo', + 'backward', + '[]foo' + ); + + test( + 'should delete selected widget with paragraph after - forward', + '[]foo', + 'forward', + '[]foo' + ); + + test( + 'should delete selected widget between paragraphs - backward', + 'bar[]foo', + 'backward', + 'bar[]foo' + ); + + test( + 'should delete selected widget between paragraphs - forward', + 'bar[]foo', + 'forward', + 'bar[]foo' + ); + + test( + 'should delete selected widget preceded by another widget - backward', + '[]', + 'backward', + '[]' + ); + + test( + 'should delete selected widget preceded by another widget - forward', + '[]', + 'forward', + '[]' + ); + + test( + 'should delete selected widget before another widget - forward', + '[]', + 'forward', + '[]' + ); + + test( + 'should delete selected widget before another widget - backward', + '[]', + 'backward', + '[]' + ); + + test( + 'should delete selected widget between other widgets - forward', + '[]', + 'forward', + '[]' + ); + + test( + 'should delete selected widget between other widgets - backward', + '[]', + 'backward', + '[]' + ); + + test( + 'should select inline objects - backward', + 'foo[]bar', + 'backward', + 'foo[]bar' + ); + + test( + 'should select inline objects - forward', + 'foo[]bar', + 'forward', + 'foo[]bar' + ); + + test( + 'should delete selected inline objects - backward', + 'foo[]bar', + 'backward', + 'foo[]bar' + ); + + test( + 'should delete selected inline objects - forward', + 'foo[]bar', + 'forward', + 'foo[]bar' + ); + + test( + 'should use standard delete behaviour when after first letter - backward', + 'a[]', + 'backward', + '[]' + ); + + test( + 'should use standard delete behaviour when before first letter - forward', + '[]a', + 'forward', + '[]' + ); + + it( 'should prevent default behaviour and stop event propagation', () => { + setModelData( model, 'foo[]' ); + const scrollStub = sinon.stub( viewDocument, 'scrollToTheSelection' ); + const deleteSpy = sinon.spy(); + + viewDocument.on( 'delete', deleteSpy ); + const domEventDataMock = { target: document.createElement( 'div' ), preventDefault: sinon.spy() }; + + viewDocument.fire( 'delete', new DomEventData( + viewDocument, + domEventDataMock, + { direction: 'forward', unit: 'character', sequence: 0 } + ) ); + + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.notCalled( deleteSpy ); + scrollStub.restore(); + } ); + + test( + 'should remove the entire empty element if it is next to a widget', + + 'foo' + + '' + + '
[]
' + + 'foo', + + 'backward', + + 'foo[]foo' + ); + + test( + 'should remove the entire empty element (deeper structure) if it is next to a widget', + + 'foo' + + '' + + '
[]
' + + 'foo', + + 'backward', + + 'foo' + + '[]' + + 'foo' + ); + + test( + 'should remove the entire empty element (deeper structure) if it is next to a widget (forward delete)', + + 'foo' + + '
[]
' + + '' + + 'foo', + + 'forward', + + 'foo' + + '[]' + + 'foo' + ); + + test( + 'should not remove the entire element which is not empty and the element is next to a widget', + + 'foo' + + '' + + '
[]
' + + 'foo', + + 'backward', + + 'foo' + + '[]' + + '
' + + 'foo' + ); + + test( + 'should not remove the entire element which is not empty and the element is next to a widget (forward delete)', + + 'foo' + + '
Foo[]
' + + '' + + 'foo', + + 'forward', + + 'foo' + + '
Foo
' + + '[]' + + 'foo' + ); + + test( + 'should not remove the entire element (deeper structure) which is not empty and the element is next to a widget', + + 'foo' + + '' + + '
' + + '
' + + '
' + + '[]' + + '
' + + '
' + + '' + + '
' + + 'foo', + + 'backward', + + 'foo' + + '[]' + + '
' + + '' + + '
' + + 'foo' + ); + + test( + 'should do nothing if the nested element is not empty and the element is next to a widget', + + 'foo' + + '' + + '
' + + '
' + + '
' + + 'Foo[]' + + '
' + + '
' + + '
' + + 'foo', + + 'backward', + + 'foo' + + '' + + '
' + + '
' + + '
' + + 'Fo[]' + + '
' + + '
' + + '
' + + 'foo' + ); + + it( 'does nothing when editor when read only mode is enabled (delete)', () => { + const scrollStub = sinon.stub( viewDocument, 'scrollToTheSelection' ); + setModelData( model, + 'foo' + + '' + + '
[]
' + + 'foo' + ); + + editor.isReadOnly = true; + + const domEventDataMock = { target: document.createElement( 'div' ), preventDefault: sinon.spy() }; + + viewDocument.fire( 'delete', new DomEventData( + viewDocument, + domEventDataMock, + { direction: 'backward', unit: 'character', sequence: 0 } + ) ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '' + + '
[]
' + + 'foo' + ); + scrollStub.restore(); + } ); + + it( 'does nothing when editor when read only mode is enabled (forward delete)', () => { + const scrollStub = sinon.stub( viewDocument, 'scrollToTheSelection' ); + setModelData( model, + 'foo' + + '' + + '
[]
' + + 'foo' + ); + + editor.isReadOnly = true; + + const domEventDataMock = { target: document.createElement( 'div' ), preventDefault: sinon.spy() }; + + viewDocument.fire( 'delete', new DomEventData( + viewDocument, + domEventDataMock, + { direction: 'forward', unit: 'character', sequence: 0 } + ) ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '' + + '
[]
' + + 'foo' + ); + scrollStub.restore(); + } ); + } ); } );