Skip to content

Commit

Permalink
Merge pull request #11244 from ckeditor/ck/10880-tab-key
Browse files Browse the repository at this point in the history
Feature (engine): Introduced `TabObserver` that allows listening to pressing down the `Tab` key in a specified context.

Feature (core): The `MultiCommand` accepts priorities for child commands.

Other (table, code-block, list): `Tab` and `Tab+Shift` keystrokes handlers now listen to `tab` events and are executed with respect to context.

Internal (list): Implemented handling of `Tab` and `Tab+Shift` keys in document lists. Closes #10880.

Internal (list): `indentList` and `outdentList` commands are now registered with priority in 'Indent' `MultiCommand` , `Tab` and `Tab+Shift` listeners now executes in `li` context in order to not interfere with other plugins' listeners . Closes #11072.
  • Loading branch information
niegowski authored Feb 24, 2022
2 parents 31f09b8 + 83101fa commit 354827a
Show file tree
Hide file tree
Showing 19 changed files with 3,935 additions and 115 deletions.
27 changes: 15 additions & 12 deletions packages/ckeditor5-code-block/src/codeblockediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,18 @@ export default class CodeBlockEditing extends Plugin {
editor.commands.add( 'indentCodeBlock', new IndentCodeBlockCommand( editor ) );
editor.commands.add( 'outdentCodeBlock', new OutdentCodeBlockCommand( editor ) );

const getCommandExecuter = commandName => {
return ( data, cancel ) => {
const command = this.editor.commands.get( commandName );
this.listenTo( view.document, 'tab', ( evt, data ) => {
const commandName = data.shiftKey ? 'outdentCodeBlock' : 'indentCodeBlock';
const command = editor.commands.get( commandName );

if ( command.isEnabled ) {
this.editor.execute( commandName );
cancel();
}
};
};
if ( command.isEnabled ) {
editor.execute( commandName );

editor.keystrokes.set( 'Tab', getCommandExecuter( 'indentCodeBlock' ) );
editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'outdentCodeBlock' ) );
data.stopPropagation();
data.preventDefault();
evt.stop();
}
}, { context: 'pre' } );

schema.register( 'codeBlock', {
allowWhere: '$block',
Expand Down Expand Up @@ -220,7 +219,11 @@ export default class CodeBlockEditing extends Plugin {
const outdent = commands.get( 'outdent' );

if ( indent ) {
indent.registerChildCommand( commands.get( 'indentCodeBlock' ) );
// Priority is highest due to integration with `IndentList` command of `List` plugin.
// If selection is in a code block we give priority to it. This way list item cannot be indented
// but if we would give priority to indenting list item then user would have to indent list item
// as much as possible and only then he could indent code block.
indent.registerChildCommand( commands.get( 'indentCodeBlock' ), { priority: 'highest' } );
}

if ( outdent ) {
Expand Down
136 changes: 135 additions & 1 deletion packages/ckeditor5-code-block/tests/codeblockediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe( 'CodeBlockEditing', () => {
} );

it( 'should execute outdentCodeBlock command on Shift+Tab keystroke', () => {
domEvtDataStub.keyCode += getCode( 'Shift' );
domEvtDataStub.shiftKey = true;

setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );

Expand Down Expand Up @@ -223,6 +223,140 @@ describe( 'CodeBlockEditing', () => {
sinon.assert.notCalled( domEvtDataStub.preventDefault );
sinon.assert.notCalled( domEvtDataStub.stopPropagation );
} );

it( 'should not call indent block command when outside `pre` context', () => {
const indentBlockCommand = editor.commands.get( 'indentCodeBlock' );
const indentBlockCommandSpy = sinon.spy( indentBlockCommand, 'execute' );

setModelData( model,
'<paragraph>[]foo</paragraph>',
'<codeBlock language="plaintext">bar</codeBlock>'
);

editor.editing.view.document.fire( 'keydown', domEvtDataStub );

sinon.assert.notCalled( indentBlockCommandSpy );
sinon.assert.notCalled( domEvtDataStub.preventDefault );
sinon.assert.notCalled( domEvtDataStub.stopPropagation );
} );

it( 'should not call outdent block command when outside `pre` context', () => {
const outdentBlockCommand = editor.commands.get( 'outdentCodeBlock' );
const outdentBlockCommandSpy = sinon.spy( outdentBlockCommand, 'execute' );

domEvtDataStub.shiftKey = true;

setModelData( model,
'<paragraph>[]foo</paragraph>',
'<codeBlock language="plaintext">bar</codeBlock>'
);

editor.editing.view.document.fire( 'keydown', domEvtDataStub );

sinon.assert.notCalled( outdentBlockCommandSpy );
sinon.assert.notCalled( domEvtDataStub.preventDefault );
sinon.assert.notCalled( domEvtDataStub.stopPropagation );
} );

it( 'should not indent on tab key when tab event was captured by listener with higher priority', () => {
setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );

const onTabPress = ( bubblingEventInfo, domEventData ) => {
domEventData.preventDefault();
domEventData.stopPropagation();
bubblingEventInfo.stop();
};

const onTabPressSpy = sinon.spy( onTabPress );

editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'highest' } );

editor.editing.view.document.fire( 'keydown', domEvtDataStub );

sinon.assert.notCalled( editor.execute );
sinon.assert.calledOnce( domEvtDataStub.preventDefault );
sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
sinon.assert.calledOnce( onTabPressSpy );
} );

it( 'should not be stopped by a listener with lower priority', () => {
setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );

const onTabPress = ( bubblingEventInfo, domEventData ) => {
domEventData.preventDefault();
domEventData.stopPropagation();
bubblingEventInfo.stop();
};

const onTabPressSpy = sinon.spy( onTabPress );

editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'low' } );

editor.editing.view.document.fire( 'keydown', domEvtDataStub );

sinon.assert.calledOnce( editor.execute );
sinon.assert.calledWithExactly( editor.execute, 'indentCodeBlock' );
sinon.assert.calledOnce( domEvtDataStub.preventDefault );
sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
sinon.assert.notCalled( onTabPressSpy );
} );

it( 'should not outdent on tab key when tab event was captured by listener with higher priority', () => {
setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );

model.change( writer => {
// <codeBlock language="plaintext"> foo[]</codeBlock>
writer.insertText( ' ', model.document.getRoot().getChild( 0 ), 0 );
} );

domEvtDataStub.shiftKey = true;

const onTabPress = ( bubblingEventInfo, domEventData ) => {
domEventData.preventDefault();
domEventData.stopPropagation();
bubblingEventInfo.stop();
};

const onTabPressSpy = sinon.spy( onTabPress );

editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'highest' } );

editor.editing.view.document.fire( 'keydown', domEvtDataStub );

sinon.assert.notCalled( editor.execute );
sinon.assert.calledOnce( domEvtDataStub.preventDefault );
sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
sinon.assert.calledOnce( onTabPressSpy );
} );

it( 'outdent should not be stopped by a listener with lower priority', () => {
setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );

model.change( writer => {
// <codeBlock language="plaintext"> []foo</codeBlock>
writer.insertText( ' ', model.document.getRoot().getChild( 0 ) );
} );

domEvtDataStub.shiftKey = true;

const onTabPress = ( bubblingEventInfo, domEventData ) => {
domEventData.preventDefault();
domEventData.stopPropagation();
bubblingEventInfo.stop();
};

const onTabPressSpy = sinon.spy( onTabPress );

editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'lowest' } );

editor.editing.view.document.fire( 'keydown', domEvtDataStub );

sinon.assert.calledOnce( editor.execute );
sinon.assert.calledWithExactly( editor.execute, 'outdentCodeBlock' );
sinon.assert.calledOnce( domEvtDataStub.preventDefault );
sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
sinon.assert.notCalled( onTabPressSpy );
} );
} );

describe( 'enter key handling', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/ckeditor5-core/tests/multicommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
*/

import MultiCommand from '../src/multicommand';
import ModelTestEditor from './_utils/modeltesteditor';
import Command from '../src/command';

import ModelTestEditor from './_utils/modeltesteditor';
import testUtils from './_utils/utils';

describe( 'MultiCommand', () => {
let editor, multiCommand;

testUtils.createSinonSandbox();

beforeEach( () => {
return ModelTestEditor
.create()
Expand Down
68 changes: 68 additions & 0 deletions packages/ckeditor5-engine/src/view/observer/tabobserver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module engine/view/observer/tabobserver
*/

import Observer from './observer';
import BubblingEventInfo from './bubblingeventinfo';

import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';

/**
* Tab observer introduces the {@link module:engine/view/document~Document#event:tab `Document#tab`} event.
*
* Note that because {@link module:engine/view/observer/tabobserver~TabObserver} is attached by the
* {@link module:engine/view/view~View} this event is available by default.
*
* @extends module:engine/view/observer/observer~Observer
*/
export default class TabObserver extends Observer {
/**
* @inheritDoc
*/
constructor( view ) {
super( view );

const doc = this.document;

doc.on( 'keydown', ( evt, data ) => {
if (
!this.isEnabled ||
data.keyCode != keyCodes.tab ||
data.ctrlKey
) {
return;
}

const event = new BubblingEventInfo( doc, 'tab', doc.selection.getFirstRange() );

doc.fire( event, data );

if ( event.stop.called ) {
evt.stop();
}
} );
}

/**
* @inheritDoc
*/
observe() {}
}

/**
* Event fired when the user presses a tab key.
*
* Introduced by {@link module:engine/view/observer/tabobserver~TabObserver}.
*
* Note that because {@link module:engine/view/observer/tabobserver~TabObserver} is attached by the
* {@link module:engine/view/view~View} this event is available by default.
*
* @event module:engine/view/document~Document#event:tab
*
* @param {module:engine/view/observer/domeventdata~DomEventData} data
*/
4 changes: 4 additions & 0 deletions packages/ckeditor5-engine/src/view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import FocusObserver from './observer/focusobserver';
import CompositionObserver from './observer/compositionobserver';
import InputObserver from './observer/inputobserver';
import ArrowKeysObserver from './observer/arrowkeysobserver';
import TabObserver from './observer/tabobserver';

import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';
Expand Down Expand Up @@ -54,6 +55,8 @@ import env from '@ckeditor/ckeditor5-utils/src/env';
* * {@link module:engine/view/observer/keyobserver~KeyObserver},
* * {@link module:engine/view/observer/fakeselectionobserver~FakeSelectionObserver}.
* * {@link module:engine/view/observer/compositionobserver~CompositionObserver}.
* * {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver}.
* * {@link module:engine/view/observer/tabobserver~TabObserver}.
*
* This class also {@link module:engine/view/view~View#attachDomRoot binds the DOM and the view elements}.
*
Expand Down Expand Up @@ -186,6 +189,7 @@ export default class View {
this.addObserver( FakeSelectionObserver );
this.addObserver( CompositionObserver );
this.addObserver( ArrowKeysObserver );
this.addObserver( TabObserver );

if ( env.isAndroid ) {
this.addObserver( InputObserver );
Expand Down
Loading

0 comments on commit 354827a

Please sign in to comment.