Skip to content

Commit

Permalink
Merge pull request #9751 from ckeditor/i/4665
Browse files Browse the repository at this point in the history
Feature (mention): Keyboard shortcuts to accept mentions can be customized using the  [`config.mention.commitKeys`](https://ckeditor.com/docs/ckeditor5/latest/api/module_mention_mention-MentionConfig.html#member-commitKeys) configuration option. Closes #4665.
  • Loading branch information
oleq authored Aug 2, 2021
2 parents 35b08a9 + 274367a commit 9fa1052
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 14 deletions.
26 changes: 26 additions & 0 deletions packages/ckeditor5-mention/src/mention.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ export default class Mention extends Plugin {
* @member {Array.<module:mention/mention~MentionFeed>} module:mention/mention~MentionConfig#feeds
*/

/**
* The configuration of the custom commit keys supported by the editor.
*
* ClassicEditor
* .create( editorElement, {
* plugins: [ Mention, ... ],
* mention: {
* // [ Enter, Space ]
* commitKeys: [ 13, 32 ]
* feeds: [
* { ... }
* ...
* ]
* }
* } )
* .then( ... )
* .catch( ... );
*
* Custom commit keys configuration allows you to customize how users will confirm the selection of mentions from the dropdown list.
* You can add as many mention commit keys as you need. For instance, in the snippet above new mentions will be committed by pressing
* either <kbd>Enter</kbd> or <kbd>Space</kbd> (13 and 32 key codes respectively).
*
* @member {Array.<Number>} module:mention/mention~MentionConfig#commitKeys
* @default [ 13, 9 ] // [ Enter, Tab ]
*/

/**
* The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}.
*
Expand Down
33 changes: 20 additions & 13 deletions packages/ckeditor5-mention/src/mentionui.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ import MentionListItemView from './ui/mentionlistitemview';

const VERTICAL_SPACING = 3;

// The key codes that mention UI handles when it is open.
const handledKeyCodes = [
// The key codes that mention UI handles when it is open (without commit keys).
const defaultHandledKeyCodes = [
keyCodes.arrowup,
keyCodes.arrowdown,
keyCodes.enter,
keyCodes.tab,
keyCodes.esc
];

// Dropdown commit key codes.
const defaultCommitKeyCodes = [
keyCodes.enter,
keyCodes.tab
];

/**
* The mention UI feature.
*
Expand Down Expand Up @@ -90,6 +94,9 @@ export default class MentionUI extends Plugin {
init() {
const editor = this.editor;

const commitKeys = editor.config.get( 'mention.commitKeys' ) || defaultCommitKeyCodes;
const handledKeyCodes = defaultHandledKeyCodes.concat( commitKeys );

/**
* The contextual balloon plugin instance.
*
Expand All @@ -112,7 +119,7 @@ export default class MentionUI extends Plugin {
this._mentionsView.selectPrevious();
}

if ( data.keyCode == keyCodes.enter || data.keyCode == keyCodes.tab ) {
if ( commitKeys.includes( data.keyCode ) ) {
this._mentionsView.executeSelected();
}

Expand Down Expand Up @@ -165,6 +172,14 @@ export default class MentionUI extends Plugin {

this.on( 'requestFeed:response', ( evt, data ) => this._handleFeedResponse( data ) );
this.on( 'requestFeed:error', () => this._hideUIAndRemoveMarker() );

// Checks if a given key code is handled by the mention UI.
//
// @param {Number}
// @returns {Boolean}
function isHandledKey( keyCode ) {
return handledKeyCodes.includes( keyCode );
}
}

/**
Expand Down Expand Up @@ -686,14 +701,6 @@ function createFeedCallback( feedItems ) {
};
}

// Checks if a given key code is handled by the mention UI.
//
// @param {Number}
// @returns {Boolean}
function isHandledKey( keyCode ) {
return handledKeyCodes.includes( keyCode );
}

// Checks if position in inside or right after a text with a mention.
//
// @param {module:engine/model/position~Position} position.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div id="editor">
<p>Hello <span class="mention" data-mention="@Ted">@Ted</span>.</p>

<figure class="image">
<img src="sample.jpg" />
<figcaption>CKEditor logo - caption</figcaption>
</figure>
</div>
142 changes: 142 additions & 0 deletions packages/ckeditor5-mention/tests/manual/mention-custom-commitkeys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* global console, window */

import global from '@ckeditor/ckeditor5-utils/src/dom/global';

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Mention from '../../src/mention';
import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

import { toWidget, viewToModelPositionOutsideModelElement } from '@ckeditor/ckeditor5-widget/src/utils';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';

import { keyCodes } from 'ckeditor5/src/utils';

class InlineWidget extends Plugin {
constructor( editor ) {
super( editor );

editor.model.schema.register( 'placeholder', {
allowWhere: '$text',
isObject: true,
isInline: true,
allowAttributes: [ 'type' ]
} );

editor.conversion.for( 'editingDowncast' ).elementToElement( {
model: 'placeholder',
view: ( modelItem, conversionApi ) => {
const widgetElement = createPlaceholderView( modelItem, conversionApi );

return toWidget( widgetElement, conversionApi.writer );
}
} );

editor.conversion.for( 'dataDowncast' ).elementToElement( {
model: 'placeholder',
view: createPlaceholderView
} );

editor.conversion.for( 'upcast' ).elementToElement( {
view: 'placeholder',
model: ( viewElement, { writer } ) => {
let type = 'general';

if ( viewElement.childCount ) {
const text = viewElement.getChild( 0 );

if ( text.is( '$text' ) ) {
type = text.data.slice( 1, -1 );
}
}

return writer.createElement( 'placeholder', { type } );
}
} );

editor.editing.mapper.on(
'viewToModelPosition',
viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.name == 'placeholder' )
);

this._createToolbarButton();

function createPlaceholderView( modelItem, { writer } ) {
const widgetElement = writer.createContainerElement( 'placeholder' );
const viewText = writer.createText( '{' + modelItem.getAttribute( 'type' ) + '}' );

writer.insert( writer.createPositionAt( widgetElement, 0 ), viewText );

return widgetElement;
}
}

_createToolbarButton() {
const editor = this.editor;
const t = editor.t;

editor.ui.componentFactory.add( 'placeholder', locale => {
const buttonView = new ButtonView( locale );

buttonView.set( {
label: t( 'Insert placeholder' ),
tooltip: true,
withText: true
} );

this.listenTo( buttonView, 'execute', () => {
const model = editor.model;

model.change( writer => {
const placeholder = writer.createElement( 'placeholder', { type: 'placeholder' } );

model.insertContent( placeholder );

writer.setSelection( placeholder, 'on' );
} );
} );

return buttonView;
} );
}
}

ClassicEditor
.create( global.document.querySelector( '#editor' ), {
plugins: [ ArticlePluginSet, Underline, Mention, InlineWidget ],
toolbar: [
'heading',
'|', 'bulletedList', 'numberedList', 'blockQuote',
'|', 'bold', 'italic', 'underline', 'link',
'|', 'insertTable', 'placeholder',
'|', 'undo', 'redo'
],
image: {
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
},
table: {
contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
tableToolbar: [ 'bold', 'italic' ]
},
mention: {
commitKeys: [ keyCodes.a, keyCodes.space ],
feeds: [
{
marker: '@',
feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ]
}
]
}
} )
.then( editor => {
window.editor = editor;
} )
.catch( err => {
console.error( err.stack );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Mention

The mention configuration with a custom `config.mention.commitKeys` configuration and a static list of autocomplete feed:

### Configuration

Type "@" to display the list of available mentions.

### Interaction

- Use **<kbd>arrowup</kbd>** to select previous item
- Use **<kbd>arrowdown</kbd>** to select next item
- Use **<kbd>space</kbd>** or **<kbd>a</kbd>** keys to insert a mention into the documentation.
71 changes: 70 additions & 1 deletion packages/ckeditor5-mention/tests/mentionui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1549,7 +1549,7 @@ describe( 'MentionUI', () => {
};

const keyUpEvtData = {
keyCode: keyCodes.arrowdown,
keyCode: keyCodes.arrowup,
preventDefault: sinon.spy(),
stopPropagation: sinon.spy()
};
Expand Down Expand Up @@ -2099,6 +2099,75 @@ describe( 'MentionUI', () => {
} );
} );
}

describe( 'overriding commit keys using config.mention.commitKeys', () => {
const issues = [
{ id: '@Ted' },
{ id: '@Barney' },
{ id: '@Robin' },
{ id: '@Lily' },
{ id: '@Marshal' }
];

beforeEach( () => {
return createClassicTestEditor( {
commitKeys: [ keyCodes.a ],
feeds: [
{
marker: '@',
feed: feedText => issues.filter( issue => issue.id.includes( feedText ) )
}
]
} );
} );

// Testing if custom key configuration will execute the mention command.
testExecuteKey( 'a', keyCodes.a, issues );

it( 'should no longer commit on enter (default)', () => {
setData( model, '<paragraph>foo []</paragraph>' );

model.change( writer => {
writer.insertText( '@', doc.selection.getFirstPosition() );
} );

return waitForDebounce()
.then( () => {
const command = editor.commands.get( 'mention' );
const executeSpy = testUtils.sinon.spy( command, 'execute' );

fireKeyDownEvent( {
keyCode: keyCodes.enter,
preventDefault: sinon.spy(),
stopPropagation: sinon.spy()
} );

sinon.assert.notCalled( executeSpy );
} );
} );

it( 'should no longer commit on tab (default)', () => {
setData( model, '<paragraph>foo []</paragraph>' );

model.change( writer => {
writer.insertText( '@', doc.selection.getFirstPosition() );
} );

return waitForDebounce()
.then( () => {
const command = editor.commands.get( 'mention' );
const executeSpy = testUtils.sinon.spy( command, 'execute' );

fireKeyDownEvent( {
keyCode: keyCodes.tab,
preventDefault: sinon.spy(),
stopPropagation: sinon.spy()
} );

sinon.assert.notCalled( executeSpy );
} );
} );
} );
} );

describe( 'execute', () => {
Expand Down

0 comments on commit 9fa1052

Please sign in to comment.