Spaces:
Running
Running
/** | |
* @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. | |
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license | |
*/ | |
( function() { | |
CKEDITOR.plugins.add( 'autocomplete', { | |
requires: 'textwatcher', | |
onLoad: function() { | |
CKEDITOR.document.appendStyleSheet( this.path + 'skins/default.css' ); | |
} | |
} ); | |
/** | |
* The main class implementing a generic [Autocomplete](https://ckeditor.com/cke4/addon/autocomplete) feature in the editor. | |
* It acts as a controller that works with the {@link CKEDITOR.plugins.autocomplete.model model} and | |
* {@link CKEDITOR.plugins.autocomplete.view view} classes. | |
* | |
* It is possible to maintain multiple autocomplete instances for a single editor at a time. | |
* In order to create an autocomplete instance use its {@link #constructor constructor}. | |
* | |
* @class CKEDITOR.plugins.autocomplete | |
* @since 4.10.0 | |
* @constructor Creates a new instance of autocomplete and attaches it to the editor. | |
* | |
* In order to initialize the autocomplete feature it is enough to instantiate this class with | |
* two required callbacks: | |
* | |
* * {@link CKEDITOR.plugins.autocomplete.configDefinition#textTestCallback config.textTestCallback} – A function being called by | |
* the {@link CKEDITOR.plugins.textWatcher text watcher} plugin, as new text is being inserted. | |
* Its purpose is to determine whether a given range should be matched or not. | |
* See {@link CKEDITOR.plugins.textWatcher#constructor} for more details. | |
* There is also {@link CKEDITOR.plugins.textMatch#match} which is a handy helper for that purpose. | |
* * {@link CKEDITOR.plugins.autocomplete.configDefinition#dataCallback config.dataCallback} – A function that should return | |
* (through its callback) suggestion data for the current query string. | |
* | |
* # Creating an autocomplete instance | |
* | |
* Depending on your use case, put this code in the {@link CKEDITOR.pluginDefinition#init} callback of your | |
* plugin or, for example, in the {@link CKEDITOR.editor#instanceReady} event listener. Ensure that you loaded the | |
* {@link CKEDITOR.plugins.textMatch Text Match} plugin. | |
* | |
* ```javascript | |
* var itemsArray = [ { id: 1, name: '@Andrew' }, { id: 2, name: '@Kate' } ]; | |
* | |
* // Called when the user types in the editor or moves the caret. | |
* // The range represents the caret position. | |
* function textTestCallback( range ) { | |
* // You do not want to autocomplete a non-empty selection. | |
* if ( !range.collapsed ) { | |
* return null; | |
* } | |
* | |
* // Use the text match plugin which does the tricky job of doing | |
* // a text search in the DOM. The matchCallback function should return | |
* // a matching fragment of the text. | |
* return CKEDITOR.plugins.textMatch.match( range, matchCallback ); | |
* } | |
* | |
* // Returns the position of the matching text. | |
* // It matches with a word starting from the '@' character | |
* // up to the caret position. | |
* function matchCallback( text, offset ) { | |
* // Get the text before the caret. | |
* var left = text.slice( 0, offset ), | |
* // Will look for an '@' character followed by word characters. | |
* match = left.match( /@\w*$/ ); | |
* | |
* if ( !match ) { | |
* return null; | |
* } | |
* | |
* return { start: match.index, end: offset }; | |
* } | |
* | |
* // Returns (through its callback) the suggestions for the current query. | |
* function dataCallback( matchInfo, callback ) { | |
* // Simple search. | |
* // Filter the entire items array so only the items that start | |
* // with the query remain. | |
* var suggestions = itemsArray.filter( function( item ) { | |
* return item.name.toLowerCase().indexOf( matchInfo.query.toLowerCase() ) == 0; | |
* } ); | |
* | |
* // Note: The callback function can also be executed asynchronously | |
* // so dataCallback can do XHR requests or use any other asynchronous API. | |
* callback( suggestions ); | |
* } | |
* | |
* // Finally, instantiate the autocomplete class. | |
* new CKEDITOR.plugins.autocomplete( editor, { | |
* textTestCallback: textTestCallback, | |
* dataCallback: dataCallback | |
* } ); | |
* ``` | |
* | |
* # Changing the behavior of the autocomplete class by subclassing it | |
* | |
* This plugin will expose a `CKEDITOR.plugins.customAutocomplete` class which uses | |
* a custom view that positions the panel relative to the {@link CKEDITOR.editor#container}. | |
* | |
* ```javascript | |
* CKEDITOR.plugins.add( 'customautocomplete', { | |
* requires: 'autocomplete', | |
* | |
* onLoad: function() { | |
* var View = CKEDITOR.plugins.autocomplete.view, | |
* Autocomplete = CKEDITOR.plugins.autocomplete; | |
* | |
* function CustomView( editor ) { | |
* // Call the parent class constructor. | |
* View.call( this, editor ); | |
* } | |
* // Inherit the view methods. | |
* CustomView.prototype = CKEDITOR.tools.prototypedCopy( View.prototype ); | |
* | |
* // Change the positioning of the panel, so it is stretched | |
* // to 100% of the editor container width and is positioned | |
* // relative to the editor container. | |
* CustomView.prototype.updatePosition = function( range ) { | |
* var caretRect = this.getViewPosition( range ), | |
* container = this.editor.container; | |
* | |
* this.setPosition( { | |
* // Position the panel relative to the editor container. | |
* left: container.$.offsetLeft, | |
* top: caretRect.top, | |
* bottom: caretRect.bottom | |
* } ); | |
* // Stretch the panel to 100% of the editor container width. | |
* this.element.setStyle( 'width', container.getSize( 'width' ) + 'px' ); | |
* }; | |
* | |
* function CustomAutocomplete( editor, configDefinition ) { | |
* // Call the parent class constructor. | |
* Autocomplete.call( this, editor, configDefinition ); | |
* } | |
* // Inherit the autocomplete methods. | |
* CustomAutocomplete.prototype = CKEDITOR.tools.prototypedCopy( Autocomplete.prototype ); | |
* | |
* CustomAutocomplete.prototype.getView = function() { | |
* return new CustomView( this.editor ); | |
* } | |
* | |
* // Expose the custom autocomplete so it can be used later. | |
* CKEDITOR.plugins.customAutocomplete = CustomAutocomplete; | |
* } | |
* } ); | |
* ``` | |
* @param {CKEDITOR.editor} editor The editor to watch. | |
* @param {CKEDITOR.plugins.autocomplete.configDefinition} config Configuration object for this autocomplete instance. | |
*/ | |
function Autocomplete( editor, config ) { | |
var configKeystrokes = editor.config.autocomplete_commitKeystrokes || CKEDITOR.config.autocomplete_commitKeystrokes; | |
/** | |
* The editor instance that autocomplete is attached to. | |
* | |
* @readonly | |
* @property {CKEDITOR.editor} | |
*/ | |
this.editor = editor; | |
/** | |
* Indicates throttle threshold expressed in milliseconds, reducing text checks frequency. | |
* | |
* @property {Number} [throttle=20] | |
*/ | |
this.throttle = config.throttle !== undefined ? config.throttle : 20; | |
/** | |
* The autocomplete view instance. | |
* | |
* @readonly | |
* @property {CKEDITOR.plugins.autocomplete.view} | |
*/ | |
this.view = this.getView(); | |
/** | |
* The autocomplete model instance. | |
* | |
* @readonly | |
* @property {CKEDITOR.plugins.autocomplete.model} | |
*/ | |
this.model = this.getModel( config.dataCallback ); | |
this.model.itemsLimit = config.itemsLimit; | |
/** | |
* The autocomplete text watcher instance. | |
* | |
* @readonly | |
* @property {CKEDITOR.plugins.textWatcher} | |
*/ | |
this.textWatcher = this.getTextWatcher( config.textTestCallback ); | |
/** | |
* The autocomplete keystrokes used to finish autocompletion with the selected view item. | |
* The property is using the {@link CKEDITOR.config#autocomplete_commitKeystrokes} configuration option as default keystrokes. | |
* You can change this property to set individual keystrokes for the plugin instance. | |
* | |
* @property {Number[]} | |
* @readonly | |
*/ | |
this.commitKeystrokes = CKEDITOR.tools.array.isArray( configKeystrokes ) ? configKeystrokes.slice() : [ configKeystrokes ]; | |
/** | |
* Listeners registered by this autocomplete instance. | |
* | |
* @private | |
*/ | |
this._listeners = []; | |
/** | |
* Template of markup to be inserted as the autocomplete item gets committed. | |
* | |
* You can use {@link CKEDITOR.plugins.autocomplete.model.item item} properties to customize the template. | |
* | |
* ```javascript | |
* var outputTemplate = `<a href="/tracker/{ticket}">#{ticket} ({name})</a>`; | |
* ``` | |
* | |
* @readonly | |
* @property {CKEDITOR.template} [outputTemplate=null] | |
*/ | |
this.outputTemplate = config.outputTemplate !== undefined ? new CKEDITOR.template( config.outputTemplate ) : null; | |
if ( config.itemTemplate ) { | |
this.view.itemTemplate = new CKEDITOR.template( config.itemTemplate ); | |
} | |
// Attach autocomplete when editor instance is ready (#2114). | |
if ( this.editor.status === 'ready' ) { | |
this.attach(); | |
} else { | |
this.editor.on( 'instanceReady', function() { | |
this.attach(); | |
}, this ); | |
} | |
} | |
Autocomplete.prototype = { | |
/** | |
* Attaches the autocomplete to the {@link #editor}. | |
* | |
* * The view is appended to the DOM and the listeners are attached. | |
* * The {@link #textWatcher text watcher} is attached to the editor. | |
* * The listeners on the {@link #model} and {@link #view} events are added. | |
*/ | |
attach: function() { | |
var editor = this.editor, | |
win = CKEDITOR.document.getWindow(), | |
editable = editor.editable(), | |
editorScrollableElement = editable.isInline() ? editable : editable.getDocument(); | |
// iOS classic editor listens on frame parent element for editor `scroll` event (#1910). | |
if ( CKEDITOR.env.iOS && !editable.isInline() ) { | |
editorScrollableElement = iOSViewportElement( editor ); | |
} | |
this.view.append(); | |
this.view.attach(); | |
this.textWatcher.attach(); | |
this._listeners.push( this.textWatcher.on( 'matched', this.onTextMatched, this ) ); | |
this._listeners.push( this.textWatcher.on( 'unmatched', this.onTextUnmatched, this ) ); | |
this._listeners.push( this.model.on( 'change-data', this.modelChangeListener, this ) ); | |
this._listeners.push( this.model.on( 'change-selectedItemId', this.onSelectedItemId, this ) ); | |
this._listeners.push( this.view.on( 'change-selectedItemId', this.onSelectedItemId, this ) ); | |
this._listeners.push( this.view.on( 'click-item', this.onItemClick, this ) ); | |
// Update view position on viewport change. | |
this._listeners.push( win.on( 'scroll', function() { | |
this.viewRepositionListener(); | |
}, this ) ); | |
this._listeners.push( editorScrollableElement.on( 'scroll', function() { | |
this.viewRepositionListener(); | |
}, this ) ); | |
this._listeners.push( editor.on( 'contentDom', onContentDom, this ) ); | |
// CKEditor's event system has a limitation that one function (in this case this.check) | |
// cannot be used as listener for the same event more than once. Hence, wrapper function. | |
this._listeners.push( editor.on( 'change', function() { | |
this.viewRepositionListener(); | |
}, this ) ); | |
// Don't let browser to focus dropdown element (#2107). | |
this._listeners.push( this.view.element.on( 'mousedown', function( e ) { | |
e.data.preventDefault(); | |
}, null, null, 9999 ) ); | |
// Attach if editor is already initialized. | |
if ( editable ) { | |
onContentDom.call( this ); | |
} | |
function onContentDom() { | |
// Priority 5 to get before the enterkey. | |
// Note: CKEditor's event system has a limitation that one function (in this case this.onKeyDown) | |
// cannot be used as listener for the same event more than once. Hence, wrapper function. | |
this._listeners.push( editable.on( 'keydown', function( evt ) { | |
this.onKeyDown( evt ); | |
}, this, null, 5 ) ); | |
} | |
}, | |
/** | |
* Closes the view and sets its {@link CKEDITOR.plugins.autocomplete.model#isActive state} to inactive. | |
*/ | |
close: function() { | |
this.model.setActive( false ); | |
this.view.close(); | |
}, | |
/** | |
* Commits the currently chosen or given item. HTML is generated for this item using the | |
* {@link #getHtmlToInsert} method and then it is inserted into the editor. The item is inserted | |
* into the {@link CKEDITOR.plugins.autocomplete.model#range query's range}, so the query text is | |
* replaced by the inserted HTML. | |
* | |
* @param {Number/String} [itemId] If given, then the specified item will be inserted into the editor | |
* instead of the currently chosen one. | |
*/ | |
commit: function( itemId ) { | |
if ( !this.model.isActive ) { | |
return; | |
} | |
this.close(); | |
if ( itemId == null ) { | |
itemId = this.model.selectedItemId; | |
// If non item is selected abort commit. | |
if ( itemId == null ) { | |
return; | |
} | |
} | |
var item = this.model.getItemById( itemId ), | |
editor = this.editor; | |
editor.fire( 'saveSnapshot' ); | |
editor.getSelection().selectRanges( [ this.model.range ] ); | |
editor.insertHtml( this.getHtmlToInsert( item ), 'text' ); | |
editor.fire( 'saveSnapshot' ); | |
}, | |
/** | |
* Destroys the autocomplete instance. | |
* View element and event listeners will be removed from the DOM. | |
*/ | |
destroy: function() { | |
CKEDITOR.tools.array.forEach( this._listeners, function( obj ) { | |
obj.removeListener(); | |
} ); | |
this._listeners = []; | |
this.view.element.remove(); | |
}, | |
/** | |
* Returns HTML that should be inserted into the editor when the item is committed. | |
* | |
* See also the {@link #commit} method. | |
* | |
* @param {CKEDITOR.plugins.autocomplete.model.item} item | |
* @returns {String} The HTML to insert. | |
*/ | |
getHtmlToInsert: function( item ) { | |
var encodedItem = encodeItem( item ); | |
return this.outputTemplate ? this.outputTemplate.output( encodedItem ) : encodedItem.name; | |
}, | |
/** | |
* Creates and returns the model instance. This method is used when | |
* initializing the autocomplete and can be overwritten in order to | |
* return an instance of a different class than the default model. | |
* | |
* @param {Function} dataCallback See {@link CKEDITOR.plugins.autocomplete.configDefinition#dataCallback configDefinition.dataCallback}. | |
* @returns {CKEDITOR.plugins.autocomplete.model} The model instance. | |
*/ | |
getModel: function( dataCallback ) { | |
var that = this; | |
return new Model( function( matchInfo, callback ) { | |
return dataCallback.call( this, CKEDITOR.tools.extend( { | |
// Make sure autocomplete instance is available in the callback (#2108). | |
autocomplete: that | |
}, matchInfo ), callback ); | |
} ); | |
}, | |
/** | |
* Creates and returns the text watcher instance. This method is used while | |
* initializing the autocomplete and can be overwritten in order to | |
* return an instance of a different class than the default text watcher. | |
* | |
* @param {Function} textTestCallback See the {@link CKEDITOR.plugins.autocomplete} arguments. | |
* @returns {CKEDITOR.plugins.textWatcher} The text watcher instance. | |
*/ | |
getTextWatcher: function( textTestCallback ) { | |
return new CKEDITOR.plugins.textWatcher( this.editor, textTestCallback, this.throttle ); | |
}, | |
/** | |
* Creates and returns the view instance. This method is used while | |
* initializing the autocomplete and can be overwritten in order to | |
* return an instance of a different class than the default view. | |
* | |
* @returns {CKEDITOR.plugins.autocomplete.view} The view instance. | |
*/ | |
getView: function() { | |
return new View( this.editor ); | |
}, | |
/** | |
* Opens the panel if {@link CKEDITOR.plugins.autocomplete.model#hasData there is any data available}. | |
*/ | |
open: function() { | |
if ( this.model.hasData() ) { | |
this.model.setActive( true ); | |
this.view.open(); | |
this.model.selectFirst(); | |
this.view.updatePosition( this.model.range ); | |
} | |
}, | |
// LISTENERS ------------------ | |
/** | |
* The function that should be called once the content has changed. | |
* | |
* @private | |
*/ | |
viewRepositionListener: function() { | |
if ( this.model.isActive ) { | |
this.view.updatePosition( this.model.range ); | |
} | |
}, | |
/** | |
* The function that should be called once the model data has changed. | |
* | |
* @param {CKEDITOR.eventInfo} evt | |
* @private | |
*/ | |
modelChangeListener: function( evt ) { | |
if ( this.model.hasData() ) { | |
this.view.updateItems( evt.data ); | |
this.open(); | |
} else { | |
this.close(); | |
} | |
}, | |
/** | |
* The function that should be called once a view item was clicked. | |
* | |
* @param {CKEDITOR.eventInfo} evt | |
* @private | |
*/ | |
onItemClick: function( evt ) { | |
this.commit( evt.data ); | |
}, | |
/** | |
* The function that should be called on every `keydown` event occurred within the {@link CKEDITOR.editable editable} element. | |
* | |
* @param {CKEDITOR.dom.event} evt | |
* @private | |
*/ | |
onKeyDown: function( evt ) { | |
if ( !this.model.isActive ) { | |
return; | |
} | |
var keyCode = evt.data.getKey(), | |
handled = false; | |
// Esc key. | |
if ( keyCode == 27 ) { | |
this.close(); | |
this.textWatcher.unmatch(); | |
handled = true; | |
// Down Arrow. | |
} else if ( keyCode == 40 ) { | |
this.model.selectNext(); | |
handled = true; | |
// Up Arrow. | |
} else if ( keyCode == 38 ) { | |
this.model.selectPrevious(); | |
handled = true; | |
// Completion keys. | |
} else if ( CKEDITOR.tools.indexOf( this.commitKeystrokes, keyCode ) != -1 ) { | |
this.commit(); | |
this.textWatcher.unmatch(); | |
handled = true; | |
} | |
if ( handled ) { | |
evt.cancel(); | |
evt.data.preventDefault(); | |
this.textWatcher.consumeNext(); | |
} | |
}, | |
/** | |
* The function that should be called once an item was selected. | |
* | |
* @param {CKEDITOR.eventInfo} evt | |
* @private | |
*/ | |
onSelectedItemId: function( evt ) { | |
this.model.setItem( evt.data ); | |
this.view.selectItem( evt.data ); | |
}, | |
/** | |
* The function that should be called once a text was matched by the {@link CKEDITOR.plugins.textWatcher text watcher} | |
* component. | |
* | |
* @param {CKEDITOR.eventInfo} evt | |
* @private | |
*/ | |
onTextMatched: function( evt ) { | |
this.model.setActive( false ); | |
this.model.setQuery( evt.data.text, evt.data.range ); | |
}, | |
/** | |
* The function that should be called once a text was unmatched by the {@link CKEDITOR.plugins.textWatcher text watcher} | |
* component. | |
* | |
* @param {CKEDITOR.eventInfo} evt | |
* @private | |
*/ | |
onTextUnmatched: function() { | |
// Remove query and request ID to avoid opening view for invalid callback (#1984). | |
this.model.query = null; | |
this.model.lastRequestId = null; | |
this.close(); | |
} | |
}; | |
/** | |
* Class representing the autocomplete view. | |
* | |
* In order to use a different view, implement a new view class and override | |
* the {@link CKEDITOR.plugins.autocomplete#getView} method. | |
* | |
* ```javascript | |
* myAutocomplete.prototype.getView = function() { | |
* return new myView( this.editor ); | |
* }; | |
* ``` | |
* | |
* You can also modify this autocomplete instance on the fly. | |
* | |
* ```javascript | |
* myAutocomplete.prototype.getView = function() { | |
* // Call the original getView method. | |
* var view = CKEDITOR.plugins.autocomplete.prototype.getView.call( this ); | |
* | |
* // Override one property. | |
* view.itemTemplate = new CKEDITOR.template( '<li data-id={id}><img src="{iconSrc}" alt="..."> {name}</li>' ); | |
* | |
* return view; | |
* }; | |
* ``` | |
* | |
* **Note:** This class is marked as private, which means that its API might be subject to change in order to | |
* provide further enhancements. | |
* | |
* @class CKEDITOR.plugins.autocomplete.view | |
* @since 4.10.0 | |
* @private | |
* @mixins CKEDITOR.event | |
* @constructor Creates the autocomplete view instance. | |
* @param {CKEDITOR.editor} editor The editor instance. | |
*/ | |
function View( editor ) { | |
/** | |
* The panel's item template used to render matches in the dropdown. | |
* | |
* You can use {@link CKEDITOR.plugins.autocomplete.model#data data item} properties to customize the template. | |
* | |
* A minimal template must be wrapped with a HTML `li` element containing the `data-id="{id}"` attribute. | |
* | |
* ```javascript | |
* var itemTemplate = '<li data-id="{id}"><img src="{iconSrc}" alt="{name}">{name}</li>'; | |
* ``` | |
* | |
* @readonly | |
* @property {CKEDITOR.template} | |
*/ | |
this.itemTemplate = new CKEDITOR.template( '<li data-id="{id}">{name}</li>' ); | |
/** | |
* The editor instance. | |
* | |
* @readonly | |
* @property {CKEDITOR.editor} | |
*/ | |
this.editor = editor; | |
/** | |
* The ID of the selected item. | |
* | |
* @readonly | |
* @property {Number/String} selectedItemId | |
*/ | |
/** | |
* The document to which the view is attached. It is set by the {@link #append} method. | |
* | |
* @readonly | |
* @property {CKEDITOR.dom.document} document | |
*/ | |
/** | |
* The view's main element. It is set by the {@link #append} method. | |
* | |
* @readonly | |
* @property {CKEDITOR.dom.element} element | |
*/ | |
/** | |
* Event fired when an item in the panel is clicked. | |
* | |
* @event click-item | |
* @param {String} The clicked item {@link CKEDITOR.plugins.autocomplete.model.item#id}. Note: the ID | |
* is stringified due to the way how it is stored in the DOM. | |
*/ | |
/** | |
* Event fired when the {@link #selectedItemId} property changes. | |
* | |
* @event change-selectedItemId | |
* @param {Number/String} data The new value. | |
*/ | |
} | |
View.prototype = { | |
/** | |
* Appends the {@link #element main element} to the DOM. | |
*/ | |
append: function() { | |
this.document = CKEDITOR.document; | |
this.element = this.createElement(); | |
this.document.getBody().append( this.element ); | |
}, | |
/** | |
* Removes existing items and appends given items to the {@link #element}. | |
* | |
* @param {CKEDITOR.dom.documentFragment} itemsFragment The document fragment with item elements. | |
*/ | |
appendItems: function( itemsFragment ) { | |
this.element.setHtml( '' ); | |
this.element.append( itemsFragment ); | |
}, | |
/** | |
* Attaches the view's listeners to the DOM elements. | |
*/ | |
attach: function() { | |
this.element.on( 'click', function( evt ) { | |
var target = evt.data.getTarget(), | |
itemElement = target.getAscendant( this.isItemElement, true ); | |
if ( itemElement ) { | |
this.fire( 'click-item', itemElement.data( 'id' ) ); | |
} | |
}, this ); | |
this.element.on( 'mouseover', function( evt ) { | |
var target = evt.data.getTarget(); | |
if ( this.element.contains( target ) ) { | |
// Find node containing data-id attribute inside target node tree (#2187). | |
target = target.getAscendant( function( element ) { | |
return element.hasAttribute( 'data-id' ); | |
}, true ); | |
if ( !target ) { | |
return; | |
} | |
var itemId = target.data( 'id' ); | |
this.fire( 'change-selectedItemId', itemId ); | |
} | |
}, this ); | |
}, | |
/** | |
* Closes the panel. | |
*/ | |
close: function() { | |
this.element.removeClass( 'cke_autocomplete_opened' ); | |
}, | |
/** | |
* Creates and returns the view's main element. | |
* | |
* @private | |
* @returns {CKEDITOR.dom.element} | |
*/ | |
createElement: function() { | |
var el = new CKEDITOR.dom.element( 'ul', this.document ); | |
el.addClass( 'cke_autocomplete_panel' ); | |
// Below float panels and context menu, but above maximized editor (-5). | |
el.setStyle( 'z-index', this.editor.config.baseFloatZIndex - 3 ); | |
return el; | |
}, | |
/** | |
* Creates the item element based on the {@link #itemTemplate}. | |
* | |
* @param {CKEDITOR.plugins.autocomplete.model.item} item The item for which an element will be created. | |
* @returns {CKEDITOR.dom.element} | |
*/ | |
createItem: function( item ) { | |
var encodedItem = encodeItem( item ); | |
return CKEDITOR.dom.element.createFromHtml( this.itemTemplate.output( encodedItem ), this.document ); | |
}, | |
/** | |
* Returns the view position based on a given `range`. | |
* | |
* Indicates the start position of the autocomplete dropdown. | |
* The value returned by this function is passed to the {@link #setPosition} method | |
* by the {@link #updatePosition} method. | |
* | |
* @param {CKEDITOR.dom.range} range The range of the text match. | |
* @returns {Object} Represents the position of the caret. The value is relative to the panel's offset parent. | |
* @returns {Number} rect.left | |
* @returns {Number} rect.top | |
* @returns {Number} rect.bottom | |
*/ | |
getViewPosition: function( range ) { | |
// Use the last rect so the view will be | |
// correctly positioned with a word split into few lines. | |
var rects = range.getClientRects(), | |
viewPositionRect = rects[ rects.length - 1 ], | |
offset, | |
editable = this.editor.editable(); | |
if ( editable.isInline() ) { | |
offset = CKEDITOR.document.getWindow().getScrollPosition(); | |
} else { | |
offset = editable.getParent().getDocumentPosition( CKEDITOR.document ); | |
} | |
// Consider that offset host might be repositioned on its own. | |
// Similar to #1048. See https://github.com/ckeditor/ckeditor-dev/pull/1732#discussion_r182790235. | |
var hostElement = CKEDITOR.document.getBody(); | |
if ( hostElement.getComputedStyle( 'position' ) === 'static' ) { | |
hostElement = hostElement.getParent(); | |
} | |
var offsetCorrection = hostElement.getDocumentPosition(); | |
offset.x -= offsetCorrection.x; | |
offset.y -= offsetCorrection.y; | |
return { | |
top: ( viewPositionRect.top + offset.y ), | |
bottom: ( viewPositionRect.top + viewPositionRect.height + offset.y ), | |
left: ( viewPositionRect.left + offset.x ) | |
}; | |
}, | |
/** | |
* Gets the item element by the item ID. | |
* | |
* @param {Number/String} itemId | |
* @returns {CKEDITOR.dom.element} The item element. | |
*/ | |
getItemById: function( itemId ) { | |
return this.element.findOne( 'li[data-id="' + itemId + '"]' ); | |
}, | |
/** | |
* Checks whether a given node is the item element. | |
* | |
* @param {CKEDITOR.dom.node} node | |
* @returns {Boolean} | |
*/ | |
isItemElement: function( node ) { | |
return node.type == CKEDITOR.NODE_ELEMENT && | |
Boolean( node.data( 'id' ) ); | |
}, | |
/** | |
* Opens the panel. | |
*/ | |
open: function() { | |
this.element.addClass( 'cke_autocomplete_opened' ); | |
}, | |
/** | |
* Selects the item in the panel and scrolls the list to show it if needed. | |
* The {@link #selectedItemId currently selected item} is deselected first. | |
* | |
* @param {Number/String} itemId The ID of the item that should be selected. | |
*/ | |
selectItem: function( itemId ) { | |
if ( this.selectedItemId != null ) { | |
this.getItemById( this.selectedItemId ).removeClass( 'cke_autocomplete_selected' ); | |
} | |
var itemElement = this.getItemById( itemId ); | |
itemElement.addClass( 'cke_autocomplete_selected' ); | |
this.selectedItemId = itemId; | |
this.scrollElementTo( itemElement ); | |
}, | |
/** | |
* Sets the position of the panel. This method only performs the check | |
* for the available space below and above the specified `rect` and | |
* positions the panel in the best place. | |
* | |
* This method is used by the {@link #updatePosition} method which | |
* controls how the panel should be positioned on the screen, for example | |
* based on the caret position and/or the editor position. | |
* | |
* @param {Object} rect Represents the position of a vertical (e.g. a caret) line relative to which | |
* the panel should be positioned. | |
* @param {Number} rect.left The position relative to the panel's offset parent in pixels. | |
* For example, the position of the caret. | |
* @param {Number} rect.top The position relative to the panel's offset parent in pixels. | |
* For example, the position of the upper end of the caret. | |
* @param {Number} rect.bottom The position relative to the panel's offset parent in pixels. | |
* For example, the position of the bottom end of the caret. | |
*/ | |
setPosition: function( rect ) { | |
var editor = this.editor, | |
viewHeight = this.element.getSize( 'height' ), | |
editable = editor.editable(), | |
// Bounding rect where the view should fit (visible editor viewport). | |
editorViewportRect; | |
// iOS classic editor has different viewport element (#1910). | |
if ( CKEDITOR.env.iOS && !editable.isInline() ) { | |
editorViewportRect = iOSViewportElement( editor ).getClientRect( true ); | |
} else { | |
editorViewportRect = editable.isInline() ? editable.getClientRect( true ) : editor.window.getFrame().getClientRect( true ); | |
} | |
// How much space is there for the view above and below the specified rect. | |
var spaceAbove = rect.top - editorViewportRect.top, | |
spaceBelow = editorViewportRect.bottom - rect.bottom, | |
top; | |
// As a default, keep the view inside the editor viewport. | |
// +---------------------------------------------+ | |
// | editor viewport | | |
// | | | |
// | | | |
// | | | |
// | █ - caret position | | |
// | +--------------+ | | |
// | | view | | | |
// | +--------------+ | | |
// | | | |
// | | | |
// +---------------------------------------------+ | |
top = rect.top < editorViewportRect.top ? editorViewportRect.top : Math.min( editorViewportRect.bottom, rect.bottom ); | |
// If the view doesn't fit below the caret position and fits above, set it there. | |
// This means that the position below the caret is preferred. | |
// +---------------------------------------------+ | |
// | | | |
// | editor viewport | | |
// | +--------------+ | | |
// | | | | | |
// | | view | | | |
// | | | | | |
// | +--------------+ | | |
// | █ - caret position | | |
// | | | |
// +---------------------------------------------+ | |
if ( viewHeight > spaceBelow && viewHeight < spaceAbove ) { | |
top = rect.top - viewHeight; | |
} | |
// If the caret position is below the view - keep it at the bottom edge. | |
// +---------------------------------------------+ | |
// | editor viewport | | |
// | | | |
// | +--------------+ | | |
// | | | | | |
// | | view | | | |
// | | | | | |
// +-----+==============+------------------------+ | |
// | | | |
// | █ - caret position | | |
// | | | |
// +---------------------------------------------+ | |
if ( editorViewportRect.bottom < rect.bottom ) { | |
top = Math.min( rect.top - viewHeight, editorViewportRect.bottom - viewHeight ); | |
} | |
// If the caret position is above the view - keep it at the top edge. | |
// +---------------------------------------------+ | |
// | | | |
// | █ - caret position | | |
// | | | |
// +-----+==============+------------------------+ | |
// | | | | | |
// | | view | | | |
// | | | | | |
// | +--------------+ | | |
// | | | |
// | editor viewport | | |
// +---------------------------------------------+ | |
if ( editorViewportRect.top > rect.top ) { | |
top = Math.max( rect.bottom, editorViewportRect.top ); | |
} | |
this.element.setStyles( { | |
left: rect.left + 'px', | |
top: top + 'px' | |
} ); | |
}, | |
/** | |
* Scrolls the list so the item element is visible in it. | |
* | |
* @param {CKEDITOR.dom.element} itemElement | |
*/ | |
scrollElementTo: function( itemElement ) { | |
itemElement.scrollIntoParent( this.element ); | |
}, | |
/** | |
* Updates the list of items in the panel. | |
* | |
* @param {CKEDITOR.plugins.autocomplete.model.item[]} items. | |
*/ | |
updateItems: function( items ) { | |
var i, | |
frag = new CKEDITOR.dom.documentFragment( this.document ); | |
for ( i = 0; i < items.length; ++i ) { | |
frag.append( this.createItem( items[ i ] ) ); | |
} | |
this.appendItems( frag ); | |
this.selectedItemId = null; | |
}, | |
/** | |
* Updates the position of the panel. | |
* | |
* By default this method finds the position of the caret and uses | |
* {@link #setPosition} to move the panel to the best position close | |
* to the caret. | |
* | |
* @param {CKEDITOR.dom.range} range The range of the text match. | |
*/ | |
updatePosition: function( range ) { | |
this.setPosition( this.getViewPosition( range ) ); | |
} | |
}; | |
CKEDITOR.event.implementOn( View.prototype ); | |
/** | |
* Class representing the autocomplete model. | |
* | |
* In case you want to modify the model behavior, check out the | |
* {@link CKEDITOR.plugins.autocomplete.view} documentation. It contains | |
* examples of how to easily override the default behavior. | |
* | |
* A model instance is created by the {@link CKEDITOR.plugins.autocomplete#getModel} method. | |
* | |
* **Note:** This class is marked as private, which means that its API might be subject to change in order to | |
* provide further enhancements. | |
* | |
* @class CKEDITOR.plugins.autocomplete.model | |
* @since 4.10.0 | |
* @private | |
* @mixins CKEDITOR.event | |
* @constructor Creates the autocomplete model instance. | |
* @param {Function} dataCallback See {@link CKEDITOR.plugins.autocomplete} arguments. | |
*/ | |
function Model( dataCallback ) { | |
/** | |
* The callback executed by the model when requesting data. | |
* See {@link CKEDITOR.plugins.autocomplete} arguments. | |
* | |
* @readonly | |
* @property {Function} | |
*/ | |
this.dataCallback = dataCallback; | |
/** | |
* Whether the autocomplete is active (i.e. can receive user input like click, key press). | |
* Should be modified by the {@link #setActive} method which fires the {@link #change-isActive} event. | |
* | |
* @readonly | |
*/ | |
this.isActive = false; | |
/** | |
* Indicates the limit of items rendered in the dropdown. | |
* | |
* For falsy values like `0` or `null` all items will be rendered. | |
* | |
* @property {Number} [itemsLimit=0] | |
*/ | |
this.itemsLimit = 0; | |
/** | |
* The ID of the last request for data. Used by the {@link #setQuery} method. | |
* | |
* @readonly | |
* @private | |
* @property {Number} lastRequestId | |
*/ | |
/** | |
* The query string set by the {@link #setQuery} method. | |
* | |
* The query string always has a corresponding {@link #range}. | |
* | |
* @readonly | |
* @property {String} query | |
*/ | |
/** | |
* The range in the DOM where the {@link #query} text is. | |
* | |
* The range always has a corresponding {@link #query}. Both can be set by the {@link #setQuery} method. | |
* | |
* @readonly | |
* @property {CKEDITOR.dom.range} range | |
*/ | |
/** | |
* The query results — the items to be displayed in the autocomplete panel. | |
* | |
* @readonly | |
* @property {CKEDITOR.plugins.autocomplete.model.item[]} data | |
*/ | |
/** | |
* The ID of the item currently selected in the panel. | |
* | |
* @readonly | |
* @property {Number/String} selectedItemId | |
*/ | |
/** | |
* Event fired when the {@link #data} array changes. | |
* | |
* @event change-data | |
* @param {CKEDITOR.plugins.autocomplete.model.item[]} data The new value. | |
*/ | |
/** | |
* Event fired when the {@link #selectedItemId} property changes. | |
* | |
* @event change-selectedItemId | |
* @param {Number/String} data The new value. | |
*/ | |
/** | |
* Event fired when the {@link #isActive} property changes. | |
* | |
* @event change-isActive | |
* @param {Boolean} data The new value. | |
*/ | |
} | |
Model.prototype = { | |
/** | |
* Gets an index from the {@link #data} array of the item by its ID. | |
* | |
* @param {Number/String} itemId | |
* @returns {Number} | |
*/ | |
getIndexById: function( itemId ) { | |
if ( !this.hasData() ) { | |
return -1; | |
} | |
for ( var data = this.data, i = 0, l = data.length; i < l; i++ ) { | |
if ( data[ i ].id == itemId ) { | |
return i; | |
} | |
} | |
return -1; | |
}, | |
/** | |
* Gets the item from the {@link #data} array by its ID. | |
* | |
* @param {Number/String} itemId | |
* @returns {CKEDITOR.plugins.autocomplete.model.item} | |
*/ | |
getItemById: function( itemId ) { | |
var index = this.getIndexById( itemId ); | |
return ~index && this.data[ index ] || null; | |
}, | |
/** | |
* Whether the model contains non-empty {@link #data}. | |
* | |
* @returns {Boolean} | |
*/ | |
hasData: function() { | |
return Boolean( this.data && this.data.length ); | |
}, | |
/** | |
* Sets the {@link #selectedItemId} property. | |
* | |
* @param {Number/String} itemId | |
*/ | |
setItem: function( itemId ) { | |
if ( this.getIndexById( itemId ) < 0 ) { | |
throw new Error( 'Item with given id does not exist' ); | |
} | |
this.selectedItemId = itemId; | |
}, | |
/** | |
* Fires the {@link #change-selectedItemId} event. | |
* | |
* @param {Number/String} itemId | |
*/ | |
select: function( itemId ) { | |
this.fire( 'change-selectedItemId', itemId ); | |
}, | |
/** | |
* Selects the first item. See also the {@link #select} method. | |
*/ | |
selectFirst: function() { | |
if ( this.hasData() ) { | |
this.select( this.data[ 0 ].id ); | |
} | |
}, | |
/** | |
* Selects the last item. See also the {@link #select} method. | |
*/ | |
selectLast: function() { | |
if ( this.hasData() ) { | |
this.select( this.data[ this.data.length - 1 ].id ); | |
} | |
}, | |
/** | |
* Selects the next item in the {@link #data} array. If no item is selected, | |
* it selects the first one. If the last one is selected, it selects the first one. | |
* | |
* See also the {@link #select} method. | |
*/ | |
selectNext: function() { | |
if ( this.selectedItemId == null ) { | |
this.selectFirst(); | |
return; | |
} | |
var index = this.getIndexById( this.selectedItemId ); | |
if ( index < 0 || index + 1 == this.data.length ) { | |
this.selectFirst(); | |
} else { | |
this.select( this.data[ index + 1 ].id ); | |
} | |
}, | |
/** | |
* Selects the previous item in the {@link #data} array. If no item is selected, | |
* it selects the last one. If the first one is selected, it selects the last one. | |
* | |
* See also the {@link #select} method. | |
*/ | |
selectPrevious: function() { | |
if ( this.selectedItemId == null ) { | |
this.selectLast(); | |
return; | |
} | |
var index = this.getIndexById( this.selectedItemId ); | |
if ( index <= 0 ) { | |
this.selectLast(); | |
} else { | |
this.select( this.data[ index - 1 ].id ); | |
} | |
}, | |
/** | |
* Sets the {@link #isActive} property and fires the {@link #change-isActive} event. | |
* | |
* @param {Boolean} isActive | |
*/ | |
setActive: function( isActive ) { | |
this.isActive = isActive; | |
this.fire( 'change-isActive', isActive ); | |
}, | |
/** | |
* Sets the {@link #query} and {@link #range} and makes a request for the query results | |
* by executing the {@link #dataCallback} function. When the data is returned (synchronously or | |
* asynchronously, because {@link #dataCallback} exposes a callback function), the {@link #data} | |
* property is set and the {@link #change-data} event is fired. | |
* | |
* This method controls that only the response for the current query is handled. | |
* | |
* @param {String} query | |
* @param {CKEDITOR.dom.range} range | |
*/ | |
setQuery: function( query, range ) { | |
var that = this, | |
requestId = CKEDITOR.tools.getNextId(); | |
this.lastRequestId = requestId; | |
this.query = query; | |
this.range = range; | |
this.data = null; | |
this.selectedItemId = null; | |
this.dataCallback( { | |
query: query, | |
range: range | |
}, handleData ); | |
// Note: don't put any executable code here because the callback passed to | |
// this.dataCallback may be executed synchronously or asynchronously | |
// so execution order will differ. | |
function handleData( data ) { | |
// Handle only the response for the most recent setQuery call. | |
if ( requestId == that.lastRequestId ) { | |
// Limit number of items (#2030). | |
if ( that.itemsLimit ) { | |
that.data = data.slice( 0, that.itemsLimit ); | |
} else { | |
that.data = data; | |
} | |
that.fire( 'change-data', that.data ); | |
} | |
} | |
} | |
}; | |
CKEDITOR.event.implementOn( Model.prototype ); | |
/** | |
* An abstract class representing one {@link CKEDITOR.plugins.autocomplete.model#data data item}. | |
* A item can be understood as one entry in the autocomplete panel. | |
* | |
* An item must have a unique {@link #id} and may have more properties which can then be used, for example, | |
* in the {@link CKEDITOR.plugins.autocomplete.view#itemTemplate} template or the | |
* {@link CKEDITOR.plugins.autocomplete#getHtmlToInsert} method. | |
* | |
* Example items: | |
* | |
* ```javascript | |
* { id: 345, name: 'CKEditor' } | |
* { id: 'smile1', alt: 'smile', emojiSrc: 'emojis/smile.png' } | |
* ``` | |
* | |
* @abstract | |
* @class CKEDITOR.plugins.autocomplete.model.item | |
* @since 4.10.0 | |
*/ | |
/** | |
* The unique ID of the item. The ID should not change with time, so two | |
* {@link CKEDITOR.plugins.autocomplete.model#dataCallback} | |
* calls should always result in the same ID for the same logical item. | |
* This can, for example, allow to keep the same item selected when | |
* the data changes. | |
* | |
* **Note:** When using a string as an item, make sure that the string does not | |
* contain any special characters (above all `"[]` characters). This limitation is | |
* due to the simplified way the {@link CKEDITOR.plugins.autocomplete.view} | |
* stores IDs in the DOM. | |
* | |
* @readonly | |
* @property {Number/String} id | |
*/ | |
CKEDITOR.plugins.autocomplete = Autocomplete; | |
Autocomplete.view = View; | |
Autocomplete.model = Model; | |
/** | |
* The autocomplete keystrokes used to finish autocompletion with the selected view item. | |
* This setting will set completing keystrokes for each autocomplete plugin respectively. | |
* | |
* To change completing keystrokes individually use the {@link CKEDITOR.plugins.autocomplete#commitKeystrokes} plugin property. | |
* | |
* ```javascript | |
* // Default configuration (9 = Tab, 13 = Enter). | |
* config.autocomplete_commitKeystrokes = [ 9, 13 ]; | |
* ``` | |
* | |
* Commit keystroke can also be disabled by setting it to an empty array. | |
* | |
* ```javascript | |
* // Disable autocomplete commit keystroke. | |
* config.autocomplete_commitKeystrokes = []; | |
* ``` | |
* | |
* @since 4.10.0 | |
* @cfg {Number/Number[]} [autocomplete_commitKeystrokes=[9, 13]] | |
* @member CKEDITOR.config | |
*/ | |
CKEDITOR.config.autocomplete_commitKeystrokes = [ 9, 13 ]; | |
// Viewport on iOS is moved into iframe parent element because of https://bugs.webkit.org/show_bug.cgi?id=149264 issue. | |
// Once upstream issue is resolved this function should be removed and its concurrences should be refactored to | |
// follow the default code path. | |
function iOSViewportElement( editor ) { | |
return editor.window.getFrame().getParent(); | |
} | |
function encodeItem( item ) { | |
return CKEDITOR.tools.array.reduce( CKEDITOR.tools.objectKeys( item ), function( cur, key ) { | |
cur[ key ] = CKEDITOR.tools.htmlEncode( item[ key ] ); | |
return cur; | |
}, {} ); | |
} | |
/** | |
* Abstract class describing the definition of the [Autocomplete](https://ckeditor.com/cke4/addon/autocomplete) plugin configuration. | |
* | |
* It lists properties used to define and create autocomplete configuration definition. | |
* | |
* Simple usage: | |
* | |
* ```javascript | |
* var definition = { | |
* dataCallback: dataCallback, | |
* textTestCallback: textTestCallback, | |
* throttle: 200 | |
* }; | |
* ``` | |
* | |
* @class CKEDITOR.plugins.autocomplete.configDefinition | |
* @abstract | |
* @since 4.10.0 | |
*/ | |
/** | |
* Callback executed to get suggestion data based on the search query. The returned data will be | |
* displayed in the autocomplete view. | |
* | |
* ```javascript | |
* // Returns (through its callback) the suggestions for the current query. | |
* // Note: The itemsArray variable is the example "database". | |
* function dataCallback( matchInfo, callback ) { | |
* // Simple search. | |
* // Filter the entire items array so only the items that start | |
* // with the query remain. | |
* var suggestions = itemsArray.filter( function( item ) { | |
* return item.name.indexOf( matchInfo.query ) === 0; | |
* } ); | |
* | |
* // Note: The callback function can also be executed asynchronously | |
* // so dataCallback can do an XHR request or use any other asynchronous API. | |
* callback( suggestions ); | |
* } | |
* | |
* ``` | |
* | |
* @method dataCallback | |
* @param {CKEDITOR.plugins.autocomplete.matchInfo} matchInfo | |
* @param {Function} callback The callback which should be executed with the matched data. | |
* @param {CKEDITOR.plugins.autocomplete.model.item[]} callback.data The suggestion data that should be | |
* displayed in the autocomplete view for a given query. The data items should implement the | |
* {@link CKEDITOR.plugins.autocomplete.model.item} interface. | |
*/ | |
/** | |
* Callback executed to check if a text next to the selection should open | |
* the autocomplete. See the {@link CKEDITOR.plugins.textWatcher}'s `callback` argument. | |
* | |
* ```javascript | |
* // Called when the user types in the editor or moves the caret. | |
* // The range represents the caret position. | |
* function textTestCallback( range ) { | |
* // You do not want to autocomplete a non-empty selection. | |
* if ( !range.collapsed ) { | |
* return null; | |
* } | |
* | |
* // Use the text match plugin which does the tricky job of doing | |
* // a text search in the DOM. The matchCallback function should return | |
* // a matching fragment of the text. | |
* return CKEDITOR.plugins.textMatch.match( range, matchCallback ); | |
* } | |
* | |
* // Returns a position of the matching text. | |
* // It matches with a word starting from the '@' character | |
* // up to the caret position. | |
* function matchCallback( text, offset ) { | |
* // Get the text before the caret. | |
* var left = text.slice( 0, offset ), | |
* // Will look for an '@' character followed by word characters. | |
* match = left.match( /@\w*$/ ); | |
* | |
* if ( !match ) { | |
* return null; | |
* } | |
* return { start: match.index, end: offset }; | |
* } | |
* ``` | |
* | |
* @method textTestCallback | |
* @param {CKEDITOR.dom.range} range Range representing the caret position. | |
*/ | |
/** | |
* @inheritdoc CKEDITOR.plugins.autocomplete#throttle | |
* @property {Number} [throttle] | |
*/ | |
/** | |
* @inheritdoc CKEDITOR.plugins.autocomplete.model#itemsLimit | |
* @property {Number} [itemsLimit] | |
*/ | |
/** | |
* @inheritdoc CKEDITOR.plugins.autocomplete.view#itemTemplate | |
* @property {String} [itemTemplate] | |
*/ | |
/** | |
* @inheritdoc CKEDITOR.plugins.autocomplete#outputTemplate | |
* @property {String} [outputTemplate] | |
*/ | |
/** | |
* Abstract class describing a set of properties that can be used to produce more adequate suggestion data based on the matched query. | |
* | |
* @class CKEDITOR.plugins.autocomplete.matchInfo | |
* @abstract | |
* @since 4.10.0 | |
*/ | |
/** | |
* The query string that was accepted by the | |
* {@link CKEDITOR.plugins.autocomplete.configDefinition#textTestCallback config.textTestCallback}. | |
* | |
* @property {String} query | |
*/ | |
/** | |
* The range in the DOM indicating the position of the {@link #query}. | |
* | |
* @property {CKEDITOR.dom.range} range | |
*/ | |
/** | |
* The {@link CKEDITOR.plugins.autocomplete Autocomplete} instance that matched the query. | |
* | |
* @property {CKEDITOR.plugins.autocomplete} autocomplete | |
*/ | |
} )(jQuery); | |