/** * @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 = `#{ticket} ({name})`; * ``` * * @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( '
  • ... {name}
  • ' ); * * 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 = '
  • {name}{name}
  • '; * ``` * * @readonly * @property {CKEDITOR.template} */ this.itemTemplate = new CKEDITOR.template( '
  • {name}
  • ' ); /** * 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);