diff --git a/docs/_snippets/features/build-mention-source.html b/docs/_snippets/features/build-mention-source.html new file mode 100644 index 0000000..e69de29 diff --git a/docs/_snippets/features/build-mention-source.js b/docs/_snippets/features/build-mention-source.js new file mode 100644 index 0000000..4006230 --- /dev/null +++ b/docs/_snippets/features/build-mention-source.js @@ -0,0 +1,13 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor'; +import Mention from '@ckeditor/ckeditor5-mention/src/mention'; + +ClassicEditor.builtinPlugins.push( Mention ); + +window.ClassicEditor = ClassicEditor; diff --git a/docs/_snippets/features/custom-mention-colors-variables.html b/docs/_snippets/features/custom-mention-colors-variables.html new file mode 100644 index 0000000..6d50d1b --- /dev/null +++ b/docs/_snippets/features/custom-mention-colors-variables.html @@ -0,0 +1,14 @@ + + +
+

Hello @Ted.

+
diff --git a/docs/_snippets/features/custom-mention-colors-variables.js b/docs/_snippets/features/custom-mention-colors-variables.js new file mode 100644 index 0000000..5d974a3 --- /dev/null +++ b/docs/_snippets/features/custom-mention-colors-variables.js @@ -0,0 +1,31 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals ClassicEditor, console, window, document */ + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +ClassicEditor + .create( document.querySelector( '#snippet-mention-custom-colors' ), { + cloudServices: CS_CONFIG, + toolbar: { + items: [ + 'heading', '|', 'bold', 'italic', '|', 'undo', 'redo' + ], + viewportTopOffset: window.getViewportTopOffsetConfig() + }, + mention: [ + { + marker: '@', + feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] + } + ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/docs/_snippets/features/mention-customization.html b/docs/_snippets/features/mention-customization.html new file mode 100644 index 0000000..ca68d19 --- /dev/null +++ b/docs/_snippets/features/mention-customization.html @@ -0,0 +1,22 @@ +
+

Hello @Ted Mosby!

+
+ + diff --git a/docs/_snippets/features/mention-customization.js b/docs/_snippets/features/mention-customization.js new file mode 100644 index 0000000..6843e4e --- /dev/null +++ b/docs/_snippets/features/mention-customization.js @@ -0,0 +1,153 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals ClassicEditor, console, window, document, setTimeout */ + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; + +// The link plugin using highest priority in conversion pipeline. +const HIGHER_THEN_HIGHEST = priorities.highest + 50; + +ClassicEditor + .create( document.querySelector( '#snippet-mention-customization' ), { + cloudServices: CS_CONFIG, + extraPlugins: [ CustomMention ], + toolbar: { + items: [ + 'heading', '|', 'bold', 'italic', '|', 'undo', 'redo' + ], + viewportTopOffset: window.getViewportTopOffsetConfig(), + }, + mention: [ + { + marker: '@', + feed: getFeedItems, + itemRenderer: customItemRenderer, + minimumCharacters: 1 + } + ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +function CustomMention( editor ) { + // The upcast converter will convert elements to the model 'mention' attribute. + editor.conversion.for( 'upcast' ).elementToAttribute( { + view: { + name: 'a', + key: 'data-mention', + classes: 'mention', + attributes: { + href: true, + 'data-user-id': true + } + }, + model: { + key: 'mention', + value: viewItem => { + // Optionally: do not convert partial mentions. + if ( !isFullMention( viewItem ) ) { + return; + } + + // The mention feature expects that mention attribute value in the model is a plain object: + const mentionValue = { + // The name attribute is required by mention editing. + name: viewItem.getAttribute( 'data-mention' ), + // Add any other properties as required. + link: viewItem.getAttribute( 'href' ), + id: viewItem.getAttribute( 'data-user-id' ) + }; + + return mentionValue; + } + }, + converterPriority: HIGHER_THEN_HIGHEST + } ); + + function isFullMention( viewElement ) { + const textNode = viewElement.getChild( 0 ); + const dataMention = viewElement.getAttribute( 'data-mention' ); + + // Do not parse empty mentions. + if ( !textNode || !textNode.is( 'text' ) ) { + return false; + } + + const mentionString = textNode.data; + + // Assume that mention is set as marker + mention name. + const name = mentionString.slice( 1 ); + + // Do not upcast partial mentions - might come from copy-paste of partially selected mention. + return name == dataMention; + } + + // Don't forget to define a downcast converter as well: + editor.conversion.for( 'downcast' ).attributeToElement( { + model: 'mention', + view: ( modelAttributeValue, viewWriter ) => { + if ( !modelAttributeValue ) { + // Do not convert empty attributes. + return; + } + + return viewWriter.createAttributeElement( 'a', { + class: 'mention', + 'data-mention': modelAttributeValue.name, + 'data-user-id': modelAttributeValue.id, + 'href': modelAttributeValue.link + } ); + }, + converterPriority: HIGHER_THEN_HIGHEST + } ); +} + +const items = [ + { id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' }, + { id: '2', name: 'Lily Aldrin', username: 'lilypad', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' }, + { id: '3', name: 'Marshall Eriksen', username: 'marshmallow', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' }, + { id: '4', name: 'Robin Scherbatsky', username: 'rsparkles', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' }, + { id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' } +]; + +function getFeedItems( feedText ) { + // As an example of asynchronous action return a promise that resolves after a 100ms timeout. + return new Promise( resolve => { + setTimeout( () => { + resolve( items.filter( isItemMatching ) ); + }, 100 ); + } ); + + // Filtering function - it uses `name` and `username` properties of an item to find a match. + function isItemMatching( item ) { + // Make search case-insensitive. + const searchString = feedText.toLowerCase(); + + // Include an item in the search results if name or username includes the current user input. + return textIncludesSearchSting( item.name, searchString ) || textIncludesSearchSting( item.username, searchString ); + } + + function textIncludesSearchSting( text, searchString ) { + return text.toLowerCase().includes( searchString ); + } +} + +function customItemRenderer( item ) { + const span = document.createElement( 'span' ); + + span.classList.add( 'custom-item' ); + span.id = `mention-list-item-id-${ item.id }`; + + span.innerHTML = `${ item.name } @${ item.username }`; + + return span; +} diff --git a/docs/_snippets/features/mention.html b/docs/_snippets/features/mention.html new file mode 100644 index 0000000..9619a1b --- /dev/null +++ b/docs/_snippets/features/mention.html @@ -0,0 +1,3 @@ +
+

Hello @Ted.

+
diff --git a/docs/_snippets/features/mention.js b/docs/_snippets/features/mention.js new file mode 100644 index 0000000..92944b8 --- /dev/null +++ b/docs/_snippets/features/mention.js @@ -0,0 +1,31 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals ClassicEditor, console, window, document */ + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +ClassicEditor + .create( document.querySelector( '#snippet-mention' ), { + cloudServices: CS_CONFIG, + toolbar: { + items: [ + 'heading', '|', 'bold', 'italic', '|', 'undo', 'redo' + ], + viewportTopOffset: window.getViewportTopOffsetConfig() + }, + mention: [ + { + marker: '@', + feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] + } + ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/docs/api/mention.md b/docs/api/mention.md new file mode 100644 index 0000000..0d41cab --- /dev/null +++ b/docs/api/mention.md @@ -0,0 +1,34 @@ +--- +category: api-reference +--- + +# Mention feature for CKEditor 5 + +[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-mention.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-mention) + +This package implements the mention feature for CKEditor 5. This features provides smart completion functionality for custom text matches based on user input. + +## Demo + +Check out the {@link features/mention#demo demo in the Mention feature} guide. + +## Documentation + +See the {@link features/mention Mention feature} guide and the {@link module:mention/mention~Mention} plugin documentation. + +## Installation + +```bash +npm install --save @ckeditor/ckeditor5-mention +``` + +## Contribute + +The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5-mention. + +## External links + +* [`@ckeditor/ckeditor5-mention` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-mention) +* [`ckeditor/ckeditor5-mention` on GitHub](https://github.com/ckeditor/ckeditor5-mention) +* [Issue tracker](https://github.com/ckeditor/ckeditor5-mention/issues) +* [Changelog](https://github.com/ckeditor/ckeditor5-mention/blob/master/CHANGELOG.md) diff --git a/docs/features/mention.md b/docs/features/mention.md new file mode 100644 index 0000000..5118c20 --- /dev/null +++ b/docs/features/mention.md @@ -0,0 +1,323 @@ +--- +category: features +--- + +{@snippet features/build-mention-source} + +# Mention + +The {@link module:mention/mention~Mention} feature brings support for smart completion based on user input. When user types a pre-configured marker, such as `@` or `#`, they get an autocomplete suggestions in a balloon panel displayed next to the caret. The selected suggestion is then inserted into the content. + +## Demo + +You can type `'@'` character to invoke mention auto-complete UI. The below demo is configured as static list of names. + +{@snippet features/mention} + +## Configuration + +The minimal configuration of a mention requires defining a {@link module:mention/mention~MentionFeed `feed`} and a {@link module:mention/mention~MentionFeed `marker`} (if not using the default `@` character). You can define also `minimumCharacters` after which the auto-complete panel will be shown. + +```js +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ Mention, ... ], + mention: { + feeds: [ + { + marker: '@', + feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ], + minimumCharacters: 1 + } + } + } + } ) + .then( ... ) + .catch( ... ); +``` + +Additionally you can configure: +- How the item is rendered in the auto-complete panel. +- How the item is converted during the conversion. + +### Providing the feed + +The {@link module:mention/mention~MentionFeed `feed`} can be provided as: + +- static array - good for scenarios with relatively small set of auto-complete items. +- a callback - which provides more control over the returned list of items. + +If using a callback you can return a `Promise` that resolves with list of {@link module:mention/mention~MentionFeedItem mention feed items}. Those can be simple stings used as mention text or plain objects with at least one `name` property. The other parameters can be used either when {@link features/mention#customizing-the-auto-complete-list customizing the auto-complete list} {@link features/mention#customizing-the-output customizing the output}. + + +When using external resources to obtain the feed it is recommended to add some caching mechanism so subsequent calls for the same suggestoin would load faster. + + +The callback receives a matched text which should be used to filter item suggestions. It should return a `Promise` and resolve it with an array of items that match to the feed text. + + +Consider adding the `minimumCharacters` option to the feed config so the editor will call the feed callback after a minimum characters typed instead of action on marker alone. + + +```js +const items = [ + { id: '1', name: 'Barney Stinson', username: 'swarley', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' }, + { id: '2', name: 'Lily Aldrin', username: 'lilypad', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' }, + { id: '3', name: 'Marshall Eriksen', username: 'marshmallow', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' }, + { id: '4', name: 'Robin Scherbatsky', username: 'rsparkles', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' }, + { id: '5', name: 'Ted Mosby', username: 'tdog', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' } +]; + +function getFeedItems( feedText ) { + // As an example of asynchronous action return a promise that resolves after a 100ms timeout. + return new Promise( resolve => { + setTimeout( () => { + const itemsToDisplay = items + // Filter out the full list of all items to only those matching feedText. + .filter( isItemMatching ) + // Return at most 10 items - notably for generic queries when the list may contain hundreds of elements. + .slice( 0, 10 ); + + resolve( itemsToDisplay ); + }, 100 ); + } ); + + // Filtering function - it uses `name` and `username` properties of an item to find a match. + function isItemMatching( item ) { + // Make search case-insensitive. + const searchString = feedText.toLowerCase(); + + // Include an item in the search results if name or username includes the current user input. + return textIncludesSearchSting( item.name, searchString ) || textIncludesSearchSting( item.username, searchString ); + } + + function textIncludesSearchSting( text, searchString ) { + return text.toLowerCase().includes( searchString ); + } +} +``` + +The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed at the end of this section}. + + +The mention feature does not limit items displayed in the mention suggestion list when using the callback. You should limit the output by yourself. + + +### Customizing the auto-complete list + +The items displayed in auto-complete list can be customized by defining the {@link module:mention/mention~MentionFeed `itemRenderer`} callback. + +This callback takes a plain object feed item (at least with `name` parameter - even when feed items are defined as strings). The item renderer function must return a new DOM element. + +```js +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ Mention, ... ], + mention: { + feeds: [ + { + feed: [ ... ], + // Define the custom item renderer: + itemRenderer: customItemRenderer + } + ] + } + } ) + .then( ... ) + .catch( ... ); + +function customItemRenderer( item ) { + const span = document.createElement( 'span' ); + + span.classList.add( 'custom-item' ); + span.id = `mention-list-item-id-${ item.id }`; + + // Add child nodes to the main span or just set innerHTML. + span.innerHTML = `${ item.name } @${ item.username }`; + + return span; +} +``` + +The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed at the end of this section}. + +### Customizing the output + +In order to have full control over the markup generated by the editor you can overwrite the conversion process. To do that you must specify both {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher upcast} and {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast} converters. + +Below is an example of a plugin that overrides the default output: + +```html +@Ted +``` + +To a link: + +```html +
@Ted Mosby +``` + +The below converters must have priority higher then link attribute converter. The mention item in the model must be stored as a plain object with `name` attribute. + +```js +import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; + +// The link plugin using highest priority in conversion pipeline. +const HIGHER_THEN_HIGHEST = priorities.highest + 50; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ Mention, CustomMention, ... ], // Add custom mention plugin function. + mention: { + // configuration... + } + } ) + .then( ... ) + .catch( ... ); + +function CustomMention( editor ) { + // The upcast converter will convert elements to the model 'mention' attribute. + editor.conversion.for( 'upcast' ).elementToAttribute( { + view: { + name: 'a', + key: 'data-mention', + classes: 'mention', + attributes: { + href: true, + 'data-user-id': true + } + }, + model: { + key: 'mention', + value: viewItem => { + // Optionally: do not convert partial mentions. + if ( !isFullMention( viewItem ) ) { + return; + } + + // The mention feature expects that mention attribute value in the model is a plain object: + const mentionValue = { + // The name attribute is required by mention editing. + name: viewItem.getAttribute( 'data-mention' ), + // Add any other properties as required. + link: viewItem.getAttribute( 'href' ), + id: viewItem.getAttribute( 'data-user-id' ) + }; + + return mentionValue; + } + }, + converterPriority: HIGHER_THEN_HIGHEST + } ); + + function isFullMention( viewElement ) { + const textNode = viewElement.getChild( 0 ); + const dataMention = viewElement.getAttribute( 'data-mention' ); + + // Do not parse empty mentions. + if ( !textNode || !textNode.is( 'text' ) ) { + return false; + } + + const mentionString = textNode.data; + + // Assume that mention is set as marker + mention name. + const name = mentionString.slice( 1 ); + + // Do not upcast partial mentions - might come from copy-paste of partially selected mention. + return name == dataMention; + } + + // Don't forget to define a downcast converter as well: + editor.conversion.for( 'downcast' ).attributeToElement( { + model: 'mention', + view: ( modelAttributeValue, viewWriter ) => { + if ( !modelAttributeValue ) { + // Do not convert empty attributes. + return; + } + + return viewWriter.createAttributeElement( 'a', { + class: 'mention', + 'data-mention': modelAttributeValue.name, + 'data-user-id': modelAttributeValue.id, + 'href': modelAttributeValue.link + } ); + }, + converterPriority: HIGHER_THEN_HIGHEST + } ); +} +``` + +The full working demo with all customization possible is {@link features/mention#fully-customized-mention-feed at the end of this section}. + +# Fully customized mention feed + +Below is an example of a customized mention feature that: + +- Returns a feed of items with extended properties. +- Renders custom DOM view in auto-complete suggestion in panel view. +- Converts mention to an `` element instead of ``. + +{@snippet features/mention-customization} + +### Colors and styles + +#### Using CSS variables + +The mention feature is using the power of [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables) which are defined in the [theme lark stylesheet](https://github.com/ckeditor/ckeditor5-theme-lark/blob/master/theme/ckeditor5-mention/mentionediting.css). Thanks to that mention styles can be easily customized: + +```css +:root { + /* Make mention background blue. */ + --ck-color-mention-background: hsla(220, 100%, 54%, 0.4); + + /* Make mention text dark grey. */ + --ck-color-mention-text: hsl(0, 0%, 15%); +} +``` + +{@snippet features/custom-mention-colors-variables} + +## Installation + + + This feature is enabled by default in all builds. The installation instructions are for developers interested in building their own, custom editor. + + +To add this feature to your editor, install the [`@ckeditor/ckeditor5-mention`](https://www.npmjs.com/package/@ckeditor/ckeditor5-mention) package: + +```bash +npm install --save @ckeditor/ckeditor5-mention +``` + +Then add `Mention` to your plugin list and {@link module:mention/mention~MentionConfig configure} the feature (if needed): + +```js +import Mention from '@ckeditor/ckeditor5-mention/src/mention'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ Mention, ... ], + mention: { + // configuration... + } + } ) + .then( ... ) + .catch( ... ); +``` + +## Common API + +The {@link module:mention/mention~Mention} plugin registers: +* the `'mention'` command implemented by {@link module:mention/mentioncommand~MentionCommand}. + + You can insert a mention element by executing the following code: + + ```js + editor.execute( 'mention', { marker: '@', mention: 'John' } ); + ``` + +## Contribute + +The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5-mention. diff --git a/package.json b/package.json index a41fb4b..da9860c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "ckeditor5-plugin" ], "dependencies": { - "@ckeditor/ckeditor5-core": "^12.0.0" + "@ckeditor/ckeditor5-core": "^12.0.0", + "@ckeditor/ckeditor5-ui": "^12.0.0", + "@ckeditor/ckeditor5-utils": "^12.0.0" }, "devDependencies": { "@ckeditor/ckeditor5-basic-styles": "^11.0.0", @@ -20,8 +22,11 @@ "@ckeditor/ckeditor5-enter": "^11.0.0", "@ckeditor/ckeditor5-heading": "^11.0.0", "@ckeditor/ckeditor5-link": "^11.0.0", + "@ckeditor/ckeditor5-paragraph": "^11.0.0", + "@ckeditor/ckeditor5-table": "^12.0.0", "@ckeditor/ckeditor5-typing": "^12.0.0", "@ckeditor/ckeditor5-undo": "^11.0.0", + "@ckeditor/ckeditor5-widget": "^11.0.0", "eslint": "^5.5.0", "eslint-config-ckeditor5": "^1.0.11", "husky": "^1.3.1", diff --git a/src/mention.js b/src/mention.js new file mode 100644 index 0000000..600dd39 --- /dev/null +++ b/src/mention.js @@ -0,0 +1,115 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/mention + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +import MentionEditing from './mentionediting'; +import MentionUI from './mentionui'; + +/** + * The mention plugin. + * + * For a detailed overview, check the {@glink features/mention Mention feature documentation}. + * + * @extends module:core/plugin~Plugin + */ +export default class Mention extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'Mention'; + } + + /** + * @inheritDoc + */ + static get requires() { + return [ MentionEditing, MentionUI ]; + } +} + +/** + * The configuration of the {@link module:mention/mention~Mention} feature. + * + * Read more in {@link module:mention/mention~MentionConfig}. + * + * @member {module:mention/mention~MentionConfig} module:core/editor/editorconfig~EditorConfig#mention + * @type {Array.} + */ + +/** + * The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}. + * + * See {@link module:mention/mention~MentionConfig} to learn more. + * + * const mentionFeed = { + * marker: '@', + * feed: [ 'Alice', 'Bob', ... ] + * } + * + * @typedef {Object} module:mention/mention~MentionFeed + * @property {String} [marker=''] The character which triggers auto-completion for mention. + * @property {Array.|Function} feed The auto complete feed items. Provide an array for + * static configuration or a function that returns a promise for asynchronous feeds. + * @property {Number} [minimumCharacters=0] Specifies after how many characters show the autocomplete panel. + * @property {Function} [itemRenderer] Function that renders {@link module:mention/mention~MentionFeedItem} + * to the autocomplete list to a DOM element. + */ + +/** + * The mention feed item. In configuration might be defined as string or a plain object. The strings will be used as `name` property + * when converting to an object in the model. + * + * *Note* When defining feed item as a plain object you must provide the at least the `name` property. + * + * @typedef {Object|String} module:mention/mention~MentionFeedItem + * @property {String} name Name of the mention. + */ + +/** + * The list fo mention feeds supported by the editor. + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ Mention, ... ], + * mention: { + * feeds: [ + * { + * marker: '@', + * feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * You can provide as many mention feeds but they must have different `marker` defined. + * + * @member {Array.} module:mention/mention~MentionConfig#feeds + */ + +/** + * The configuration of the mention features. + * + * Read more about {@glink features/mention#configuration configuring the mention feature}. + * + * ClassicEditor + * .create( editorElement, { + * mention: ... // Media embed feature options. + * } ) + * .then( ... ) + * .catch( ... ); + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + * + * @interface MentionConfig + */ diff --git a/src/mentioncommand.js b/src/mentioncommand.js new file mode 100644 index 0000000..41d9472 --- /dev/null +++ b/src/mentioncommand.js @@ -0,0 +1,86 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/mentioncommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import toMap from '@ckeditor/ckeditor5-utils/src/tomap'; +import uid from '@ckeditor/ckeditor5-utils/src/uid'; + +/** + * The mention command. + * + * The command is registered by the {@link module:mention/mentionediting~MentionEditing} as `'mention'`. + * + * To insert a mention on a range, execute the command and specify a mention object and a range to replace: + * + * const focus = editor.model.document.selection.focus; + * + * editor.execute( 'mention', { + * mention: { + * name: 'Foo', + * id: '1234', + * title: 'Big Foo' + * }, + * marker: '#', + * range: model.createRange( focus, focus.getShiftedBy( -1 ) ) + * } ); + * + * @extends module:core/command~Command + */ +export default class MentionCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const doc = model.document; + + this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'mention' ); + } + + /** + * Executes the command. + * + * @param {Object} [options] Options for the executed command. + * @param {Object|String} options.mention Mention object to insert. If passed a string it will be used to create a plain object with + * name attribute equal to passed string. + * @param {String} [options.marker='@'] The mention marker to insert. + * @param {String} [options.range] Range to replace. Note that replace range might be shorter then inserted text with mention attribute. + * @fires execute + */ + execute( options ) { + const model = this.editor.model; + const document = model.document; + const selection = document.selection; + + const marker = options.marker || '@'; + + const mention = typeof options.mention == 'string' ? { name: options.mention } : options.mention; + + // Set internal attributes on mention object. + mention._id = uid(); + mention._marker = marker; + + const range = options.range || selection.getFirstRange(); + + model.change( writer => { + const currentAttributes = toMap( selection.getAttributes() ); + const attributesWithMention = new Map( currentAttributes.entries() ); + attributesWithMention.set( 'mention', mention ); + + const mentionText = `${ marker }${ mention.name }`; + + // Replace range with a text with mention. + writer.remove( range ); + writer.insertText( mentionText, attributesWithMention, range.start ); + + // Insert space after a mention. + writer.insertText( ' ', currentAttributes, model.document.selection.focus ); + } ); + } +} diff --git a/src/mentionediting.js b/src/mentionediting.js new file mode 100644 index 0000000..a821e92 --- /dev/null +++ b/src/mentionediting.js @@ -0,0 +1,180 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/mentionediting + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import uid from '@ckeditor/ckeditor5-utils/src/uid'; + +import MentionCommand from './mentioncommand'; + +import '../theme/mentionediting.css'; + +/** + * The mention editing feature. + * + * It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention` + * attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view} + * as a ``. + * + * @extends module:core/plugin~Plugin + */ +export default class MentionEditing extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'MentionEditing'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const model = editor.model; + const doc = model.document; + + // Allow mention attribute on all text nodes. + model.schema.extend( '$text', { allowAttributes: 'mention' } ); + + editor.conversion.for( 'upcast' ).elementToAttribute( { + view: { + name: 'span', + key: 'data-mention', + classes: 'mention' + }, + model: { + key: 'mention', + value: parseMentionViewItemAttributes + } + } ); + + editor.conversion.for( 'downcast' ).attributeToElement( { + model: 'mention', + view: createViewMentionElement + } ); + + doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc ) ); + doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) ); + + editor.commands.add( 'mention', new MentionCommand( editor ) ); + } +} + +// Parses matched view element to mention attribute value. +// +// @param {module:engine/view/element} viewElement +// @returns {Object} Mention attribute value +function parseMentionViewItemAttributes( viewElement ) { + const dataMention = viewElement.getAttribute( 'data-mention' ); + + const textNode = viewElement.getChild( 0 ); + + // Do not parse empty mentions. + if ( !textNode || !textNode.is( 'text' ) ) { + return; + } + + const mentionString = textNode.data; + + // Assume that mention is set as marker + mention name. + const marker = mentionString.slice( 0, 1 ); + const name = mentionString.slice( 1 ); + + // Do not upcast partial mentions - might come from copy-paste of partially selected mention. + if ( name != dataMention ) { + return; + } + + // Set UID for mention to not merge mentions in the same block that are next to each other. + return { name: dataMention, _marker: marker, _id: uid() }; +} + +// Creates mention element from mention data. +// +// @param {Object} mention +// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter +// @returns {module:engine/view/attributeelement~AttributeElement} +function createViewMentionElement( mention, viewWriter ) { + if ( !mention ) { + return; + } + + const attributes = { + class: 'mention', + 'data-mention': mention.name + }; + + const options = { + id: mention._id + }; + + return viewWriter.createAttributeElement( 'span', attributes, options ); +} + +// Model post-fixer that disallows typing with selection when selection is placed after the text node with mention attribute. +// +// @param {module:engine/model/writer~Writer} writer +// @param {module:engine/model/document~Document} doc +// @returns {Boolean} Returns true if selection was fixed. +function selectionMentionAttributePostFixer( writer, doc ) { + const selection = doc.selection; + const focus = selection.focus; + + if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && isNodeBeforeAText( focus ) ) { + writer.removeSelectionAttribute( 'mention' ); + + return true; + } + + function isNodeBeforeAText( position ) { + return position.nodeBefore && position.nodeBefore.is( 'text' ); + } +} + +// Model post-fixer that removes mention attribute from modified text node. +// +// @param {module:engine/model/writer~Writer} writer +// @param {module:engine/model/document~Document} doc +// @returns {Boolean} Returns true if selection was fixed. +function removePartialMentionPostFixer( writer, doc ) { + const changes = doc.differ.getChanges(); + + let wasChanged = false; + + for ( const change of changes ) { + // Check if user edited part of a mention. + if ( change.type == 'insert' || change.type == 'remove' ) { + const textNode = change.position.textNode; + + if ( change.name == '$text' && textNode && textNode.hasAttribute( 'mention' ) ) { + writer.removeAttribute( 'mention', textNode ); + wasChanged = true; + } + } + + // Additional check for deleting last character of a text node. + if ( change.type == 'remove' ) { + const nodeBefore = change.position.nodeBefore; + + if ( nodeBefore && nodeBefore.hasAttribute( 'mention' ) ) { + const text = nodeBefore.data; + const mention = nodeBefore.getAttribute( 'mention' ); + + const expectedText = mention._marker + mention.name; + + if ( text != expectedText ) { + writer.removeAttribute( 'mention', nodeBefore ); + wasChanged = true; + } + } + } + } + + return wasChanged; +} diff --git a/src/mentionui.js b/src/mentionui.js new file mode 100644 index 0000000..7d93542 --- /dev/null +++ b/src/mentionui.js @@ -0,0 +1,493 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/mentionui + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import Collection from '@ckeditor/ckeditor5-utils/src/collection'; +import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview'; +import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; + +import TextWatcher from './textwatcher'; + +import MentionsView from './ui/mentionsview'; +import DomWrapperView from './ui/domwrapperview'; +import MentionListItemView from './ui/mentionlistitemview'; + +const VERTICAL_SPACING = 5; + +/** + * The mention UI feature. + * + * @extends module:core/plugin~Plugin + */ +export default class MentionUI extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'MentionUI'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + /** + * The balloon panel view, containing the mention view. + * + * @type {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} + */ + this.panelView = this._creatPanelView(); + + /** + * The mentions view. + * + * @type {module:mention/ui/mentionsview~MentionsView} + * @private + */ + this._mentionsView = this._createMentionView(); + + /** + * Stores mentions feeds configurations. + * + * @type {Map} + * @private + */ + this._mentionsConfigurations = new Map(); + + editor.config.define( 'mention', { feeds: [] } ); + } + + /** + * @inheritDoc + */ + init() { + // Key listener that handles navigation in mention view. + this.editor.editing.view.document.on( 'keydown', ( evt, data ) => { + if ( isHandledKey( data.keyCode ) && this.panelView.isVisible ) { + data.preventDefault(); + evt.stop(); // Required for enter overriding. + + if ( data.keyCode == keyCodes.arrowdown ) { + this._mentionsView.selectNext(); + } + + if ( data.keyCode == keyCodes.arrowup ) { + this._mentionsView.selectPrevious(); + } + + if ( data.keyCode == keyCodes.enter || data.keyCode == keyCodes.tab || data.keyCode == keyCodes.space ) { + this._mentionsView.executeSelected(); + } + + if ( data.keyCode == keyCodes.esc ) { + this._hidePanel(); + } + } + }, { priority: 'highest' } ); // priority highest required for enter overriding. + + // Close the #panelView upon clicking outside of the plugin UI. + clickOutsideHandler( { + emitter: this.panelView, + contextElements: [ this.panelView.element ], + activator: () => this.panelView.isVisible, + callback: () => this._hidePanel() + } ); + + const feeds = this.editor.config.get( 'mention.feeds' ); + + for ( const mentionDescription of feeds ) { + const feed = mentionDescription.feed; + + const marker = mentionDescription.marker || '@'; + const minimumCharacters = mentionDescription.minimumCharacters || 0; + const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed ); + const watcher = this._setupTextWatcherForFeed( marker, minimumCharacters ); + const itemRenderer = mentionDescription.itemRenderer; + + const definition = { watcher, marker, feedCallback, itemRenderer }; + + this._mentionsConfigurations.set( marker, definition ); + } + } + + /** + * @inheritDoc + */ + destroy() { + super.destroy(); + + // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). + this.panelView.destroy(); + } + + /** + * Creates the {@link #panelView}. + * + * @private + * @returns {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} + */ + _creatPanelView() { + const panelView = new BalloonPanelView( this.editor.locale ); + + panelView.withArrow = false; + panelView.render(); + + this.editor.ui.view.body.add( panelView ); + + return panelView; + } + + /** + * Creates the {@link #_mentionsView}. + * + * @private + * @returns {module:mention/ui/mentionsview~MentionsView} + */ + _createMentionView() { + const locale = this.editor.locale; + + const mentionsView = new MentionsView( locale ); + + this._items = new Collection(); + + this.panelView.content.add( mentionsView ); + + mentionsView.listView.items.bindTo( this._items ).using( data => { + const { item, marker } = data; + + const listItemView = new MentionListItemView( locale ); + + const view = this._renderItem( item, marker ); + view.delegate( 'execute' ).to( listItemView ); + + listItemView.children.add( view ); + listItemView.item = item; + listItemView.marker = marker; + + listItemView.on( 'execute', () => { + mentionsView.fire( 'execute', { + item, + marker + } ); + } ); + + return listItemView; + } ); + + mentionsView.on( 'execute', ( evt, data ) => { + const editor = this.editor; + const model = editor.model; + + const item = data.item; + const marker = data.marker; + + const watcher = this._getWatcher( marker ); + + const text = watcher.last; + + const textMatcher = createTextMatcher( marker ); + const matched = textMatcher( text ); + const matchedTextLength = matched.marker.length + matched.feedText.length; + + // Create a range on matched text. + const end = model.createPositionAt( model.document.selection.focus ); + const start = end.getShiftedBy( -matchedTextLength ); + const range = model.createRange( start, end ); + + editor.execute( 'mention', { + mention: item, + marker, + range + } ); + + this._hidePanel(); + } ); + + return mentionsView; + } + + /** + * Returns item renderer for marker. + * + * @private + * @param {String} marker + * @returns {Function|null} + */ + _getItemRenderer( marker ) { + const { itemRenderer } = this._mentionsConfigurations.get( marker ); + + return itemRenderer; + } + + /** + * Returns a promise that resolves with autocomplete items for given text. + * + * @param {String} marker + * @param {String} feedText + * @return {Promise} + * @private + */ + _getFeed( marker, feedText ) { + const { feedCallback } = this._mentionsConfigurations.get( marker ); + + return Promise.resolve().then( () => feedCallback( feedText ) ); + } + + /** + * Registers a text watcher for marker. + * + * @private + * @param {String} marker + * @param {Number} minimumCharacters + * @returns {module:mention/textwatcher~TextWatcher} + */ + _setupTextWatcherForFeed( marker, minimumCharacters ) { + const editor = this.editor; + + const watcher = new TextWatcher( editor, createTestCallback( marker, minimumCharacters ), createTextMatcher( marker ) ); + + watcher.on( 'matched', ( evt, data ) => { + const matched = data.matched; + + const selection = editor.model.document.selection; + + const hasMention = selection.hasAttribute( 'mention' ); + const nodeBefore = selection.focus.nodeBefore; + + if ( hasMention || nodeBefore && nodeBefore.is( 'text' ) && nodeBefore.hasAttribute( 'mention' ) ) { + return; + } + + const { feedText, marker } = matched; + + this._getFeed( marker, feedText ) + .then( feed => { + this._items.clear(); + + for ( const name of feed ) { + const item = typeof name != 'object' ? { name } : name; + + this._items.add( { item, marker } ); + } + + if ( this._items.length ) { + this._showPanel(); + } else { + this._hidePanel(); + } + } ); + } ); + + watcher.on( 'unmatched', () => { + this._hidePanel(); + } ); + + return watcher; + } + + /** + * Returns registered text watcher for marker. + * + * @private + * @param {String} marker + * @returns {module:mention/textwatcher~TextWatcher} + */ + _getWatcher( marker ) { + const { watcher } = this._mentionsConfigurations.get( marker ); + + return watcher; + } + + /** + * Shows the {@link #panelView}. If panel is already visible it will reposition it. + * + * @private + */ + _showPanel() { + this.panelView.pin( this._getBalloonPanelPositionData() ); + this.panelView.show(); + this._mentionsView.selectFirst(); + } + + /** + * Hides the {@link #panelView}. + * + * @private + */ + _hidePanel() { + this.panelView.unpin(); + this.panelView.hide(); + } + + /** + * Renders a single item in the autocomplete list. + * + * @private + * @param {module:mention/mention~MentionFeedItem} item + * @param {String} marker + * @returns {module:ui/button/buttonview~ButtonView|module:mention/ui/domwrapperview~DomWrapperView} + */ + _renderItem( item, marker ) { + const editor = this.editor; + + let view; + + const renderer = this._getItemRenderer( marker ); + + if ( renderer ) { + const domNode = renderer( item ); + + view = new DomWrapperView( editor.locale, domNode ); + } else { + const buttonView = new ButtonView( editor.locale ); + + buttonView.label = item.name; + buttonView.withText = true; + + view = buttonView; + } + + return view; + } + + /** + * @returns {module:utils/dom/position~Options} + * @private + */ + _getBalloonPanelPositionData() { + const view = this.editor.editing.view; + const domConverter = view.domConverter; + const viewSelection = view.document.selection; + + return { + target: () => { + const range = viewSelection.getLastRange(); + const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( range ) ); + + return rangeRects.pop(); + }, + positions: getBalloonPanelPositions() + }; + } +} + +// Returns balloon positions data callbacks. +// +// @returns {Array.} +function getBalloonPanelPositions() { + return [ + // Positions panel to the south of caret rect. + targetRect => { + return { + top: targetRect.bottom + VERTICAL_SPACING, + left: targetRect.right, + name: 'caret_se' + }; + }, + + // Positions panel to the north of caret rect. + ( targetRect, balloonRect ) => { + return { + top: targetRect.top - balloonRect.height - VERTICAL_SPACING, + left: targetRect.right, + name: 'caret_ne' + }; + }, + + // Positions panel to the south of caret rect. + ( targetRect, balloonRect ) => { + return { + top: targetRect.bottom + VERTICAL_SPACING, + left: targetRect.right - balloonRect.width, + name: 'caret_sw' + }; + }, + + // Positions panel to the north of caret rect. + ( targetRect, balloonRect ) => { + return { + top: targetRect.top - balloonRect.height - VERTICAL_SPACING, + left: targetRect.right - balloonRect.width, + name: 'caret_nw' + }; + } + ]; +} + +// Creates a regex pattern for marker. +// +// @param {String} marker +// @param {Number} minimumCharacters +// @returns {String} +function createPattern( marker, minimumCharacters ) { + const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${ minimumCharacters },}`; + + return `(^| )(${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`; +} + +// Creates a test callback for marker to be used in text watcher instance. +// +// @param {String} marker +// @param {Number} minimumCharacters +// @returns {Function} +function createTestCallback( marker, minimumCharacters ) { + const regExp = new RegExp( createPattern( marker, minimumCharacters ) ); + + return text => regExp.test( text ); +} + +// Creates a text watcher matcher for marker. +// +// @param {String} marker +// @returns {Function} +function createTextMatcher( marker ) { + const regExp = new RegExp( createPattern( marker, 0 ) ); + + return text => { + const match = text.match( regExp ); + + const marker = match[ 2 ]; + const feedText = match[ 3 ]; + + return { marker, feedText }; + }; +} + +// Default feed callback +function createFeedCallback( feedItems ) { + return feedText => { + const filteredItems = feedItems.filter( item => { + return item.toLowerCase().includes( feedText.toLowerCase() ); + } ); + + return Promise.resolve( filteredItems ); + }; +} + +// Checks if given key code is handled by the mention ui. +// +// @param {Number} +// @returns {Boolean} +function isHandledKey( keyCode ) { + const handledKeyCodes = [ + keyCodes.arrowup, + keyCodes.arrowdown, + keyCodes.enter, + keyCodes.tab, + keyCodes.space, + keyCodes.esc + ]; + + return handledKeyCodes.includes( keyCode ); +} diff --git a/src/textwatcher.js b/src/textwatcher.js new file mode 100644 index 0000000..4f13959 --- /dev/null +++ b/src/textwatcher.js @@ -0,0 +1,118 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/textwatcher + */ + +import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; + +/** + * Text watcher feature. + * @private + */ +export default class TextWatcher { + /** + * Creates a text watcher instance. + * @param {module:core/editor/editor~Editor} editor + * @param {Function} testCallback Function used to match the text. + * @param {Function} textMatcherCallback Function used to process matched text. + */ + constructor( editor, testCallback, textMatcherCallback ) { + this.editor = editor; + this.testCallback = testCallback; + this.textMatcher = textMatcherCallback; + + this.hasMatch = false; + + this._startListening(); + } + + /** + * Last matched text. + * + * @property {String} + */ + get last() { + return this._getText(); + } + + /** + * Starts listening the editor for typing & selection events. + * + * @private + */ + _startListening() { + const editor = this.editor; + + editor.model.document.on( 'change', ( evt, batch ) => { + if ( batch.type == 'transparent' ) { + return; + } + + const changes = Array.from( editor.model.document.differ.getChanges() ); + const entry = changes[ 0 ]; + + // Typing is represented by only a single change. + const isTypingChange = changes.length == 1 && entry.name == '$text' && entry.length == 1; + // Selection is represented by empty changes. + const isSelectionChange = changes.length == 0; + + if ( !isTypingChange && !isSelectionChange ) { + return; + } + + const text = this._getText(); + + const textHasMatch = this.testCallback( text ); + + if ( !textHasMatch && this.hasMatch ) { + this.fire( 'unmatched' ); + } + + this.hasMatch = textHasMatch; + + if ( textHasMatch ) { + const matched = this.textMatcher( text ); + + this.fire( 'matched', { text, matched } ); + } + } ); + } + + /** + * Returns the text before the caret from the current selection block. + * + * @returns {String|undefined} Text from block or undefined if selection is not collapsed. + * @private + */ + _getText() { + const editor = this.editor; + const selection = editor.model.document.selection; + + // Do nothing if selection is not collapsed. + if ( !selection.isCollapsed ) { + return; + } + + const block = selection.focus.parent; + + return getText( block ).slice( 0, selection.focus.offset ); + } +} + +// Returns whole text from parent element by adding all data from text nodes together. +// @todo copied from autoformat... + +// @private +// @param {module:engine/model/element~Element} element +// @returns {String} +function getText( element ) { + return Array.from( element.getChildren() ).reduce( ( a, b ) => a + b.data, '' ); +} + +mix( TextWatcher, EmitterMixin ); + diff --git a/src/ui/domwrapperview.js b/src/ui/domwrapperview.js new file mode 100644 index 0000000..00e6537 --- /dev/null +++ b/src/ui/domwrapperview.js @@ -0,0 +1,76 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/ui/domwrapperview + */ + +import View from '@ckeditor/ckeditor5-ui/src/view'; + +/** + * This class wraps DOM element as a CKEditor5 UI View. + * + * It allows to render any DOM element and use it in mentions list. + */ +export default class DomWrapperView extends View { + /** + * Creates an instance of {@link module:mention/ui/domwrapperview~DomWrapperView} class. + * + * Also see {@link #render}. + * + * @param {module:utils/locale~Locale} [locale] The localization services instance. + * @param {Element} domElement + */ + constructor( locale, domElement ) { + super( locale ); + + // Disable template rendering on this view. + this.template = false; + + /** + * The DOM element for which wrapper was created. + * + * @type {Element} + */ + this.domElement = domElement; + + // Render dom wrapper as a button. + this.domElement.classList.add( 'ck-button' ); + + /** + * Controls whether the dom wrapper view is "on". This is in line with {@link module:ui/button/button~Button#isOn} property. + * + * @observable + * @default true + * @member {Boolean} #isOn + */ + this.set( 'isOn', false ); + + // Handle isOn state as in buttons. + this.on( 'change:isOn', ( evt, name, isOn ) => { + if ( isOn ) { + this.domElement.classList.add( 'ck-on' ); + this.domElement.classList.remove( 'ck-off' ); + } else { + this.domElement.classList.add( 'ck-off' ); + this.domElement.classList.remove( 'ck-on' ); + } + } ); + + // Pass click event as execute event. + this.listenTo( this.domElement, 'click', () => { + this.fire( 'execute' ); + } ); + } + + /** + * @inheritDoc + */ + render() { + super.render(); + + this.element = this.domElement; + } +} diff --git a/src/ui/mentionlistitemview.js b/src/ui/mentionlistitemview.js new file mode 100644 index 0000000..8bfffc2 --- /dev/null +++ b/src/ui/mentionlistitemview.js @@ -0,0 +1,24 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/ui/mentionlistitemview + */ + +import ListItemView from '@ckeditor/ckeditor5-ui/src/list/listitemview'; + +export default class MentionListItemView extends ListItemView { + highlight() { + const child = this.children.first; + + child.isOn = true; + } + + removeHighlight() { + const child = this.children.first; + + child.isOn = false; + } +} diff --git a/src/ui/mentionsview.js b/src/ui/mentionsview.js new file mode 100644 index 0000000..2431925 --- /dev/null +++ b/src/ui/mentionsview.js @@ -0,0 +1,84 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module mention/ui/mentionsview + */ + +import View from '@ckeditor/ckeditor5-ui/src/view'; +import ListView from '@ckeditor/ckeditor5-ui/src/list/listview'; + +/** + * The mention ui view. + * + * @extends module:ui/view~View + */ +export default class MentionsView extends View { + constructor( locale ) { + super( locale ); + + this.listView = new ListView( locale ); + + this.setTemplate( { + tag: 'div', + + attributes: { + class: [ + 'ck', + 'ck-mention' + ], + + tabindex: '-1' + }, + + children: [ + this.listView + ] + } ); + } + + selectFirst() { + this.select( 0 ); + } + + selectNext() { + const item = this.selected; + + const index = this.listView.items.getIndex( item ); + + this.select( index + 1 ); + } + + selectPrevious() { + const item = this.selected; + + const index = this.listView.items.getIndex( item ); + + this.select( index - 1 ); + } + + select( index ) { + let indexToGet = 0; + + if ( index > 0 && index < this.listView.items.length ) { + indexToGet = index; + } else if ( index < 0 ) { + indexToGet = this.listView.items.length - 1; + } + + const item = this.listView.items.get( indexToGet ); + item.highlight(); + + if ( this.selected ) { + this.selected.removeHighlight(); + } + + this.selected = item; + } + + executeSelected() { + this.selected.fire( 'execute' ); + } +} diff --git a/tests/manual/mention-custom-renderer.html b/tests/manual/mention-custom-renderer.html new file mode 100644 index 0000000..8def77d --- /dev/null +++ b/tests/manual/mention-custom-renderer.html @@ -0,0 +1,22 @@ +
+

Have you met...

+
+ + diff --git a/tests/manual/mention-custom-renderer.js b/tests/manual/mention-custom-renderer.js new file mode 100644 index 0000000..edff0ba --- /dev/null +++ b/tests/manual/mention-custom-renderer.js @@ -0,0 +1,67 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global console, window */ + +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Typing from '@ckeditor/ckeditor5-typing/src/typing'; +import Undo from '@ckeditor/ckeditor5-undo/src/undo'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import Mention from '../../src/mention'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline'; + +ClassicEditor + .create( global.document.querySelector( '#editor' ), { + plugins: [ Enter, Typing, Paragraph, Heading, Link, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table, Mention ], + toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ], + mention: { + feeds: [ + { + feed: getFeed, + itemRenderer: item => { + const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' ); + + span.classList.add( 'custom-item' ); + span.id = `mention-list-item-id-${ item.id }`; + + span.innerHTML = `${ item.name } @${ item.username }`; + + return span; + } + } + ] + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +function getFeed( feedText ) { + return Promise.resolve( [ + { id: '1', name: 'Barney Stinson', username: 'swarley' }, + { id: '2', name: 'Lily Aldrin', username: 'lilypad' }, + { id: '3', name: 'Marshall Eriksen', username: 'marshmallow' }, + { id: '4', name: 'Robin Scherbatsky', username: 'rsparkles' }, + { id: '5', name: 'Ted Mosby', username: 'tdog' } + ].filter( item => { + const searchString = feedText.toLowerCase(); + + return item.name.toLowerCase().includes( searchString ) || item.username.toLowerCase().includes( searchString ); + } ) ); +} diff --git a/tests/manual/mention-custom-renderer.md b/tests/manual/mention-custom-renderer.md new file mode 100644 index 0000000..97f0509 --- /dev/null +++ b/tests/manual/mention-custom-renderer.md @@ -0,0 +1,37 @@ +## Mention + +The mention configuration with custom item renderer for autocomplete list. + +### Configuration + +The list is returned in promise (no timeout) and is filtered for any match of `name` and `username` (custom feed): + +The feed: +- `{ id: '1', name: 'Barney Stinson', username: 'swarley' }` +- `{ id: '2', name: 'Lily Aldrin', username: 'lilypad' }` +- `{ id: '3', name: 'Marshall Eriksen', username: 'marshmallow' }` +- `{ id: '4', name: 'Robin Scherbatsky', username: 'rsparkles' }` +- `{ id: '5', name: 'Ted Mosby', username: 'tdog' }` + +The item is rendered as `` instead of default button. + +### Interaction + +You can interact with mention panel with keyboard: + +- Move arrows up/down to select an item. +- Use enter or tab to insert a mention into the documentation. +- The esc should close the panel. + +Mention panel should be closed on: +- Click outside the panel view. +- Changing selection - like placing it in other part of text. + +### Editing behavior: + +The mention should be removed from the text when: + +- typing inside a mention +- removing characters from a mention +- breaking the mention (enter) +- pasting part of a mention diff --git a/tests/manual/mention-custom-view.html b/tests/manual/mention-custom-view.html new file mode 100644 index 0000000..8aa2a06 --- /dev/null +++ b/tests/manual/mention-custom-view.html @@ -0,0 +1,24 @@ +
+

Have you met... @Ted Mosby

+ +

Same mention twice in data: @Ted Mosby@Ted Mosby

+
+ + diff --git a/tests/manual/mention-custom-view.js b/tests/manual/mention-custom-view.js new file mode 100644 index 0000000..55bcf6f --- /dev/null +++ b/tests/manual/mention-custom-view.js @@ -0,0 +1,107 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global console, window */ + +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Typing from '@ckeditor/ckeditor5-typing/src/typing'; +import Undo from '@ckeditor/ckeditor5-undo/src/undo'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; + +import MentionUI from '../../src/mentionui'; +import MentionEditing from '../../src/mentionediting'; + +const HIGHER_THEN_HIGHEST = priorities.highest + 50; + +class CustomMentionAttributeView extends Plugin { + init() { + const editor = this.editor; + + editor.conversion.for( 'upcast' ).elementToAttribute( { + view: { + name: 'a', + key: 'data-mention', + classes: 'mention', + attributes: { + href: true + } + }, + model: { + key: 'mention', + value: viewItem => { + const mentionValue = { + name: viewItem.getAttribute( 'data-mention' ), + link: viewItem.getAttribute( 'href' ) + }; + + return mentionValue; + } + }, + converterPriority: HIGHER_THEN_HIGHEST + } ); + + editor.conversion.for( 'downcast' ).attributeToElement( { + model: 'mention', + view: ( modelAttributeValue, viewWriter ) => { + if ( !modelAttributeValue ) { + return; + } + + return viewWriter.createAttributeElement( 'a', { + class: 'mention', + 'data-mention': modelAttributeValue.name, + 'href': modelAttributeValue.link + } ); + }, + converterPriority: HIGHER_THEN_HIGHEST + } ); + } +} + +ClassicEditor + .create( global.document.querySelector( '#editor' ), { + plugins: [ Enter, Typing, Paragraph, Link, Heading, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table, + MentionEditing, CustomMentionAttributeView, MentionUI ], + toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ], + mention: { + feeds: [ + { feed: getFeed } + ] + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +function getFeed( feedText ) { + return [ + { id: '1', name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' }, + { id: '2', name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989' }, + { id: '3', name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' }, + { id: '4', name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' }, + { id: '5', name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' } + ].filter( item => { + const searchString = feedText.toLowerCase(); + + return item.name.toLowerCase().includes( searchString ); + } ); +} diff --git a/tests/manual/mention-custom-view.md b/tests/manual/mention-custom-view.md new file mode 100644 index 0000000..ed7ed21 --- /dev/null +++ b/tests/manual/mention-custom-view.md @@ -0,0 +1,33 @@ +## Mention + +This sample overrides default mention conversion to ``. The mention is converted to a link (``). + +### Configuration + +The feed: +- `{ name: 'Barney Stinson', link: 'https://www.imdb.com/title/tt0460649/characters/nm0000439' }` +- `{ name: 'Lily Aldrin', link: 'https://www.imdb.com/title/tt0460649/characters/nm0004989?ref_=tt_cl_t5' }` +- `{ name: 'Marshall Eriksen', link: 'https://www.imdb.com/title/tt0460649/characters/nm0781981' }` +- `{ name: 'Robin Scherbatsky', link: 'https://www.imdb.com/title/tt0460649/characters/nm1130627' }` +- `{ name: 'Ted Mosby', link: 'https://www.imdb.com/title/tt0460649/characters/nm1102140' }` + +### Interaction + +You can interact with mention panel with keyboard: + +- Move arrows up/down to select an item. +- Use enter or tab to insert a mention into the documentation. +- The esc should close the panel. + +Mention panel should be closed on: +- Click outside the panel view. +- Changing selection - like placing it in other part of text. + +### Editing behavior: + +The mention should be removed from the text when: + +- typing inside a mention +- removing characters from a mention +- breaking the mention (enter) +- pasting part of a mention diff --git a/tests/manual/mention.html b/tests/manual/mention.html new file mode 100644 index 0000000..e6d0b53 --- /dev/null +++ b/tests/manual/mention.html @@ -0,0 +1,4 @@ +
+

Hello @Ted.

+

Hello @Ted@Ted.

+
diff --git a/tests/manual/mention.js b/tests/manual/mention.js new file mode 100644 index 0000000..3c2a00d --- /dev/null +++ b/tests/manual/mention.js @@ -0,0 +1,41 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global console, window */ + +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Typing from '@ckeditor/ckeditor5-typing/src/typing'; +import Undo from '@ckeditor/ckeditor5-undo/src/undo'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import Mention from '../../src/mention'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline'; + +ClassicEditor + .create( global.document.querySelector( '#editor' ), { + plugins: [ Enter, Typing, Paragraph, Heading, Link, Bold, Italic, Underline, Undo, Clipboard, Widget, ShiftEnter, Table, Mention ], + toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ], + mention: { + feeds: [ + { feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }, + ] + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/tests/manual/mention.md b/tests/manual/mention.md new file mode 100644 index 0000000..3107887 --- /dev/null +++ b/tests/manual/mention.md @@ -0,0 +1,34 @@ +## Mention + +The minimal mention configuration with a static list of autocomplete feed: + +### Configuration + +The feed: + +- Barney +- Lily +- Marshall +- Robin +- Ted + +### Interaction + +You can interact with mention panel with keyboard: + +- Move arrows up/down to select an item. +- Use enter or tab to insert a mention into the documentation. +- The esc should close the panel. + +Mention panel should be closed on: +- Click outside the panel view. +- Changing selection - like placing it in other part of text. + +### Editing behavior: + +The mention should be removed from the text when: + +- typing inside a mention +- removing characters from a mention +- breaking the mention (enter) +- pasting part of a mention diff --git a/tests/mention-integration.js b/tests/mention-integration.js new file mode 100644 index 0000000..bd91740 --- /dev/null +++ b/tests/mention-integration.js @@ -0,0 +1,89 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global document */ + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; + +import MentionEditing from '../src/mentionediting'; + +describe( 'MentionEditing - integration', () => { + let div, editor, model, doc; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + div = document.createElement( 'div' ); + document.body.appendChild( div ); + + return ClassicTestEditor.create( div, { plugins: [ Paragraph, MentionEditing, UndoEditing ] } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + } ); + } ); + + afterEach( () => { + div.remove(); + + return editor.destroy(); + } ); + + describe( 'undo', () => { + // Failing test. See ckeditor/ckeditor5#1645. + it( 'should restore removed mention on adding a text inside mention', () => { + editor.setData( '

foo @John bar

' ); + + expect( editor.getData() ).to.equal( '

foo @John bar

' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 6 ); + + writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) ); + } ); + + expect( editor.getData() ).to.equal( '

foo @Jaohn bar

' ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '

foo @Jaohn bar

' ); + + editor.execute( 'undo' ); + + expect( editor.getData() ).to.equal( '

foo @John bar

' ); + expect( getViewData( editor.editing.view ) ) + .to.equal( '

foo @John bar

' ); + } ); + + // Failing test. See ckeditor/ckeditor5#1645. + it( 'should restore removed mention on removing a text inside mention', () => { + editor.setData( '

foo @John bar

' ); + + expect( editor.getData() ).to.equal( '

foo @John bar

' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 7 ); + + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( '

foo @Jhn bar

' ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '

foo @Jhn bar

' ); + + editor.execute( 'undo' ); + + expect( editor.getData() ).to.equal( '

foo @John bar

' ); + expect( getViewData( editor.editing.view ) ) + .to.equal( '

foo @John bar

' ); + } ); + } ); +} ); diff --git a/tests/mention.js b/tests/mention.js new file mode 100644 index 0000000..92d2d5f --- /dev/null +++ b/tests/mention.js @@ -0,0 +1,50 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +import Mention from '../src/mention'; +import MentionEditing from '../src/mentionediting'; +import MentionUI from '../src/mentionui'; + +describe( 'Mention', () => { + let editorElement, editor; + + beforeEach( () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ Mention ] + } ) + .then( newEditor => { + editor = newEditor; + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( Mention ) ).to.instanceOf( Mention ); + } ); + + it( 'has proper name', () => { + expect( Mention.pluginName ).to.equal( 'Mention' ); + } ); + + it( 'should load MentionEditing plugin', () => { + expect( editor.plugins.get( MentionEditing ) ).to.instanceOf( MentionEditing ); + } ); + + it( 'should load MentionUI plugin', () => { + expect( editor.plugins.get( MentionUI ) ).to.instanceOf( MentionUI ); + } ); +} ); diff --git a/tests/mentioncommand.js b/tests/mentioncommand.js new file mode 100644 index 0000000..8b233a1 --- /dev/null +++ b/tests/mentioncommand.js @@ -0,0 +1,126 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import MentionCommand from '../src/mentioncommand'; + +describe( 'MentionCommand', () => { + let editor, command, model, doc, selection; + + beforeEach( () => { + return ModelTestEditor + .create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + selection = doc.selection; + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'x', { inheritAllFrom: '$block' } ); + model.schema.extend( '$text', { allowAttributes: [ 'mention' ] } ); + + command = new MentionCommand( editor ); + } ); + } ); + + afterEach( () => { + command.destroy(); + + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should return true if characters with the attribute can be placed at caret position', () => { + setData( model, 'f[]oo' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should return false if characters with the attribute cannot be placed at caret position', () => { + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + // Allow 'bold' on p>$text. + if ( ctx.endsWith( 'x $text' ) && attributeName == 'mention' ) { + return false; + } + } ); + + setData( model, 'fo[]o' ); + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'inserts mention attribute for given range', () => { + setData( model, 'foo @Jo[]bar' ); + + command.execute( { + mention: { name: 'John' }, + range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus ) + } ); + + assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' ); + } ); + + it( 'inserts mention object if mention was passed as string', () => { + setData( model, 'foo @Jo[]bar' ); + + command.execute( { + mention: 'John', + range: model.createRange( selection.focus.getShiftedBy( -3 ), selection.focus ) + } ); + + assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' ); + } ); + + it( 'inserts mention attribute with passed marker for given range', () => { + setData( model, 'foo @Jo[]bar' ); + + const end = model.createPositionAt( selection.focus ); + const start = end.getShiftedBy( -3 ); + + command.execute( { + mention: { name: 'John' }, + range: model.createRange( start, end ), + marker: '#' + } ); + + assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '#', 'John' ); + } ); + + it( 'inserts mention attribute at current selection if no range was passed', () => { + setData( model, 'foo []bar' ); + + command.execute( { + mention: { name: 'John' } + } ); + + assertMention( doc.getRoot().getChild( 0 ).getChild( 1 ), '@', 'John' ); + } ); + + it( 'should set also other styles in inserted text', () => { + model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } ); + + setData( model, '<$text bold="true">foo@John[]bar' ); + + command.execute( { + mention: { name: 'John' }, + range: model.createRange( selection.focus.getShiftedBy( -5 ), selection.focus ) + } ); + + const textNode = doc.getRoot().getChild( 0 ).getChild( 1 ); + assertMention( textNode, '@', 'John' ); + expect( textNode.hasAttribute( 'bold' ) ).to.be.true; + } ); + } ); + + function assertMention( textNode, marker, name ) { + expect( textNode.hasAttribute( 'mention' ) ).to.be.true; + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', marker ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', name ); + } +} ); diff --git a/tests/mentionediting.js b/tests/mentionediting.js new file mode 100644 index 0000000..e02dcdf --- /dev/null +++ b/tests/mentionediting.js @@ -0,0 +1,281 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import MentionEditing from '../src/mentionediting'; +import MentionCommand from '../src/mentioncommand'; + +describe( 'MentionEditing', () => { + let editor, model, doc; + + testUtils.createSinonSandbox(); + + afterEach( () => { + if ( editor ) { + return editor.destroy(); + } + } ); + + it( 'should be named', () => { + expect( MentionEditing.pluginName ).to.equal( 'MentionEditing' ); + } ); + + it( 'should be loaded', () => { + return createTestEditor() + .then( newEditor => { + expect( newEditor.plugins.get( MentionEditing ) ).to.be.instanceOf( MentionEditing ); + } ); + } ); + + it( 'should set proper schema rules', () => { + return createTestEditor() + .then( newEditor => { + model = newEditor.model; + + expect( model.schema.checkAttribute( [ '$root', '$text' ], 'mention' ) ).to.be.true; + + expect( model.schema.checkAttribute( [ '$block', '$text' ], 'mention' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'mention' ) ).to.be.true; + + expect( model.schema.checkAttribute( [ '$block' ], 'mention' ) ).to.be.false; + } ); + } ); + + it( 'should register mention command', () => { + return createTestEditor() + .then( newEditor => { + const command = newEditor.commands.get( 'mention' ); + + expect( command ).to.be.instanceof( MentionCommand ); + } ); + } ); + + describe( 'conversion', () => { + beforeEach( () => { + return createTestEditor() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + } ); + } ); + + it( 'should convert to mention attribute', () => { + editor.setData( '

foo @John bar

' ); + + const textNode = doc.getRoot().getChild( 0 ).getChild( 1 ); + + expect( textNode ).to.not.be.null; + expect( textNode.hasAttribute( 'mention' ) ).to.be.true; + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' ); + + const expectedView = '

foo @John bar

'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + } ); + + it( 'should convert consecutive mentions spans as two text nodes and two spans in the view', () => { + editor.setData( + '

' + + '@John' + + '@John' + + '

' + ); + + // getModelData() merges text blocks with "same" attributes: + // So expected: <$text mention="{"name":"John"}">@John<$text mention="{"name":"John"}">@John' + // Is returned as: <$text mention="{"name":"John"}">@John@John' + const paragraph = doc.getRoot().getChild( 0 ); + + expect( paragraph.childCount ).to.equal( 2 ); + + assertTextNode( paragraph.getChild( 0 ) ); + assertTextNode( paragraph.getChild( 1 ) ); + + const firstMentionId = paragraph.getChild( 0 ).getAttribute( 'mention' )._id; + const secondMentionId = paragraph.getChild( 1 ).getAttribute( 'mention' )._id; + + expect( firstMentionId ).to.not.equal( secondMentionId ); + + const expectedView = '

@John' + + '@John

'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + + function assertTextNode( textNode ) { + expect( textNode ).to.not.be.null; + expect( textNode.hasAttribute( 'mention' ) ).to.be.true; + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' ); + } + } ); + + it( 'should not convert partial mentions', () => { + editor.setData( '

@Jo

' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '@Jo' ); + + const expectedView = '

@Jo

'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + } ); + + it( 'should not convert empty mentions', () => { + editor.setData( '

foo

' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( 'foo' ); + + const expectedView = '

foo

'; + + expect( editor.getData() ).to.equal( expectedView ); + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( expectedView ); + } ); + } ); + + describe( 'selection post fixer', () => { + beforeEach( () => { + return createTestEditor() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + } ); + } ); + + it( 'should remove mention attribute from a selection if selection is on right side of a mention', () => { + editor.setData( '

foo @Johnbar

' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 9 ); + } ); + + expect( Array.from( doc.selection.getAttributes() ) ).to.deep.equal( [] ); + } ); + + it( 'should allow to type after a mention', () => { + editor.setData( '

foo @Johnbar

' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 9 ); + + writer.insertText( ' ', paragraph, 9 ); + } ); + + expect( editor.getData() ).to.equal( '

foo @John bar

' ); + } ); + } ); + + describe( 'removing partial mention post fixer', () => { + beforeEach( () => { + return createTestEditor() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + } ); + } ); + + it( 'should remove mention on adding a text inside mention', () => { + editor.setData( '

foo @John bar

' ); + + const textNode = doc.getRoot().getChild( 0 ).getChild( 1 ); + + expect( textNode ).to.not.be.null; + expect( textNode.hasAttribute( 'mention' ) ).to.be.true; + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_id' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( '_marker', '@' ); + expect( textNode.getAttribute( 'mention' ) ).to.have.property( 'name', 'John' ); + + model.change( writer => { + const paragraph = doc.getRoot().getChild( 0 ); + + writer.setSelection( paragraph, 6 ); + + writer.insertText( 'a', doc.selection.getAttributes(), writer.createPositionAt( paragraph, 6 ) ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( 'foo @Jaohn bar' ); + + expect( editor.getData() ).to.equal( '

foo @Jaohn bar

' ); + } ); + + it( 'should remove mention on removing a text inside mention', () => { + editor.setData( '

foo @John bar

' ); + + const paragraph = doc.getRoot().getChild( 0 ); + + model.change( writer => { + writer.setSelection( paragraph, 6 ); + } ); + + model.enqueueChange( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( '

foo @ohn bar

' ); + } ); + + it( 'should remove mention on removing a text at the and of a mention', () => { + editor.setData( '

foo @John bar

' ); + + const paragraph = doc.getRoot().getChild( 0 ); + + // Set selection at the end of a John. + model.change( writer => { + writer.setSelection( paragraph, 9 ); + } ); + + model.enqueueChange( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( '

foo @Joh bar

' ); + } ); + + it( 'should not remove mention on removing a text just after a mention', () => { + editor.setData( '

foo @John bar

' ); + + const paragraph = doc.getRoot().getChild( 0 ); + + // Set selection before bar. + model.change( writer => { + writer.setSelection( paragraph, 10 ); + } ); + + model.enqueueChange( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'codepoint' } ); + model.deleteContent( doc.selection ); + } ); + + expect( editor.getData() ).to.equal( '

foo @Johnbar

' ); + } ); + } ); + + function createTestEditor( mentionConfig ) { + return VirtualTestEditor + .create( { + plugins: [ Paragraph, MentionEditing ], + mention: mentionConfig + } ); + } +} ); diff --git a/tests/mentionui.js b/tests/mentionui.js new file mode 100644 index 0000000..e3a2b01 --- /dev/null +++ b/tests/mentionui.js @@ -0,0 +1,994 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global window, document, setTimeout, Event */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; + +import MentionUI from '../src/mentionui'; +import MentionEditing from '../src/mentionediting'; +import MentionsView from '../src/ui/mentionsview'; + +describe( 'MentionUI', () => { + let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, listView; + + const staticConfig = { + feeds: [ + { feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] } + ] + }; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + } ); + + afterEach( () => { + sinon.restore(); + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should create a plugin instance', () => { + return createClassicTestEditor().then( () => { + expect( mentionUI ).to.instanceOf( Plugin ); + expect( mentionUI ).to.instanceOf( MentionUI ); + } ); + } ); + + describe( 'pluginName', () => { + it( 'should return plugin by its name', () => { + return createClassicTestEditor().then( () => { + expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI ); + } ); + } ); + } ); + + describe( 'child views', () => { + beforeEach( () => createClassicTestEditor() ); + + describe( 'panelView', () => { + it( 'should create a view instance', () => { + expect( panelView ).to.instanceof( BalloonPanelView ); + } ); + + it( 'should be added to the ui.view.body collection', () => { + expect( Array.from( editor.ui.view.body ) ).to.include( panelView ); + } ); + + it( 'should have disabled arrow', () => { + expect( panelView.withArrow ).to.be.false; + } ); + + it( 'should have added MentionView as a child', () => { + expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView ); + } ); + } ); + } ); + + describe( 'position', () => { + let pinSpy; + + const caretRect = { + bottom: 118, + height: 18, + left: 500, + right: 501, + top: 100, + width: 1 + }; + + const balloonRect = { + bottom: 150, + height: 150, + left: 0, + right: 200, + top: 0, + width: 200 + }; + + beforeEach( () => { + return createClassicTestEditor( staticConfig ).then( () => { + pinSpy = sinon.spy( panelView, 'pin' ); + } ); + } ); + + it( 'should properly calculate position data', () => { + setData( model, 'foo []' ); + stubSelectionRects( [ caretRect ] ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + const pinArgument = pinSpy.firstCall.args[ 0 ]; + const { target, positions } = pinArgument; + + expect( target() ).to.deep.equal( caretRect ); + expect( positions ).to.have.length( 4 ); + + const caretSouthEast = positions[ 0 ]; + const caretNorthEast = positions[ 1 ]; + const caretSouthWest = positions[ 2 ]; + const caretNorthWest = positions[ 3 ]; + + expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( { + left: 501, + name: 'caret_se', + top: 123 + } ); + + expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( { + left: 501, + name: 'caret_ne', + top: -55 + } ); + + expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( { + left: 301, + name: 'caret_sw', + top: 123 + } ); + + expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( { + left: 301, + name: 'caret_nw', + top: -55 + } ); + } ); + } ); + + it( 'should re-calculate position on typing', () => { + setData( model, 'foo []' ); + stubSelectionRects( [ caretRect ] ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + sinon.assert.calledOnce( pinSpy ); + + model.change( writer => { + writer.insertText( 't', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + sinon.assert.calledTwice( pinSpy ); + } ); + } ); + } ); + + describe( 'typing integration', () => { + it( 'should show panel for matched marker after typing minimum characters', () => { + return createClassicTestEditor( { feeds: [ Object.assign( { minimumCharacters: 2 }, staticConfig.feeds[ 0 ] ) ] } ) + .then( () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + } ) + .then( () => { + model.change( writer => { + writer.insertText( 'B', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.false; + } ) + .then( waitForDebounce ) + .then( () => { + model.change( writer => { + writer.insertText( 'a', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 1 ); + + model.change( writer => { + writer.insertText( 'r', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 1 ); + } ); + } ); + + describe( 'static list with default trigger', () => { + beforeEach( () => { + return createClassicTestEditor( staticConfig ); + } ); + + it( 'should show panel for matched marker', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 5 ); + } ); + } ); + + it( 'should show panel for matched marker at the beginning of paragraph', () => { + setData( model, '[] foo' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 5 ); + } ); + } ); + + it( 'should not show panel for marker in the middle of other word', () => { + setData( model, 'foo[]' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + it( 'should not show panel when selection is inside a mention', () => { + setData( model, 'foo <$text mention="{\'name\':\'John\'}">@John bar' ); + + model.change( writer => { + writer.setSelection( doc.getRoot().getChild( 0 ), 7 ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + it( 'should not show panel when selection is at the end of a mention', () => { + setData( model, 'foo <$text mention="{\'name\':\'John\'}">@John bar' ); + + model.change( writer => { + writer.setSelection( doc.getRoot().getChild( 0 ), 9 ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + it( 'should not show panel when selection is not collapsed', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + + model.change( () => { + model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + it( 'should show filtered results for matched text', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + model.change( writer => { + writer.insertText( 'T', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 1 ); + } ); + } ); + + it( 'should focus the first item in panel', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + const button = listView.items.get( 0 ).children.get( 0 ); + + expect( button.isOn ).to.be.true; + } ); + } ); + + it( 'should hide panel if no matched items', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => expect( panelView.isVisible ).to.be.true ) + .then( () => { + model.change( writer => { + writer.insertText( 'x', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.false; + expect( listView.items ).to.have.length( 0 ); + } ); + } ); + + it( 'should hide panel when text was unmatched', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => expect( panelView.isVisible ).to.be.true ) + .then( () => { + model.change( writer => { + const end = doc.selection.getFirstPosition(); + const start = end.getShiftedBy( -1 ); + + writer.remove( writer.createRange( start, end ) ); + } ); + } ) + .then( waitForDebounce ) + .then( () => expect( panelView.isVisible ).to.be.false ); + } ); + } ); + + describe( 'asynchronous list with custom trigger', () => { + beforeEach( () => { + const issuesNumbers = [ '100', '101', '102', '103' ]; + + return createClassicTestEditor( { + feeds: [ + { + marker: '#', + feed: feedText => { + return new Promise( resolve => { + setTimeout( () => { + resolve( issuesNumbers.filter( number => number.includes( feedText ) ) ); + }, 20 ); + } ); + } + } + ] + } ); + } ); + + it( 'should show panel for matched marker', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '#', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 4 ); + } ); + } ); + + it( 'should show filtered results for matched text', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '#', doc.selection.getFirstPosition() ); + } ); + + model.change( writer => { + writer.insertText( '2', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 1 ); + } ); + } ); + + it( 'should hide panel if no matched items', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '#', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => expect( panelView.isVisible ).to.be.true ) + .then( () => { + model.change( writer => { + writer.insertText( 'x', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.false; + expect( listView.items ).to.have.length( 0 ); + } ); + } ); + + it( 'should hide panel when text was unmatched', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '#', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => expect( panelView.isVisible ).to.be.true ) + .then( () => { + model.change( writer => { + const end = doc.selection.getFirstPosition(); + const start = end.getShiftedBy( -1 ); + + writer.remove( writer.createRange( start, end ) ); + } ); + } ) + .then( waitForDebounce ) + .then( () => expect( panelView.isVisible ).to.be.false ); + } ); + } ); + } ); + + describe( 'panel behavior', () => { + it( 'should close the opened panel on esc', () => { + return createClassicTestEditor( staticConfig ) + .then( () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + + fireKeyDownEvent( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + it( 'should close the opened panel when click outside the panel', () => { + return createClassicTestEditor( staticConfig ) + .then( () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + + document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + it( 'should hide the panel when click outside', () => { + return createClassicTestEditor( staticConfig ) + .then( () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + + model.change( writer => { + // Place position at the begging of a paragraph. + writer.setSelection( doc.getRoot().getChild( 0 ), 0 ); + } ); + + expect( panelView.isVisible ).to.be.false; + } ); + } ); + + describe( 'default list item', () => { + const feedItems = staticConfig.feeds[ 0 ].feed.map( name => ( { name } ) ); + + beforeEach( () => { + return createClassicTestEditor( staticConfig ); + } ); + + describe( 'on arrows', () => { + it( 'should cycle down on arrow down', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + + const keyEvtData = { + keyCode: keyCodes.arrowdown, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, true, false, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, true, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, true, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, false, true ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + } ); + } ); + + it( 'should cycle up on arrow up', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + + const keyEvtData = { + keyCode: keyCodes.arrowup, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, false, true ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, true, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, true, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, true, false, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + } ); + } ); + } ); + + describe( 'on "execute" keys', () => { + testExecuteKey( 'enter', keyCodes.enter, feedItems ); + + testExecuteKey( 'tab', keyCodes.tab, feedItems ); + + testExecuteKey( 'space', keyCodes.space, feedItems ); + } ); + } ); + + describe( 'custom list item', () => { + const issues = [ + { id: '1002', title: 'Some bug in editor.' }, + { id: '1003', title: 'Introduce this feature.' }, + { id: '1004', title: 'Missing docs.' }, + { id: '1005', title: 'Another bug.' }, + { id: '1006', title: 'More bugs' } + ]; + + beforeEach( () => { + return createClassicTestEditor( { + feeds: [ + { + marker: '@', + feed: feedText => { + return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) ); + }, + itemRenderer: item => { + const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' ); + + span.innerHTML = `@${ item.title }`; + + return span; + } + } + ] + } ); + } ); + + it( 'should show panel for matched marker', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( listView.items ).to.have.length( 5 ); + } ); + } ); + + describe( 'keys', () => { + describe( 'on arrows', () => { + it( 'should cycle down on arrow down', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + + const keyEvtData = { + keyCode: keyCodes.arrowdown, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, true, false, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, true, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, true, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, false, true ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + } ); + } ); + + it( 'should cycle up on arrow up', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + + const keyEvtData = { + keyCode: keyCodes.arrowup, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, false, true ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, false, true, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, false, true, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ false, true, false, false, false ] ); + + fireKeyDownEvent( keyEvtData ); + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + } ); + } ); + } ); + + describe( 'on "execute" keys', () => { + testExecuteKey( 'enter', keyCodes.enter, issues ); + + testExecuteKey( 'tab', keyCodes.tab, issues ); + + testExecuteKey( 'space', keyCodes.space, issues ); + } ); + + describe( 'mouse', () => { + it( 'should execute selected button on mouse click', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + const command = editor.commands.get( 'mention' ); + const spy = testUtils.sinon.spy( command, 'execute' ); + + return waitForDebounce() + .then( () => { + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + + const element = panelView.element.querySelector( '#issue-1004' ); + element.dispatchEvent( new Event( 'click', { bubbles: true } ) ); + + sinon.assert.calledOnce( spy ); + + const commandOptions = spy.getCall( 0 ).args[ 0 ]; + + const item = issues[ 2 ]; + + expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item ); + expect( commandOptions ).to.have.property( 'marker', '@' ); + expect( commandOptions ).to.have.property( 'range' ); + + const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 ); + const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) ); + + expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true; + } ); + } ); + } ); + } ); + } ); + + function testExecuteKey( name, keyCode, feedItems ) { + it( 'should execute selected button on ' + name, () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + const command = editor.commands.get( 'mention' ); + const spy = testUtils.sinon.spy( command, 'execute' ); + + return waitForDebounce() + .then( () => { + expectChildViewsIsOnState( [ true, false, false, false, false ] ); + + fireKeyDownEvent( { + keyCode: keyCodes.arrowup, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expectChildViewsIsOnState( [ false, false, false, false, true ] ); + + fireKeyDownEvent( { + keyCode, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + sinon.assert.calledOnce( spy ); + + assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] ); + + const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 ); + const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) ); + + expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true; + } ); + } ); + + it( 'should do nothing if panel is not visible on ' + name, () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + const command = editor.commands.get( 'mention' ); + const spy = testUtils.sinon.spy( command, 'execute' ); + + return waitForDebounce() + .then( () => { + expect( panelView.isVisible ).to.be.true; + + fireKeyDownEvent( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( panelView.isVisible ).to.be.false; + + fireKeyDownEvent( { + keyCode, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + sinon.assert.notCalled( spy ); + + expect( panelView.isVisible ).to.be.false; + } ); + } ); + } + } ); + + describe( 'execute', () => { + beforeEach( () => createClassicTestEditor( staticConfig ) ); + + it( 'should call the mention command with proper options', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + const command = editor.commands.get( 'mention' ); + const spy = testUtils.sinon.spy( command, 'execute' ); + + return waitForDebounce() + .then( () => { + listView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + + const commandOptions = spy.getCall( 0 ).args[ 0 ]; + + assertCommandOptions( commandOptions, '@', { name: 'Barney' } ); + + const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 ); + const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) ); + + expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true; + } ); + } ); + + it( 'should hide panel on execute', () => { + setData( model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + + return waitForDebounce() + .then( () => { + listView.items.get( 0 ).children.get( 0 ).fire( 'execute' ); + + expect( panelView.isVisible ).to.be.false; + } ); + } ); + } ); + + function createClassicTestEditor( mentionConfig ) { + return ClassicTestEditor + .create( editorElement, { + plugins: [ Paragraph, MentionEditing, MentionUI ], + mention: mentionConfig + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + editingView = editor.editing.view; + mentionUI = editor.plugins.get( MentionUI ); + panelView = mentionUI.panelView; + mentionsView = mentionUI._mentionsView; + listView = mentionsView.listView; + + editingView.attachDomRoot( editorElement ); + + // Focus the engine. + editingView.document.isFocused = true; + editingView.getDomRoot().focus(); + + // Remove all selection ranges from DOM before testing. + window.getSelection().removeAllRanges(); + } ); + } + + function waitForDebounce() { + return new Promise( resolve => { + setTimeout( () => { + resolve(); + }, 50 ); + } ); + } + + function fireKeyDownEvent( options ) { + const eventInfo = new EventInfo( editingView.document, 'keydown' ); + const eventData = new DomEventData( editingView.document, { + target: document.body + }, options ); + + editingView.document.fire( eventInfo, eventData ); + } + + function stubSelectionRects( rects ) { + const originalViewRangeToDom = editingView.domConverter.viewRangeToDom; + + // Mock selection rect. + sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => { + const domRange = originalViewRangeToDom.apply( editingView.domConverter, args ); + + sinon.stub( domRange, 'getClientRects' ) + .returns( rects ); + + return domRange; + } ); + } + + function expectChildViewsIsOnState( expectedState ) { + const childViews = [ ...listView.items ].map( listView => listView.children.get( 0 ) ); + + expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState ); + } + + function assertCommandOptions( commandOptions, marker, item ) { + expect( commandOptions ).to.have.property( 'marker', marker ); + expect( commandOptions ).to.have.property( 'range' ); + expect( commandOptions ).to.have.property( 'mention' ); + + const mentionForCommand = commandOptions.mention; + + for ( const key of Object.keys( item ) ) { + expect( mentionForCommand[ key ] ).to.equal( item[ key ] ); + } + } +} ); diff --git a/theme/mentionediting.css b/theme/mentionediting.css new file mode 100644 index 0000000..62c156a --- /dev/null +++ b/theme/mentionediting.css @@ -0,0 +1,4 @@ +/* + * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */