nmitchko's picture
Upload 77 files
1e4c25f
raw
history blame contribute delete
No virus
47.1 kB
/**
* @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 &mdash; 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);