@ckeditor/ckeditor5-mention
Advanced tools
Comparing version 0.0.1 to 10.0.0
{ | ||
"name": "@ckeditor/ckeditor5-mention", | ||
"version": "0.0.1", | ||
"version": "10.0.0", | ||
"description": "Mention feature for CKEditor 5.", | ||
@@ -13,19 +13,15 @@ "keywords": [ | ||
"dependencies": { | ||
"@ckeditor/ckeditor5-core": "^12.0.0", | ||
"@ckeditor/ckeditor5-ui": "^12.0.0", | ||
"@ckeditor/ckeditor5-utils": "^12.0.0" | ||
"@ckeditor/ckeditor5-core": "^12.1.0", | ||
"@ckeditor/ckeditor5-ui": "^12.1.0", | ||
"@ckeditor/ckeditor5-utils": "^12.1.0" | ||
}, | ||
"devDependencies": { | ||
"@ckeditor/ckeditor5-basic-styles": "^11.0.0", | ||
"@ckeditor/ckeditor5-clipboard": "^11.0.0", | ||
"@ckeditor/ckeditor5-editor-classic": "^12.0.0", | ||
"@ckeditor/ckeditor5-engine": "^13.0.0", | ||
"@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", | ||
"@ckeditor/ckeditor5-basic-styles": "^11.1.0", | ||
"@ckeditor/ckeditor5-block-quote": "^11.0.1", | ||
"@ckeditor/ckeditor5-clipboard": "^11.0.1", | ||
"@ckeditor/ckeditor5-editor-classic": "^12.1.0", | ||
"@ckeditor/ckeditor5-engine": "^13.1.0", | ||
"@ckeditor/ckeditor5-font": "^11.1.0", | ||
"@ckeditor/ckeditor5-paragraph": "^11.0.1", | ||
"@ckeditor/ckeditor5-undo": "^11.0.1", | ||
"eslint": "^5.5.0", | ||
@@ -32,0 +28,0 @@ "eslint-config-ckeditor5": "^1.0.11", |
@@ -12,9 +12,11 @@ /** | ||
import MentionEditing from './mentionediting'; | ||
import MentionEditing, { _toMentionAttribute } from './mentionediting'; | ||
import MentionUI from './mentionui'; | ||
import '../theme/mention.css'; | ||
/** | ||
* The mention plugin. | ||
* | ||
* For a detailed overview, check the {@glink features/mention Mention feature documentation}. | ||
* For a detailed overview, check the {@glink features/mentions Mention feature documentation}. | ||
* | ||
@@ -25,2 +27,19 @@ * @extends module:core/plugin~Plugin | ||
/** | ||
* Creates a mention attribute value from the provided view element and optional data. | ||
* | ||
* editor.plugins.get( 'Mention' ).toMentionAttribute( viewElement, { userId: '1234' } ); | ||
* | ||
* // for a viewElement: <span data-mention="@joe">@John Doe</span> | ||
* // it will return: | ||
* // { id: '@joe', userId: '1234', _uid: '7a7bc7...', _text: '@John Doe' } | ||
* | ||
* @param {module:engine/view/element~Element} viewElement | ||
* @param {String|Object} [data] Additional data to be stored in the mention attribute. | ||
* @returns {module:mention/mention~MentionAttribute} | ||
*/ | ||
toMentionAttribute( viewElement, data ) { | ||
return _toMentionAttribute( viewElement, data ); | ||
} | ||
/** | ||
* @inheritDoc | ||
@@ -50,32 +69,20 @@ */ | ||
/** | ||
* The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}. | ||
* The configuration of the mention feature. | ||
* | ||
* See {@link module:mention/mention~MentionConfig} to learn more. | ||
* Read more about {@glink features/mentions#configuration configuring the mention feature}. | ||
* | ||
* const mentionFeed = { | ||
* marker: '@', | ||
* feed: [ 'Alice', 'Bob', ... ] | ||
* } | ||
* ClassicEditor | ||
* .create( editorElement, { | ||
* mention: ... // Media embed feature options. | ||
* } ) | ||
* .then( ... ) | ||
* .catch( ... ); | ||
* | ||
* @typedef {Object} module:mention/mention~MentionFeed | ||
* @property {String} [marker=''] The character which triggers auto-completion for mention. | ||
* @property {Array.<module:mention/mention~MentionFeedItem>|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. | ||
* See {@link module:core/editor/editorconfig~EditorConfig all editor options}. | ||
* | ||
* *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. | ||
* @interface MentionConfig | ||
*/ | ||
/** | ||
* The list fo mention feeds supported by the editor. | ||
* The list of mention feeds supported by the editor. | ||
* | ||
@@ -89,3 +96,3 @@ * ClassicEditor | ||
* marker: '@', | ||
* feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] | ||
* feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ] | ||
* }, | ||
@@ -99,3 +106,4 @@ * ... | ||
* | ||
* You can provide as many mention feeds but they must have different `marker` defined. | ||
* You can provide as many mention feeds but they must use different `marker`s. | ||
* For example, you can use `'@'` to autocomplete people and `'#'` to autocomplete tags. | ||
* | ||
@@ -106,9 +114,106 @@ * @member {Array.<module:mention/mention~MentionFeed>} module:mention/mention~MentionConfig#feeds | ||
/** | ||
* The configuration of the mention features. | ||
* The mention feed descriptor. Used in {@link module:mention/mention~MentionConfig `config.mention`}. | ||
* | ||
* Read more about {@glink features/mention#configuration configuring the mention feature}. | ||
* See {@link module:mention/mention~MentionConfig} to learn more. | ||
* | ||
* // Static configuration. | ||
* const mentionFeedPeople = { | ||
* marker: '@', | ||
* feed: [ '@Alice', '@Bob', ... ], | ||
* minimumCharacters: 2 | ||
* }; | ||
* | ||
* // Simple, synchronous callback. | ||
* const mentionFeedTags = { | ||
* marker: '#', | ||
* feed: searchString => { | ||
* return tags | ||
* // Filter the tags list. | ||
* .filter( tag => { | ||
* return tag.toLowerCase().includes( queryText.toLowerCase() ); | ||
* } ) | ||
* // Return 10 items max - needed for generic queries when the list may contain hundreds of elements. | ||
* .slice( 0, 10 ); | ||
* } | ||
* }; | ||
* | ||
* const tags = [ 'wysiwyg', 'rte', 'rich-text-edior', 'collaboration', 'real-time', ... ]; | ||
* | ||
* // Asynchronous callback. | ||
* const mentionFeedPlaceholders = { | ||
* marker: '$', | ||
* feed: searchString => { | ||
* return getMatchingPlaceholders( searchString ); | ||
* } | ||
* }; | ||
* | ||
* function getMatchingPlaceholders( searchString ) { | ||
* return new Promise( resolve => { | ||
* doSomeXHRQuery( result => { | ||
* // console.log( result ); | ||
* // -> [ '$name', '$surname', '$postal', ... ] | ||
* | ||
* resolve( result ); | ||
* } ); | ||
* } ); | ||
* } | ||
* | ||
* @typedef {Object} module:mention/mention~MentionFeed | ||
* @property {String} [marker] The character which triggers autocompletion for mention. It must be a single character. | ||
* @property {Array.<module:mention/mention~MentionFeedItem>|Function} feed The autocomplete items. Provide an array for | ||
* a static configuration (the mention feature will show matching items automatically) or a function which returns an array of | ||
* matching items (directly, or via a promise). | ||
* @property {Number} [minimumCharacters=0] Specifies after how many characters the autocomplete panel should be shown. | ||
* @property {Function} [itemRenderer] Function that renders a {@link module:mention/mention~MentionFeedItem} | ||
* to the autocomplete panel. | ||
*/ | ||
/** | ||
* The mention feed item. It may be defined as a string or a plain object. | ||
* | ||
* When defining a feed item as a plain object, the `id` property is obligatory. The additional properties | ||
* can be used when customizing the mention feature bahavior | ||
* (see {@glink features/mentions#customizing-the-autocomplete-list "Customizing the autocomplete list"} | ||
* and {@glink features/mentions#customizing-the-output "Customizing the output"} sections). | ||
* | ||
* ClassicEditor | ||
* .create( editorElement, { | ||
* mention: ... // Media embed feature options. | ||
* plugins: [ Mention, ... ], | ||
* mention: { | ||
* feeds: [ | ||
* // Feed items as objects. | ||
* { | ||
* marker: '@', | ||
* feed: [ | ||
* { | ||
* id: '@Barney', | ||
* fullName: 'Barney Bloom' | ||
* }, | ||
* { | ||
* id: '@Lily', | ||
* fullName: 'Lily Smith' | ||
* }, | ||
* { | ||
* id: '@Marshall', | ||
* fullName: 'Marshall McDonald' | ||
* }, | ||
* { | ||
* id: '@Robin', | ||
* fullName: 'Robin Hood' | ||
* }, | ||
* { | ||
* id: '@Ted', | ||
* fullName: 'Ted Cruze' | ||
* }, | ||
* // ... | ||
* ] | ||
* }, | ||
* | ||
* // Feed items as plain strings. | ||
* { | ||
* marker: '#', | ||
* feed: [ 'wysiwyg', 'rte', 'rich-text-edior', 'collaboration', 'real-time', ... ] | ||
* }, | ||
* ] | ||
* } | ||
* } ) | ||
@@ -118,5 +223,17 @@ * .then( ... ) | ||
* | ||
* See {@link module:core/editor/editorconfig~EditorConfig all editor options}. | ||
* @typedef {Object|String} module:mention/mention~MentionFeedItem | ||
* @property {String} id Unique id of the mention. It must start with the marker character. | ||
* @property {String} [text] Text inserted into the editor when creating a mention. | ||
*/ | ||
/** | ||
* Represents mention in the model. | ||
* | ||
* @interface MentionConfig | ||
* See {@link module:mention/mention~Mention#toMentionAttribute `Mention#toMentionAttribute()`}. | ||
* | ||
* @interface module:mention/mention~MentionAttribute | ||
* @property {String} id Id of a mention – identifies the mention item in mention feed. | ||
* @property {String} _uid Internal mention view item id. Should be passed as an `option.id` when using | ||
* {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement writer.createAttributeElement()}. | ||
* @property {String} _text Helper property that holds text of inserted mention. Used for detecting broken mention in the editing area. | ||
*/ |
@@ -12,3 +12,4 @@ /** | ||
import toMap from '@ckeditor/ckeditor5-utils/src/tomap'; | ||
import uid from '@ckeditor/ckeditor5-utils/src/uid'; | ||
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; | ||
import { _addMentionAttributes } from './mentionediting'; | ||
@@ -24,9 +25,24 @@ /** | ||
* | ||
* // It will replace one character before selection focus with '#1234' text | ||
* // with mention attribute filled with passed attributes. | ||
* editor.execute( 'mention', { | ||
* marker: '#', | ||
* mention: { | ||
* id: '#1234', | ||
* name: 'Foo', | ||
* id: '1234', | ||
* title: 'Big Foo' | ||
* }, | ||
* range: model.createRange( focus, focus.getShiftedBy( -1 ) ) | ||
* } ); | ||
* | ||
* // It will replace one character before selection focus with 'Teh "Big Foo"' text | ||
* // with attribute filled with passed attributes. | ||
* editor.execute( 'mention', { | ||
* marker: '#', | ||
* mention: { | ||
* id: '#1234', | ||
* name: 'Foo', | ||
* title: 'Big Foo' | ||
* }, | ||
* text: 'The "Big Foo"', | ||
* range: model.createRange( focus, focus.getShiftedBy( -1 ) ) | ||
@@ -54,3 +70,5 @@ * } ); | ||
* name attribute equal to passed string. | ||
* @param {String} [options.marker='@'] The mention marker to insert. | ||
* @param {String} options.marker The marker character (e.g. `'@'`). | ||
* @param {String} [options.text] The text of inserted mention. Defaults to full mention string composed from `marker` and | ||
* `mention` string or `mention.id` if object is passed. | ||
* @param {String} [options.range] Range to replace. Note that replace range might be shorter then inserted text with mention attribute. | ||
@@ -64,19 +82,61 @@ * @fires execute | ||
const marker = options.marker || '@'; | ||
const mentionData = typeof options.mention == 'string' ? { id: options.mention } : options.mention; | ||
const mentionID = mentionData.id; | ||
const mention = typeof options.mention == 'string' ? { name: options.mention } : options.mention; | ||
const range = options.range || selection.getFirstRange(); | ||
// Set internal attributes on mention object. | ||
mention._id = uid(); | ||
mention._marker = marker; | ||
const mentionText = options.text || mentionID; | ||
const range = options.range || selection.getFirstRange(); | ||
const mention = _addMentionAttributes( { _text: mentionText, id: mentionID }, mentionData ); | ||
if ( options.marker.length != 1 ) { | ||
/** | ||
* The marker must be a single character. | ||
* | ||
* Correct markers: `'@'`, `'#'`. | ||
* | ||
* Incorrect markers: `'$$'`, `'[@'`. | ||
* | ||
* See {@link module:mention/mention~MentionConfig}. | ||
* | ||
* @error mentioncommand-incorrect-marker | ||
*/ | ||
throw new CKEditorError( 'mentioncommand-incorrect-marker: The marker must be a single character.' ); | ||
} | ||
if ( mentionID.charAt( 0 ) != options.marker ) { | ||
/** | ||
* The feed item id must start with the marker character. | ||
* | ||
* Correct mention feed setting: | ||
* | ||
* mentions: [ | ||
* { | ||
* marker: '@', | ||
* feed: [ '@Ann', '@Barney', ... ] | ||
* } | ||
* ] | ||
* | ||
* Incorrect mention feed setting: | ||
* | ||
* mentions: [ | ||
* { | ||
* marker: '@', | ||
* feed: [ 'Ann', 'Barney', ... ] | ||
* } | ||
* ] | ||
* | ||
* See {@link module:mention/mention~MentionConfig}. | ||
* | ||
* @error mentioncommand-incorrect-id | ||
*/ | ||
throw new CKEditorError( 'mentioncommand-incorrect-id: The item id must start with the marker character.' ); | ||
} | ||
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. | ||
@@ -83,0 +143,0 @@ writer.remove( range ); |
@@ -15,4 +15,2 @@ /** | ||
import '../theme/mentionediting.css'; | ||
/** | ||
@@ -23,3 +21,3 @@ * The mention editing feature. | ||
* attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view} | ||
* as a `<span class="mention" data-mention="name">`. | ||
* as a `<span class="mention" data-mention="@mention">`. | ||
* | ||
@@ -55,3 +53,3 @@ * @extends module:core/plugin~Plugin | ||
key: 'mention', | ||
value: parseMentionViewItemAttributes | ||
value: _toMentionAttribute | ||
} | ||
@@ -65,3 +63,4 @@ } ); | ||
doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc ) ); | ||
doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) ); | ||
doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) ); | ||
doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) ); | ||
@@ -73,29 +72,33 @@ | ||
// 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' ); | ||
export function _addMentionAttributes( baseMentionData, data ) { | ||
return Object.assign( { _uid: uid() }, baseMentionData, data || {} ); | ||
} | ||
const textNode = viewElement.getChild( 0 ); | ||
/** | ||
* Creates mention attribute value from provided view element and optional data. | ||
* | ||
* This function is exposed as | ||
* {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}. | ||
* | ||
* @protected | ||
* @param {module:engine/view/element~Element} viewElementOrMention | ||
* @param {String|Object} [data] Mention data to be extended. | ||
* @return {module:mention/mention~MentionAttribute} | ||
*/ | ||
export function _toMentionAttribute( viewElementOrMention, data ) { | ||
const dataMention = viewElementOrMention.getAttribute( 'data-mention' ); | ||
// Do not parse empty mentions. | ||
if ( !textNode || !textNode.is( 'text' ) ) { | ||
const textNode = viewElementOrMention.getChild( 0 ); | ||
// Do not convert empty mentions. | ||
if ( !textNode ) { | ||
return; | ||
} | ||
const mentionString = textNode.data; | ||
const baseMentionData = { | ||
id: dataMention, | ||
_text: 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() }; | ||
return _addMentionAttributes( baseMentionData, data ); | ||
} | ||
@@ -115,7 +118,8 @@ | ||
class: 'mention', | ||
'data-mention': mention.name | ||
'data-mention': mention.id | ||
}; | ||
const options = { | ||
id: mention._id | ||
id: mention._uid, | ||
priority: 20 | ||
}; | ||
@@ -151,3 +155,3 @@ | ||
// @returns {Boolean} Returns true if selection was fixed. | ||
function removePartialMentionPostFixer( writer, doc ) { | ||
function removePartialMentionPostFixer( writer, doc, schema ) { | ||
const changes = doc.differ.getChanges(); | ||
@@ -158,24 +162,59 @@ | ||
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; | ||
// Check text node on current position; | ||
const position = change.position; | ||
if ( change.name == '$text' && textNode && textNode.hasAttribute( 'mention' ) ) { | ||
writer.removeAttribute( 'mention', textNode ); | ||
wasChanged = true; | ||
if ( change.name == '$text' ) { | ||
const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling; | ||
// Check textNode where the change occurred. | ||
wasChanged = checkAndFix( position.textNode, writer ) || wasChanged; | ||
// Occurs on paste occurs inside a text node with mention. | ||
wasChanged = checkAndFix( nodeAfterInsertedTextNode, writer ) || wasChanged; | ||
wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged; | ||
wasChanged = checkAndFix( position.nodeAfter, writer ) || wasChanged; | ||
} | ||
// Check text nodes in inserted elements (might occur when splitting paragraph or pasting content inside text with mention). | ||
if ( change.name != '$text' && change.type == 'insert' ) { | ||
const insertedNode = position.nodeAfter; | ||
for ( const item of writer.createRangeIn( insertedNode ).getItems() ) { | ||
wasChanged = checkAndFix( item, writer ) || wasChanged; | ||
} | ||
} | ||
// Additional check for deleting last character of a text node. | ||
if ( change.type == 'remove' ) { | ||
const nodeBefore = change.position.nodeBefore; | ||
// Inserted inline elements might break mention. | ||
if ( change.type == 'insert' && schema.isInline( change.name ) ) { | ||
const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling; | ||
if ( nodeBefore && nodeBefore.hasAttribute( 'mention' ) ) { | ||
const text = nodeBefore.data; | ||
const mention = nodeBefore.getAttribute( 'mention' ); | ||
wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged; | ||
wasChanged = checkAndFix( nodeAfterInserted, writer ) || wasChanged; | ||
} | ||
} | ||
const expectedText = mention._marker + mention.name; | ||
return wasChanged; | ||
} | ||
if ( text != expectedText ) { | ||
writer.removeAttribute( 'mention', nodeBefore ); | ||
// This post-fixer will extend attribute applied on part of a mention so a whole text node of a mention will have added attribute. | ||
// | ||
// @param {module:engine/model/writer~Writer} writer | ||
// @param {module:engine/model/document~Document} doc | ||
// @returns {Boolean} Returns true if selection was fixed. | ||
function extendAttributeOnMentionPostFixer( writer, doc ) { | ||
const changes = doc.differ.getChanges(); | ||
let wasChanged = false; | ||
for ( const change of changes ) { | ||
if ( change.type === 'attribute' && change.attributeKey != 'mention' ) { | ||
// Check node at the left side of a range... | ||
const nodeBefore = change.range.start.nodeBefore; | ||
// ... and on right side of range. | ||
const nodeAfter = change.range.end.nodeAfter; | ||
for ( const node of [ nodeBefore, nodeAfter ] ) { | ||
if ( isBrokenMentionNode( node ) && node.getAttribute( change.attributeKey ) != change.attributeNewValue ) { | ||
writer.setAttribute( change.attributeKey, change.attributeNewValue, node ); | ||
wasChanged = true; | ||
@@ -189,1 +228,34 @@ } | ||
} | ||
// Checks if node has correct mention attribute if present. | ||
// Returns true if node is text and has a mention attribute which text does not match expected mention text. | ||
// | ||
// @param {module:engine/model/node~Node} node a node to check | ||
// @returns {Boolean} | ||
function isBrokenMentionNode( node ) { | ||
if ( !node || !( node.is( 'text' ) || node.is( 'textProxy' ) ) || !node.hasAttribute( 'mention' ) ) { | ||
return false; | ||
} | ||
const text = node.data; | ||
const mention = node.getAttribute( 'mention' ); | ||
const expectedText = mention._text; | ||
return text != expectedText; | ||
} | ||
// Fixes mention on text node it needs a fix. | ||
// | ||
// @param {module:engine/model/text~Text} textNode | ||
// @param {module:engine/model/writer~Writer} writer | ||
// @returns {Boolean} | ||
function checkAndFix( textNode, writer ) { | ||
if ( isBrokenMentionNode( textNode ) ) { | ||
writer.removeAttribute( 'mention', textNode ); | ||
return true; | ||
} | ||
return false; | ||
} |
@@ -17,2 +17,3 @@ /** | ||
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; | ||
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; | ||
@@ -25,3 +26,3 @@ import TextWatcher from './textwatcher'; | ||
const VERTICAL_SPACING = 5; | ||
const VERTICAL_SPACING = 3; | ||
@@ -96,6 +97,6 @@ /** | ||
if ( data.keyCode == keyCodes.esc ) { | ||
this._hidePanel(); | ||
this._hidePanelAndRemoveMarker(); | ||
} | ||
} | ||
}, { priority: 'highest' } ); // priority highest required for enter overriding. | ||
}, { priority: 'highest' } ); // Required to override enter. | ||
@@ -107,3 +108,3 @@ // Close the #panelView upon clicking outside of the plugin UI. | ||
activator: () => this.panelView.isVisible, | ||
callback: () => this._hidePanel() | ||
callback: () => this._hidePanelAndRemoveMarker() | ||
} ); | ||
@@ -116,3 +117,19 @@ | ||
const marker = mentionDescription.marker || '@'; | ||
const marker = mentionDescription.marker; | ||
if ( !marker || marker.length != 1 ) { | ||
/** | ||
* The marker must be a single character. | ||
* | ||
* Correct markers: `'@'`, `'#'`. | ||
* | ||
* Incorrect markers: `'$$'`, `'[@'`. | ||
* | ||
* See {@link module:mention/mention~MentionConfig}. | ||
* | ||
* @error mentionconfig-incorrect-marker | ||
*/ | ||
throw new CKEditorError( 'mentionconfig-incorrect-marker: The marker must be provided and be a single character.' ); | ||
} | ||
const minimumCharacters = mentionDescription.minimumCharacters || 0; | ||
@@ -171,3 +188,3 @@ const feedCallback = typeof feed == 'function' ? feed : createFeedCallback( feed ); | ||
mentionsView.listView.items.bindTo( this._items ).using( data => { | ||
mentionsView.items.bindTo( this._items ).using( data => { | ||
const { item, marker } = data; | ||
@@ -214,4 +231,7 @@ | ||
this._hidePanelAndRemoveMarker(); | ||
editor.execute( 'mention', { | ||
mention: item, | ||
text: item.text, | ||
marker, | ||
@@ -221,3 +241,3 @@ range | ||
this._hidePanel(); | ||
editor.editing.view.focus(); | ||
} ); | ||
@@ -282,2 +302,22 @@ | ||
const matchedTextLength = marker.length + feedText.length; | ||
// create marker range | ||
const start = selection.focus.getShiftedBy( -matchedTextLength ); | ||
const end = selection.focus.getShiftedBy( -feedText.length ); | ||
const markerRange = editor.model.createRange( start, end ); | ||
let mentionMarker; | ||
if ( editor.model.markers.has( 'mention' ) ) { | ||
mentionMarker = editor.model.markers.get( 'mention' ); | ||
} else { | ||
mentionMarker = editor.model.change( writer => writer.addMarker( 'mention', { | ||
range: markerRange, | ||
usingOperation: false, | ||
affectsData: false | ||
} ) ); | ||
} | ||
this._getFeed( marker, feedText ) | ||
@@ -287,4 +327,4 @@ .then( feed => { | ||
for ( const name of feed ) { | ||
const item = typeof name != 'object' ? { name } : name; | ||
for ( const feedItem of feed ) { | ||
const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem; | ||
@@ -295,5 +335,5 @@ this._items.add( { item, marker } ); | ||
if ( this._items.length ) { | ||
this._showPanel(); | ||
this._showPanel( mentionMarker ); | ||
} else { | ||
this._hidePanel(); | ||
this._hidePanelAndRemoveMarker(); | ||
} | ||
@@ -304,3 +344,3 @@ } ); | ||
watcher.on( 'unmatched', () => { | ||
this._hidePanel(); | ||
this._hidePanelAndRemoveMarker(); | ||
} ); | ||
@@ -329,4 +369,4 @@ | ||
*/ | ||
_showPanel() { | ||
this.panelView.pin( this._getBalloonPanelPositionData() ); | ||
_showPanel( markerMarker ) { | ||
this.panelView.pin( this._getBalloonPanelPositionData( markerMarker, this.panelView.position ) ); | ||
this.panelView.show(); | ||
@@ -337,8 +377,14 @@ this._mentionsView.selectFirst(); | ||
/** | ||
* Hides the {@link #panelView}. | ||
* Hides the {@link #panelView} and remove 'mention' marker from markers collection. | ||
* | ||
* @private | ||
*/ | ||
_hidePanel() { | ||
_hidePanelAndRemoveMarker() { | ||
if ( this.editor.model.markers.has( 'mention' ) ) { | ||
this.editor.model.change( writer => writer.removeMarker( 'mention' ) ); | ||
} | ||
this.panelView.unpin(); | ||
// Make last matched position on panel view undefined so the #_getBalloonPanelPositionData() will return all positions on next call. | ||
this.panelView.position = undefined; | ||
this.panelView.hide(); | ||
@@ -359,2 +405,3 @@ } | ||
let view; | ||
let label = item.id; | ||
@@ -364,9 +411,15 @@ const renderer = this._getItemRenderer( marker ); | ||
if ( renderer ) { | ||
const domNode = renderer( item ); | ||
const renderResult = renderer( item ); | ||
view = new DomWrapperView( editor.locale, domNode ); | ||
} else { | ||
if ( typeof renderResult != 'string' ) { | ||
view = new DomWrapperView( editor.locale, renderResult ); | ||
} else { | ||
label = renderResult; | ||
} | ||
} | ||
if ( !view ) { | ||
const buttonView = new ButtonView( editor.locale ); | ||
buttonView.label = item.name; | ||
buttonView.label = label; | ||
buttonView.withText = true; | ||
@@ -381,18 +434,35 @@ | ||
/** | ||
* Creates position options object used to position the balloon panel. | ||
* | ||
* @param {module:engine/model/markercollection~Marker} mentionMarker | ||
* @param {String|undefined} positionName Name of last matched position name. | ||
* @returns {module:utils/dom/position~Options} | ||
* @private | ||
*/ | ||
_getBalloonPanelPositionData() { | ||
const view = this.editor.editing.view; | ||
const domConverter = view.domConverter; | ||
const viewSelection = view.document.selection; | ||
_getBalloonPanelPositionData( mentionMarker, positionName ) { | ||
const editing = this.editor.editing; | ||
const domConverter = editing.view.domConverter; | ||
const mapper = editing.mapper; | ||
return { | ||
target: () => { | ||
const range = viewSelection.getLastRange(); | ||
const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( range ) ); | ||
const viewRange = mapper.toViewRange( mentionMarker.getRange() ); | ||
const rangeRects = Rect.getDomRangeRects( domConverter.viewRangeToDom( viewRange ) ); | ||
return rangeRects.pop(); | ||
}, | ||
positions: getBalloonPanelPositions() | ||
limiter: () => { | ||
const view = this.editor.editing.view; | ||
const viewDocument = view.document; | ||
const editableElement = viewDocument.selection.editableElement; | ||
if ( editableElement ) { | ||
return view.domConverter.mapViewToDom( editableElement.root ); | ||
} | ||
return null; | ||
}, | ||
positions: getBalloonPanelPositions( positionName ), | ||
fitInViewport: true | ||
}; | ||
@@ -405,6 +475,6 @@ } | ||
// @returns {Array.<module:utils/dom/position~Position>} | ||
function getBalloonPanelPositions() { | ||
return [ | ||
function getBalloonPanelPositions( positionName ) { | ||
const positions = { | ||
// Positions panel to the south of caret rect. | ||
targetRect => { | ||
'caret_se': targetRect => { | ||
return { | ||
@@ -418,3 +488,3 @@ top: targetRect.bottom + VERTICAL_SPACING, | ||
// Positions panel to the north of caret rect. | ||
( targetRect, balloonRect ) => { | ||
'caret_ne': ( targetRect, balloonRect ) => { | ||
return { | ||
@@ -428,3 +498,3 @@ top: targetRect.top - balloonRect.height - VERTICAL_SPACING, | ||
// Positions panel to the south of caret rect. | ||
( targetRect, balloonRect ) => { | ||
'caret_sw': ( targetRect, balloonRect ) => { | ||
return { | ||
@@ -438,3 +508,3 @@ top: targetRect.bottom + VERTICAL_SPACING, | ||
// Positions panel to the north of caret rect. | ||
( targetRect, balloonRect ) => { | ||
'caret_nw': ( targetRect, balloonRect ) => { | ||
return { | ||
@@ -446,2 +516,17 @@ top: targetRect.top - balloonRect.height - VERTICAL_SPACING, | ||
} | ||
}; | ||
// Return only last position if it was matched to prevent panel from jumping after first match. | ||
if ( positions.hasOwnProperty( positionName ) ) { | ||
return [ | ||
positions[ positionName ] | ||
]; | ||
} | ||
// As default return all positions callbacks. | ||
return [ | ||
positions.caret_se, | ||
positions.caret_ne, | ||
positions.caret_sw, | ||
positions.caret_nw | ||
]; | ||
@@ -458,3 +543,3 @@ } | ||
return `(^| )(${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`; | ||
return `(^| )(\\${ marker })([_a-zA-Z0-9À-ž]${ numberOfCharacters }?)$`; | ||
} | ||
@@ -493,6 +578,14 @@ | ||
return feedText => { | ||
const filteredItems = feedItems.filter( item => { | ||
return item.toLowerCase().includes( feedText.toLowerCase() ); | ||
} ); | ||
const filteredItems = feedItems | ||
// Make default mention feed case-insensitive. | ||
.filter( item => { | ||
// Item might be defined as object. | ||
const itemId = typeof item == 'string' ? item : String( item.id ); | ||
// The default feed is case insensitive. | ||
return itemId.toLowerCase().includes( feedText.toLowerCase() ); | ||
} ) | ||
// Do not return more than 10 items. | ||
.slice( 0, 10 ); | ||
return Promise.resolve( filteredItems ); | ||
@@ -499,0 +592,0 @@ }; |
@@ -15,2 +15,6 @@ /** | ||
* Text watcher feature. | ||
* | ||
* Fires {@link module:mention/textwatcher~TextWatcher#event:matched matched} and | ||
* {@link module:mention/textwatcher~TextWatcher#event:unmatched unmatched} events on typing or selection changes. | ||
* | ||
* @private | ||
@@ -52,35 +56,54 @@ */ | ||
editor.model.document.on( 'change', ( evt, batch ) => { | ||
if ( batch.type == 'transparent' ) { | ||
editor.model.document.selection.on( 'change', ( evt, { directChange } ) => { | ||
// The indirect changes (ie on typing) are handled in document's change event. | ||
if ( !directChange ) { | ||
return; | ||
} | ||
const changes = Array.from( editor.model.document.differ.getChanges() ); | ||
const entry = changes[ 0 ]; | ||
this._evaluateTextBeforeSelection(); | ||
} ); | ||
// 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; | ||
editor.model.document.on( 'change:data', ( evt, batch ) => { | ||
if ( batch.type == 'transparent' ) { | ||
return false; | ||
} | ||
const text = this._getText(); | ||
this._evaluateTextBeforeSelection(); | ||
} ); | ||
} | ||
const textHasMatch = this.testCallback( text ); | ||
/** | ||
* Checks the editor content for matched text. | ||
* | ||
* @fires matched | ||
* @fires unmatched | ||
* | ||
* @private | ||
*/ | ||
_evaluateTextBeforeSelection() { | ||
const text = this._getText(); | ||
if ( !textHasMatch && this.hasMatch ) { | ||
this.fire( 'unmatched' ); | ||
} | ||
const textHasMatch = this.testCallback( text ); | ||
this.hasMatch = textHasMatch; | ||
if ( !textHasMatch && this.hasMatch ) { | ||
/** | ||
* Fired whenever text doesn't match anymore. Fired only when text matcher was matched. | ||
* | ||
* @event unmatched | ||
*/ | ||
this.fire( 'unmatched' ); | ||
} | ||
if ( textHasMatch ) { | ||
const matched = this.textMatcher( text ); | ||
this.hasMatch = textHasMatch; | ||
this.fire( 'matched', { text, matched } ); | ||
} | ||
} ); | ||
if ( textHasMatch ) { | ||
const matched = this.textMatcher( text ); | ||
/** | ||
* Fired whenever text matcher was matched. | ||
* | ||
* @event matched | ||
*/ | ||
this.fire( 'matched', { text, matched } ); | ||
} | ||
} | ||
@@ -105,14 +128,15 @@ | ||
return getText( block ).slice( 0, selection.focus.offset ); | ||
return _getText( editor.model.createRangeIn( 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, '' ); | ||
/** | ||
* Returns whole text from given range by adding all data from text nodes together. | ||
* | ||
* @protected | ||
* @param {module:engine/model/range~Range} range | ||
* @returns {String} | ||
*/ | ||
export function _getText( range ) { | ||
return Array.from( range.getItems() ).reduce( ( a, b ) => a + b.data, '' ); | ||
} | ||
@@ -119,0 +143,0 @@ |
@@ -10,34 +10,33 @@ /** | ||
import View from '@ckeditor/ckeditor5-ui/src/view'; | ||
import ListView from '@ckeditor/ckeditor5-ui/src/list/listview'; | ||
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; | ||
import '../../theme/mentionui.css'; | ||
/** | ||
* The mention ui view. | ||
* | ||
* @extends module:ui/view~View | ||
* @extends module:ui/list/listview~ListView | ||
*/ | ||
export default class MentionsView extends View { | ||
export default class MentionsView extends ListView { | ||
/** | ||
* @inheritDoc | ||
*/ | ||
constructor( locale ) { | ||
super( locale ); | ||
this.listView = new ListView( locale ); | ||
this.setTemplate( { | ||
tag: 'div', | ||
this.extendTemplate( { | ||
attributes: { | ||
class: [ | ||
'ck', | ||
'ck-mention' | ||
'ck-mentions' | ||
], | ||
tabindex: '-1' | ||
}, | ||
children: [ | ||
this.listView | ||
] | ||
} | ||
} ); | ||
} | ||
/** | ||
* {@link #select Selects} the first item. | ||
*/ | ||
selectFirst() { | ||
@@ -47,30 +46,52 @@ this.select( 0 ); | ||
/** | ||
* Selects next item to the currently {@link #select selected}. | ||
* | ||
* If the last item is already selected, it will select the first item. | ||
*/ | ||
selectNext() { | ||
const item = this.selected; | ||
const index = this.items.getIndex( item ); | ||
const index = this.listView.items.getIndex( item ); | ||
this.select( index + 1 ); | ||
} | ||
/** | ||
* Selects previous item to the currently {@link #select selected}. | ||
* | ||
* If the first item is already selected, it will select the last item. | ||
*/ | ||
selectPrevious() { | ||
const item = this.selected; | ||
const index = this.items.getIndex( item ); | ||
const index = this.listView.items.getIndex( item ); | ||
this.select( index - 1 ); | ||
} | ||
/** | ||
* Marks item at a given index as selected. | ||
* | ||
* Handles selection cycling when passed index is out of bounds: | ||
* - if the index is lower than 0, it will select the last item, | ||
* - if the index is higher than the last item index, it will select the first item. | ||
* | ||
* @param {Number} index Index of an item to be marked as selected. | ||
*/ | ||
select( index ) { | ||
let indexToGet = 0; | ||
if ( index > 0 && index < this.listView.items.length ) { | ||
if ( index > 0 && index < this.items.length ) { | ||
indexToGet = index; | ||
} else if ( index < 0 ) { | ||
indexToGet = this.listView.items.length - 1; | ||
indexToGet = this.items.length - 1; | ||
} | ||
const item = this.listView.items.get( indexToGet ); | ||
const item = this.items.get( indexToGet ); | ||
item.highlight(); | ||
// Scroll the mentions view to the selected element. | ||
if ( !this._isItemVisibleInScrolledArea( item ) ) { | ||
this.element.scrollTop = item.element.offsetTop; | ||
} | ||
if ( this.selected ) { | ||
@@ -83,5 +104,17 @@ this.selected.removeHighlight(); | ||
/** | ||
* Triggers the `execute` event on the {@link #select selected} item. | ||
*/ | ||
executeSelected() { | ||
this.selected.fire( 'execute' ); | ||
} | ||
// Checks if an item is visible in the scrollable area. | ||
// | ||
// The item is considered visible when: | ||
// - its top boundary is inside the scrollable rect | ||
// - its bottom boundary is inside the scrollable rect (the whole item must be visible) | ||
_isItemVisibleInScrolledArea( item ) { | ||
return new Rect( this.element ).contains( new Rect( item.element ) ); | ||
} | ||
} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
47950
12
14
1353
0