Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@ckeditor/ckeditor5-mention

Package Overview
Dependencies
Maintainers
1
Versions
690
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ckeditor/ckeditor5-mention - npm Package Compare versions

Comparing version 0.0.1 to 10.0.0

CHANGELOG.md

28

package.json
{
"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 ) );
}
}
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc