diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/plugin.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/plugin.js new file mode 100644 index 0000000..6924973 --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/plugin.js @@ -0,0 +1,1482 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +'use strict'; + +( 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 + */ +} )(); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/customview.html b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/customview.html new file mode 100644 index 0000000..dd433cd --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/customview.html @@ -0,0 +1,162 @@ + + + + + + Autocomplete Custom View — CKEditor Sample + + + + + + + + + + + +
    +
    +

    + CKEditor Sample +

    +
    +
    + +
    +
    +
    +
    +

    Autocomplete Custom View Demo

    +

    This sample shows the progress of work on Autocomplete with custom View. Type “ @ ” (at least 2 characters) to start autocompletion.

    +
    +
    +
    +
    +
    +
    +
    +

    Apollo 11

    +
    + Saturn V carrying Apollo 11 +
    +

    Apollo 11 was the spaceflight that landed the first humans, Americans Neil Armstrong and Buzz Aldrin, on the Moon on July 20, 1969, at 20:18 UTC. Armstrong became the first to step onto the lunar surface 6 hours later on July 21 at 02:56 UTC.

    +
    + Saturn V carrying Apollo 11 +
    Saturn V carrying Apollo 11
    +
    +

    Armstrong spent about three and a half two and a half hours outside the spacecraft, Aldrin slightly less; and together they collected 47.5 pounds (21.5 kg) of lunar material for return to Earth. A third member of the mission, Michael Collins, piloted the command spacecraft alone in lunar orbit until Armstrong and Aldrin returned to it for the trip back to Earth.

    +
    +
    +
    +
    +
    + + + + + + diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/smiley.html b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/smiley.html new file mode 100644 index 0000000..af9bcca --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/smiley.html @@ -0,0 +1,172 @@ + + + + + + Autocomplete Smileys — CKEditor Sample + + + + + + + + + + + +
    +
    +

    + CKEditor Sample +

    +
    +
    + +
    +
    +
    +
    +

    Autocomplete Smileys Demo

    +

    This sample shows the progress of work on Autocomplete with Smileys integration. Type “ : ” to start smileys autocompletion.

    +
    +
    +
    +
    +
    +
    +
    +

    Apollo 11

    +
    + Saturn V carrying Apollo 11 +
    +

    Apollo 11 was the spaceflight that landed the first humans, Americans Neil Armstrong and Buzz Aldrin, on the Moon on July 20, 1969, at 20:18 UTC. Armstrong became the first to step onto the lunar surface 6 hours later on July 21 at 02:56 UTC.

    +
    + Saturn V carrying Apollo 11 +
    Saturn V carrying Apollo 11
    +
    +

    Armstrong spent about three and a half two and a half hours outside the spacecraft, Aldrin slightly less; and together they collected 47.5 pounds (21.5 kg) of lunar material for return to Earth. A third member of the mission, Michael Collins, piloted the command spacecraft alone in lunar orbit until Armstrong and Aldrin returned to it for the trip back to Earth.

    +
    +
    +
    +
    +
    + + + + + + + diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/utils.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/utils.js new file mode 100644 index 0000000..fa3961a --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/samples/utils.js @@ -0,0 +1,97 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* exported autocompleteUtils */ + +'use strict'; + +var autocompleteUtils = { + generateData: function( object, prefix ) { + return Object.keys( object ).sort().map( function( prop, index ) { + return { + id: index, + name: prefix + prop + }; + } ); + }, + + getAsyncDataCallback: function( data ) { + return function( query, range, callback ) { + setTimeout( function() { + callback( + data.filter( function( item ) { + return item.name.indexOf( query ) === 0; + } ) + ); + }, Math.random() * 500 ); + }; + }, + + getSyncDataCallback: function( data ) { + return function( query, range, callback ) { + callback( + data.filter( function( item ) { + return item.name.indexOf( query ) === 0; + } ) + ); + }; + }, + + getTextTestCallback: function( prefix, minChars, requireSpaceAfter ) { + var matchPattern = createPattern(); + + return function( range ) { + if ( !range.collapsed ) { + return null; + } + + return CKEDITOR.plugins.textMatch.match( range, matchCallback ); + }; + + function matchCallback( text, offset ) { + var left = text.slice( 0, offset ), + right = text.slice( offset ), + match = left.match( matchPattern ); + + if ( !match ) { + return null; + } + + if ( requireSpaceAfter ) { + // Require space (or end of text) after the caret. + if ( right && !right.match( /^\s/ ) ) { + return null; + } + } + + return { start: match.index, end: offset }; + } + + function createPattern() { + var pattern = prefix + '\\w'; + + if ( minChars ) { + pattern += '{' + minChars + ',}'; + } else { + pattern += '*'; + } + + pattern += '$'; + + return new RegExp( pattern ); + } + } +}; + + + + + + + + + + + diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/skins/default.css b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/skins/default.css new file mode 100644 index 0000000..1e6ee6d --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/autocomplete/skins/default.css @@ -0,0 +1,38 @@ +/* +Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. +For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license +*/ + +.cke_autocomplete_panel +{ + position: absolute; + display: none; + box-sizing: border-box; + width: 200px; + max-height: 300px; + overflow: auto; + padding: 0; + margin: 0; + list-style: none; + background: #FFF; + border: 1px solid #b6b6b6; + border-bottom-color: #999; + border-radius: 3px; + font: 12px Arial, Helvetica, Tahoma, Verdana, Sans-Serif; +} +.cke_autocomplete_opened +{ + display: block; +} +.cke_autocomplete_panel > li +{ + padding: 5px; +} +.cke_autocomplete_panel > li:hover +{ + cursor: pointer; +} +.cke_autocomplete_selected, .cke_autocomplete_panel > li:hover +{ + background-color: #EFF0EF; +} diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/dialogs/redmineMacro.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/dialogs/redmineMacro.js new file mode 100644 index 0000000..d7a160e --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/dialogs/redmineMacro.js @@ -0,0 +1,38 @@ + +'use strict'; + +CKEDITOR.dialog.add( 'redmineMacro', function( editor ) { + var lang = editor.lang.redmine, + generalLabel = editor.lang.common.generalTab, + validNameRegex = /^[^\[\]<>]+$/; + + return { + title: lang.editMacro, + minWidth: 300, + minHeight: 80, + contents: [ + { + id: 'info', + label: generalLabel, + title: generalLabel, + elements: [ + { + id: 'name', + type: 'text', + style: 'width: 100%;', + label: lang.editMacroProperties, + 'default': '', + required: true, + validate: CKEDITOR.dialog.validate.regex( validNameRegex, lang.invalidMacro ), + setup: function( widget ) { + this.setValue( widget.data.name ); + }, + commit: function( widget ) { + widget.setData( 'name', this.getValue() ); + } + } + ] + } + ] + }; +} ); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/dialogs/redmineWikilink.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/dialogs/redmineWikilink.js new file mode 100644 index 0000000..1521a55 --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/dialogs/redmineWikilink.js @@ -0,0 +1,44 @@ + +'use strict'; + +CKEDITOR.dialog.add( 'redmineWikilink', function( editor ) { + var lang = editor.lang.redmine, + generalLabel = editor.lang.common.generalTab, + validNameRegex = /^[^\[\]<>]+$/; + + return { + title: lang.wikiPageLink, + minWidth: 300, + minHeight: 80, + contents: [ + { + id: 'info', + label: generalLabel, + title: generalLabel, + elements: [ + { + id: 'name', + type: 'text', + style: 'width: 100%;', + label: lang.wikiPage, + 'default': '', + required: true, + validate: CKEDITOR.dialog.validate.regex( validNameRegex, lang.invalidWikiPageLink ), + setup: function( widget ) { + this.setValue( widget.data.name ); + }, + commit: function( widget ) { + widget.setData( 'name', this.getValue() ); + } + }, + { + type: 'html', + html: '- ' + lang.help_line1 + '
    ' + + '- ' + lang.help_line2 + '
    ' + + '- ' + lang.help_line3 + } + ] + } + ] + }; +} ); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/icons/hidpi/wikipage.png b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/icons/hidpi/wikipage.png new file mode 100644 index 0000000..25d2361 Binary files /dev/null and b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/icons/hidpi/wikipage.png differ diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/icons/wikipage.png b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/icons/wikipage.png new file mode 100644 index 0000000..8d5b819 Binary files /dev/null and b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/icons/wikipage.png differ diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/lang/en.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/lang/en.js new file mode 100755 index 0000000..dd5eaee --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/lang/en.js @@ -0,0 +1,32 @@ + +CKEDITOR.plugins.setLang( 'redmine', 'en', { + // Dialogs. + wikiPageLink: 'Link to WikiPage', + invalidWikiPageLink: 'The link can not be empty and can not contain any of following characters: [, ], <, >', + help_line1: 'Also WikiPage#Anchor; or', + help_line2: 'WikiPage|TextLink or WikiPage#Anchor|TextLink; or', + help_line3: 'link to pages of another project using project:WikiPage.', + + editMacro: 'Edit Macro', + editMacroProperties: 'Edit Macro Properties', + invalidMacro: 'It can not be empty and can not contain any of following characters: [, ], <, >', + + // Plugin. + pathName: 'redmineWidget', + + // Autocomplete WikiLinks. + wikiPage: 'WikiPage', + anchor: 'Anchor', + textLink: 'TextLink', + project: 'project', + wikipage_linkTo: 'Link to ', + + // Autocomplete Macros. + toc: 'Table of contents.', + child_pages: 'List of child pages.', + child_pages_depth: 'List of child pages with 2 levels.', + include_page: 'Page', + include: 'Include a wiki page.', + include_project: 'project', + include_from_project: 'Include a page of a specific project wiki.' +}); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/lang/es.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/lang/es.js new file mode 100755 index 0000000..d82b43e --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/lang/es.js @@ -0,0 +1,32 @@ + +CKEDITOR.plugins.setLang( 'redmine', 'es', { + // Dialogs. + wikiPageLink: 'Enlace a Página del Wiki', + invalidWikiPageLink: 'El enlace no puede estar vacío y no puede contener ninguno de los siguientes caracteres: [, ], <, >', + help_line1: 'También Página#Referencia; o', + help_line2: 'Página|TextoEnlace o Página#Referencia|TextoEnlace; o', + help_line3: 'enlazar a páginas de otro proyecto usando proyecto:Página.', + + editMacro: 'Editar Macro', + editMacroProperties: 'Editar Propiedades de la Macro', + invalidMacro: 'No puede estar vacío y no puede contener ninguno de los siguientes caracteres: [, ], <, >', + + // Plugin. + pathName: 'redmineWidget', + + // Autocomplete WikiLinks. + wikiPage: 'Página', + anchor: 'Referencia', + textLink: 'TextoEnlace', + project: 'proyecto', + wikipage_linkTo: 'Enlace a ', + + // Autocomplete Macros. + toc: 'Tabla de contenidos.', + child_pages: 'Lista de páginas hijas.', + child_pages_depth: 'Lista de páginas hijas con 2 niveles.', + include_page: 'Página', + include: 'Incluye una página del wiki.', + include_project: 'proyecto', + include_from_project: 'Incluye una página del wiki de otro proyecto.' +}); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/plugin.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/plugin.js index 4335dd6..c2d8681 100644 --- a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/plugin.js +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/redmine/plugin.js @@ -1,59 +1,322 @@ + +'use strict'; + (function(){ - function preservedPattern(i) { - return "____preserved_" + i + "____"; - } +/* + function preservedPattern( i ) { + return "____preserved_" + i + "____"; + } - function wrapConversion(f) { - return function(data) { - var preserved = []; + function wrapConversion( f ) { + return function( data ) { + var preserved = []; - // preserve Redmine macro - data = data.replace(/\{\{.*?\}\}/g, function(match) { - preserved.push(decodeEntities(match)); - return preservedPattern(preserved.length); - }); + // Preserve Redmine macro. + data = data.replace( /\{\{.*?\}\}/g, function( match ) { + preserved.push( decodeEntities( match ) ); + return preservedPattern( preserved.length ); + }); - // convert - arguments[0] = data; - data = f.apply(this, arguments); + // Convert. + arguments[0] = data; + data = f.apply( this, arguments ); - // restore - for (var i = 0; i < preserved.length; i++) { - data = data.replace(preservedPattern(i + 1), preserved[i]); - } + // Restore. + for ( var i = 0; i < preserved.length; i++ ) { + data = data.replace( preservedPattern( i + 1 ), preserved[i] ); + } - return data; - }; - } + return data; + }; + } +*/ + var element = document.createElement( 'div' ); + function decodeEntities( html ) { + element.innerHTML = html; + html = element.textContent; + element.textContent = ''; + return html; + } - var element = document.createElement('div'); - function decodeEntities(html) { - element.innerHTML = html; - html = element.textContent; - element.textContent = ''; - return html; - } + function onText( text, node ) { + return ( node.parent.name == "a" ) ? text : text.replace( /(^|\s)https?:\/\/\S*/g, decodeEntities ); + } - function onText(text, node) { - return (node.parent.name == "a") ? - text : text.replace(/(^|\s)https?:\/\/\S*/g, decodeEntities); - } + // Register the plugin in the editor. + CKEDITOR.plugins.add( 'redmine', { + requires: 'widget,dialog,autocomplete,textmatch', + lang: 'en,es', // %REMOVE_LINE_CORE% + icons: 'wikipage', // %REMOVE_LINE_CORE% + hidpi: true, // %REMOVE_LINE_CORE% - CKEDITOR.plugins.add('redmine', { - afterInit: function(editor) { - var processor = editor.dataProcessor; + onLoad: function() { + // Register styles for placeholder widget frame. + CKEDITOR.addCss( '.cke_placeholder{padding:0 4px;margin:0 2px;background-color:#ff0}' ); + }, - processor.toHtml = wrapConversion(processor.toHtml); - processor.toDataFormat = wrapConversion(processor.toDataFormat); - processor.htmlFilter.addRules({text: onText}, 11); - processor.dataFilter.addRules({text: onText}, 11); - } - }); + init: function( editor ) { + + var lang = editor.lang.redmine; + + // Register Wikilink Widget. + CKEDITOR.dialog.add( 'redmineWikilink', this.path + 'dialogs/redmineWikilink.js' ); + editor.widgets.add( 'redmineWikilink', { + dialog: 'redmineWikilink', + pathName: lang.pathName, + template: '[[]]', + + downcast: function() { + return new CKEDITOR.htmlParser.text( '[[' + this.data.name + ']]' ); + }, + init: function() { + this.setData( 'name', this.element.getText().slice( 2, -2 ) ); + }, + data: function() { + this.element.setText( '[[' + this.data.name + ']]' ); + }, + getLabel: function() { + return this.editor.lang.widget.label.replace( /%1/, this.data.name + ' ' + this.pathName ); + } + } ); + editor.ui.addButton && editor.ui.addButton( 'LinkWikiPage', { + label: lang.wikiPageLink, + command: 'redmineWikilink', + toolbar: 'insert,5', + icon: 'wikipage' + } ); + + // Register Macro Widget. + CKEDITOR.dialog.add( 'redmineMacro', this.path + 'dialogs/redmineMacro.js' ); + editor.widgets.add( 'redmineMacro', { + dialog: 'redmineMacro', + pathName: lang.pathName, + template: '{{}}', + + downcast: function() { + return new CKEDITOR.htmlParser.text( '{{' + this.data.name + '}}' ); + }, + init: function() { + this.setData( 'name', this.element.getText().slice( 2, -2 ) ); + }, + data: function() { + this.element.setText( '{{' + this.data.name + '}}' ); + }, + getLabel: function() { + return this.editor.lang.widget.label.replace( /%1/, this.data.name + ' ' + this.pathName ); + } + } ); + + editor.on( 'instanceReady', function() { + var config = {}; + + // 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 performing + // 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 a word starting from '{{' or the '#' character + // up to the caret position. + function matchCallback( text, offset ) { + var match; + + // Will look for a '[[' characters followed by a wiki page name. + match = text.slice( 0, offset ).match( /\[{2}([A-z]|\])*$/ ); + if ( match ) { + return { + start: match.index, + end: offset + }; + } + + // Will look for a '{{' characters followed by a macro name. + match = text.slice( 0, offset ).match( /\{{2}([A-z]|\})*$/ ); + if ( match ) { + return { + start: match.index, + end: offset + }; + } + + /* Will look for a '#' character followed by a issue number. + match = text.slice( 0, offset ).match( /#\d*$/ ); + if ( match ) { + return { + start: match.index, + end: offset + }; + } */ + + return null; + } + + config.textTestCallback = textTestCallback; + + // WikiPage links sugestions. + var itemsWikiPageArray = [ + { + id: 1, + title: '[[' + lang.wikiPage + ']]', + description: lang.wikipage_linkTo + lang.wikiPage + '.' + }, + { + id: 2, + title: '[[' + lang.wikiPage + '#' + lang.anchor + ']]', + description: lang.wikipage_linkTo + lang.wikiPage + '#' + lang.anchor + '.' + }, + { + id: 3, + title: '[[' + lang.wikiPage + '|' + lang.textLink + ']]', + description: lang.wikipage_linkTo + lang.wikiPage + '|' + lang.textLink + '.' + }, + { + id: 4, + title: '[[' + lang.project + ':' + lang.wikiPage + ']]', + description: lang.wikipage_linkTo + lang.project + ':' + lang.wikiPage + '.' + } + ]; + // Macro sugestions. + var itemsMacroArray = [ + { + id: 1, + title: '{{toc}}', + description: lang.toc + }, + { + id: 2, + title: '{{child_pages}}', + description: lang.child_pages + }, + { + id: 3, + title: '{{child_pages(depth=2)}}', + description: lang.child_pages_depth + }, + { + id: 4, + title: '{{include(' + lang.include_page + ')}}', + description: lang.include + }, + { + id: 5, + title: '{{include(' + lang.include_project + ':' + lang.include_page + ')}}', + description: lang.include_from_project + } + ]; + + // Returns (through its callback) the suggestions for the current query. + function dataCallback( matchInfo, callback ) { + var itemsArray; + if (matchInfo.query.substring( 0, 2 ) == '[[' ) { + itemsArray = itemsWikiPageArray; + } + else if (matchInfo.query.substring( 0, 2 ) == '{{' ) { + itemsArray = itemsMacroArray; + } + var suggestions = itemsArray.filter( function( item ) { + return item.title.indexOf( matchInfo.query.toLowerCase() ) == 0; + }); + + callback( suggestions ); + + /* Remove the '#' tag. + var query = matchInfo.query.substring( 1 ); + + // Simple search. + // Filter the entire items array so only the items that start + // with the query remain. + var suggestions = itemsArray.filter( function( item ) { + return String( item.id ).indexOf( 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 ); */ + } + + config.dataCallback = dataCallback; + + // Define the templates of the autocomplete suggestions dropdown and output text. + config.itemTemplate = '
  • {title}
    {description}
  • '; + config.outputTemplate = "{title}"; + + // Attach autocomplete to the editor. + new CKEDITOR.plugins.autocomplete( editor, config ); + } ); + }, + + afterInit: function( editor ) { + var processor = editor.dataProcessor; + + processor.dataFilter.addRules( { + text: function( text, node ) { + var dtd = node.parent && CKEDITOR.dtd[ node.parent.name ]; + + if ( dtd && !dtd.span ) + return; + + return text.replace( /\[\[([^\[\]])+\]\]/g, function( match ) { + // Creating widget code. + var widgetWrapper = null, + innerElement = new CKEDITOR.htmlParser.element( 'span', { + 'class': 'cke_placeholder' + } ); + + // Adds placeholder identifier as innertext. + innerElement.add( new CKEDITOR.htmlParser.text( match ) ); + widgetWrapper = editor.widgets.wrapElement( innerElement, 'redmineWikilink' ); + + // Return outerhtml of widget wrapper so it will be placed as replacement. + return widgetWrapper.getOuterHtml(); + } ); + } + } ); + processor.dataFilter.addRules( { + text: function( text, node ) { + var dtd = node.parent && CKEDITOR.dtd[ node.parent.name ]; + + if ( dtd && !dtd.span ) + return; + + return text.replace( /\{\{([^\{\}])+\}\}/g, function( match ) { + // Creating widget code. + var widgetWrapper = null, + innerElement = new CKEDITOR.htmlParser.element( 'span', { + 'class': 'cke_placeholder' + } ); + + // Adds placeholder identifier as innertext. + innerElement.add( new CKEDITOR.htmlParser.text( match ) ); + widgetWrapper = editor.widgets.wrapElement( innerElement, 'redmineMacro' ); + + // Return outerhtml of widget wrapper so it will be placed as replacement. + return widgetWrapper.getOuterHtml(); + } ); + } + } ); + processor.htmlFilter.addRules( { text: onText }, 11 ); + processor.dataFilter.addRules( { text: onText }, 11 ); +/* + processor.toHtml = wrapConversion( processor.toHtml ); + processor.toDataFormat = wrapConversion( processor.toDataFormat ); +*/ + } + } ); + + CKEDITOR.on( 'dialogDefinition', function( e ) { + if ( e.data.name == 'table' ) { + var width = e.data.definition.getContents( 'info' ).get( 'txtWidth' ); + width['default'] = "100%"; + } + } ); - CKEDITOR.on('dialogDefinition', function(e) { - if (e.data.name == 'table') { - var width = e.data.definition.getContents('info').get('txtWidth'); - width['default'] = "100%"; - } - }); })(); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/textmatch/plugin.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/textmatch/plugin.js new file mode 100644 index 0000000..7b034bb --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/textmatch/plugin.js @@ -0,0 +1,331 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +'use strict'; + +( function() { + + CKEDITOR.plugins.add( 'textmatch', {} ); + + /** + * A global namespace for methods exposed by the [Text Match](https://ckeditor.com/cke4/addon/textmatch) plugin. + * + * The most important function is {@link #match} which performs a text + * search in the DOM. + * + * @singleton + * @class + * @since 4.10.0 + */ + CKEDITOR.plugins.textMatch = {}; + + /** + * Allows to search in the DOM for matching text using a callback which operates on strings instead of text nodes. + * Returns {@link CKEDITOR.dom.range} and the matching text. + * + * ```javascript + * var range = editor.getSelection().getRanges()[ 0 ]; + * + * CKEDITOR.plugins.textMatch.match( range, function( text, offset ) { + * // Let's assume that text is 'Special thanks to #jo.' and offset is 21. + * // The offset "21" means that the caret is between '#jo' and '.'. + * + * // Get the text before the caret. + * var left = text.slice( 0, offset ), + * // Will look for a literal '#' character and at least two word characters. + * match = left.match( /#\w{2,}$/ ); + * + * if ( !match ) { + * return null; + * } + * + * // The matching fragment is the '#jo', which can + * // be identified by the following offsets: { start: 18, end: 21 }. + * return { start: match.index, end: offset }; + * } ); + * ``` + * + * @member CKEDITOR.plugins.textMatch + * @param {CKEDITOR.dom.range} range A collapsed range — the position from which the scanning starts. + * Usually the caret position. + * @param {Function} testCallback A callback executed to check if the text matches. + * @param {String} testCallback.text The full text to check. + * @param {Number} testCallback.rangeOffset An offset of the `range` in the `text` to be checked. + * @param {Object} [testCallback.return] The position of the matching fragment (`null` if nothing matches). + * @param {Number} testCallback.return.start The offset of the start of the matching fragment. + * @param {Number} testCallback.return.end The offset of the end of the matching fragment. + * + * @returns {Object/null} An object with information about the matching text or `null`. + * @returns {String} return.text The matching text. + * The text does not reflect the range offsets. The range could contain additional, + * browser-related characters like {@link CKEDITOR.dom.selection#FILLING_CHAR_SEQUENCE}. + * @returns {CKEDITOR.dom.range} return.range A range in the DOM for the text that matches. + */ + CKEDITOR.plugins.textMatch.match = function( range, callback ) { + var textAndOffset = CKEDITOR.plugins.textMatch.getTextAndOffset( range ), + fillingCharSequence = CKEDITOR.dom.selection.FILLING_CHAR_SEQUENCE, + fillingSequenceOffset = 0; + + if ( !textAndOffset ) { + return; + } + + // Remove filling char sequence for clean query (#2038). + if ( textAndOffset.text.indexOf( fillingCharSequence ) == 0 ) { + fillingSequenceOffset = fillingCharSequence.length; + + textAndOffset.text = textAndOffset.text.replace( fillingCharSequence, '' ); + textAndOffset.offset -= fillingSequenceOffset; + } + + var result = callback( textAndOffset.text, textAndOffset.offset ); + + if ( !result ) { + return null; + } + + return { + range: CKEDITOR.plugins.textMatch.getRangeInText( range, result.start, result.end + fillingSequenceOffset ), + text: textAndOffset.text.slice( result.start, result.end ) + }; + }; + + /** + * Returns a text (as a string) in which the DOM range is located (the function scans for adjacent text nodes) + * and the offset of the caret in that text. + * + * ## Examples + * + * * `{}` is the range position in the text node (it means that the text node is **not** split at that position). + * * `[]` is the range position in the element (it means that the text node is split at that position). + * * `.` is a separator for text nodes (it means that the text node is split at that position). + * + * Examples: + * + * ``` + * Input:

    he[]llo

    + * Result: { text: 'hello', offset: 2 } + * + * Input:

    he.llo{}

    + * Result: { text: 'hello', offset: 5 } + * + * Input:

    {}he.llo

    + * Result: { text: 'hell', offset: 0 } + * + * Input:

    he{}llo

    + * Result: { text: 'he', offset: 2 } + * + * Input:

    hello.m{}y.friend

    + * Result: { text: 'omyfriend', offset: 2 } + * ``` + * + * @member CKEDITOR.plugins.textMatch + * @param {CKEDITOR.dom.range} range + * @returns {Object/null} + * @returns {String} return.text The text in which the DOM range is located. + * @returns {Number} return.offset An offset of the caret. + */ + CKEDITOR.plugins.textMatch.getTextAndOffset = function( range ) { + if ( !range.collapsed ) { + return null; + } + + var text = '', offset = 0, + textNodes = CKEDITOR.plugins.textMatch.getAdjacentTextNodes( range ), + nodeReached = false, + elementIndex, + startContainerIsText = ( range.startContainer.type != CKEDITOR.NODE_ELEMENT ); + + if ( startContainerIsText ) { + // Determining element index in textNodes array. + elementIndex = indexOf( textNodes, function( current ) { + return range.startContainer.equals( current ); + } ); + } else { + // Based on range startOffset decreased by first text node index. + elementIndex = range.startOffset - ( textNodes[ 0 ] ? textNodes[ 0 ].getIndex() : 0 ); + } + + var max = textNodes.length; + for ( var i = 0; i < max; i += 1 ) { + var currentNode = textNodes[ i ]; + text += currentNode.getText(); + + // We want to increase text offset only when startContainer is not reached. + if ( !nodeReached ) { + if ( startContainerIsText ) { + if ( i == elementIndex ) { + nodeReached = true; + offset += range.startOffset; + } else { + offset += currentNode.getText().length; + } + } else { + if ( i == elementIndex ) { + nodeReached = true; + } + + // In below example there are three text nodes in p element and four possible offsets ( 0, 1, 2, 3 ) + // We are going to increase offset while iteration: + // index 0 ==> 0 + // index 1 ==> 3 + // index 2 ==> 3 + 3 + // index 3 ==> 3 + 3 + 2 + + //

    foo bar ba

    + // 0^^^1^^^2^^3 + if ( i > 0 ) { + offset += textNodes[ i - 1 ].getText().length; + } + + // If element index at last element we also want to increase offset. + if ( max == elementIndex && i + 1 == max ) { + offset += currentNode.getText().length; + } + } + } + } + + return { + text: text, + offset: offset + }; + }; + + /** + * Transforms the `start` and `end` offsets in the text generated by the {@link #getTextAndOffset} + * method into a DOM range. + * + * ## Examples + * + * * `{}` is the range position in the text node (it means that the text node is **not** split at that position). + * * `.` is a separator for text nodes (it means that the text node is split at that position). + * + * Examples: + * + * ``` + * Input:

    f{}oo.bar

    , 0, 3 + * Result:

    {foo}.bar

    + * + * Input:

    f{}oo.bar

    , 1, 5 + * Result:

    f{oo.ba}r

    + * ``` + * + * @member CKEDITOR.plugins.textMatch + * @param {CKEDITOR.dom.range} range + * @param {Number} start A start offset. + * @param {Number} end An end offset. + * @returns {CKEDITOR.dom.range} Transformed range. + */ + CKEDITOR.plugins.textMatch.getRangeInText = function( range, start, end ) { + var resultRange = new CKEDITOR.dom.range( range.root ), + elements = CKEDITOR.plugins.textMatch.getAdjacentTextNodes( range ), + startData = findElementAtOffset( elements, start ), + endData = findElementAtOffset( elements, end ); + + resultRange.setStart( startData.element, startData.offset ); + resultRange.setEnd( endData.element, endData.offset ); + + return resultRange; + }; + + /** + * Creates a collection of adjacent text nodes which are between DOM elements, starting from the given range. + * This function works only for collapsed ranges. + * + * ## Examples + * + * * `{}` is the range position in the text node (it means that the text node is **not** split at that position). + * * `.` is a separator for text nodes (it means that the text node is split at that position). + * + * Examples: + * + * ``` + * Input:

    he.llo{}

    + * Result: [ 'he', 'llo' ] + * + * Input:

    {}he.llo

    + * Result: [ 'he', 'll' ] + * + * Input:

    he{}llo.

    + * Result: [ 'he' ] + * + * Input:

    hell{}o.my.friend

    + * Result: [ 'o', 'my', 'friend' ] + * ``` + * + * @member CKEDITOR.plugins.textMatch + * @param {CKEDITOR.dom.range} range + * @return {CKEDITOR.dom.text[]} An array of text nodes. + */ + CKEDITOR.plugins.textMatch.getAdjacentTextNodes = function( range ) { + if ( !range.collapsed ) { + throw new Error( 'Range must be collapsed.' ); // %REMOVE_LINE% + // Reachable in prod mode. + return []; // jshint ignore:line + } + + var collection = [], + siblings, + elementIndex, + node, i; + + if ( range.startContainer.type != CKEDITOR.NODE_ELEMENT ) { + siblings = range.startContainer.getParent().getChildren(); + elementIndex = range.startContainer.getIndex(); + } else { + siblings = range.startContainer.getChildren(); + elementIndex = range.startOffset; + } + + i = elementIndex; + while ( node = siblings.getItem( --i ) ) { + if ( node.type == CKEDITOR.NODE_TEXT ) { + collection.unshift( node ); + } else { + break; + } + } + + i = elementIndex; + while ( node = siblings.getItem( i++ ) ) { + if ( node.type == CKEDITOR.NODE_TEXT ) { + collection.push( node ); + } else { + break; + } + } + + return collection; + }; + + function findElementAtOffset( elements, offset ) { + var max = elements.length, + currentOffset = 0; + for ( var i = 0; i < max; i += 1 ) { + var current = elements[ i ]; + if ( offset >= currentOffset && currentOffset + current.getText().length >= offset ) { + return { + element: current, + offset: offset - currentOffset + }; + } + + currentOffset += current.getText().length; + } + + return null; + } + + function indexOf( arr, checker ) { + for ( var i = 0; i < arr.length; i++ ) { + if ( checker( arr[ i ] ) ) { + return i; + } + } + + return -1; + } +} )(); diff --git a/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/textwatcher/plugin.js b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/textwatcher/plugin.js new file mode 100644 index 0000000..3f72ad7 --- /dev/null +++ b/plugins/redmine_ckeditor/assets/ckeditor-contrib/plugins/textwatcher/plugin.js @@ -0,0 +1,295 @@ +/** + * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +'use strict'; + +( function() { + + CKEDITOR.plugins.add( 'textwatcher', {} ); + + /** + * API exposed by the [Text Watcher](https://ckeditor.com/cke4/addon/textwatcher) plugin. + * + * Class implementing the text watcher — a base for features like + * autocomplete. It fires the {@link #matched} and {@link #unmatched} events + * based on changes in the text and the position of the caret in the editor. + * + * To check whether the text matches some criteria, the text watcher uses + * a callback function which should return the matching text and a {@link CKEDITOR.dom.range} + * for that text. + * + * Since the text watcher works on the DOM where searching for text + * is pretty complicated, it is usually recommended to use the {@link CKEDITOR.plugins.textMatch#match} + * function. + * + * Example: + * + * ```javascript + * 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 ); + * } + * + * 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 }; + * } + * + * // Initialize the text watcher. + * var textWatcher = new CKEDITOR.plugins.textWatcher( editor, textTestCallback ); + * // Start listening. + * textWatcher.attach(); + * + * // Handle text matching. + * textWatcher.on( 'matched', function( evt ) { + * autocomplete.setQuery( evt.data.text ); + * } ); + * ``` + * + * @class CKEDITOR.plugins.textWatcher + * @since 4.10.0 + * @mixins CKEDITOR.event + * @constructor Creates the text watcher instance. + * @param {CKEDITOR.editor} editor The editor instance to watch in. + * @param {Function} callback Callback executed when the text watcher + * thinks that something might have changed. + * @param {Number} [throttle=0] Throttle interval, see {@link #throttle}. + * @param {CKEDITOR.dom.range} callback.range The range representing the caret position. + * @param {Object} [callback.return=null] Matching text data (`null` if nothing matches). + * @param {String} callback.return.text The matching text. + * @param {CKEDITOR.dom.range} callback.return.range A range in the DOM for the text that matches. + */ + function TextWatcher( editor, callback, throttle ) { + /** + * The editor instance which the text watcher watches. + * + * @readonly + * @property {CKEDITOR.editor} + */ + this.editor = editor; + + /** + * The last matched text. + * + * @readonly + * @property {String} + */ + this.lastMatched = null; + + /** + * Whether the next check should be ignored. See the {@link #consumeNext} method. + * + * @readonly + */ + this.ignoreNext = false; + + /** + * The callback passed to the {@link CKEDITOR.plugins.textWatcher} constructor. + * + * @readonly + * @property {Function} + */ + this.callback = callback; + + /** + * Keys that should be ignored by the {@link #check} method. + * + * @readonly + * @property {Number[]} + */ + this.ignoredKeys = [ + 16, // Shift + 17, // Ctrl + 18, // Alt + 91, // Cmd + 35, // End + 36, // Home + 37, // Left + 38, // Up + 39, // Right + 40, // Down + 33, // PageUp + 34 // PageUp + ]; + + /** + * Listeners registered by this text watcher. + * + * @private + */ + this._listeners = []; + + /** + * Indicates throttle threshold mitigating text checks. + * + * Higher levels of the throttle threshold will create a delay for text watcher checks + * but also improve its performance. + * + * See the {@link CKEDITOR.tools#throttle throttle} feature for more information. + * + * @readonly + * @property {Number} [throttle=0] + */ + this.throttle = throttle || 0; + + /** + * The {@link CKEDITOR.tools#throttle throttle buffer} used to mitigate text checks. + * + * @private + */ + this._buffer = CKEDITOR.tools.throttle( this.throttle, testTextMatch, this ); + + /** + * Event fired when the text is no longer matching. + * + * @event matched + * @param {Object} data The value returned by the {@link #callback}. + * @param {String} data.text + * @param {CKEDITOR.dom.range} data.range + */ + + /** + * Event fired when the text stops matching. + * + * @event unmatched + */ + + function testTextMatch( selectionRange ) { + var matched = this.callback( selectionRange ); + + if ( matched ) { + if ( matched.text == this.lastMatched ) { + return; + } + + this.lastMatched = matched.text; + this.fire( 'matched', matched ); + } else if ( this.lastMatched ) { + this.unmatch(); + } + } + } + + TextWatcher.prototype = { + /** + * Attaches the text watcher to the {@link #editor}. + * + * @chainable + */ + attach: function() { + var editor = this.editor; + + this._listeners.push( editor.on( 'contentDom', onContentDom, this ) ); + this._listeners.push( editor.on( 'blur', unmatch, this ) ); + this._listeners.push( editor.on( 'beforeModeUnload', unmatch, this ) ); + this._listeners.push( editor.on( 'setData', unmatch, this ) ); + this._listeners.push( editor.on( 'afterCommandExec', unmatch, this ) ); + + // Attach if editor is already initialized. + if ( editor.editable() ) { + onContentDom.call( this ); + } + + return this; + + function onContentDom() { + var editable = editor.editable(); + + this._listeners.push( editable.attachListener( editable, 'keyup', check, 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. + function check( evt ) { + this.check( evt ); + } + + function unmatch() { + this.unmatch(); + } + }, + + /** + * Triggers a text check. Fires the {@link #matched} and {@link #unmatched} events. + * The {@link #matched} event will not be fired twice in a row for the same text + * unless the text watcher is {@link #unmatch reset}. + * + * @param {CKEDITOR.dom.event/CKEDITOR.eventInfo} [evt] + */ + check: function( evt ) { + if ( this.ignoreNext ) { + this.ignoreNext = false; + return; + } + + // Ignore control keys, so they don't trigger the check. + if ( evt && evt.name == 'keyup' && ( CKEDITOR.tools.array.indexOf( this.ignoredKeys, evt.data.getKey() ) != -1 ) ) { + return; + } + + var sel = this.editor.getSelection(); + if ( !sel ) { + return; + } + + var selectionRange = sel.getRanges()[ 0 ]; + if ( !selectionRange ) { + return; + } + + this._buffer.input( selectionRange ); + }, + + /** + * Ignores the next {@link #check}. + * + * @chainable + */ + consumeNext: function() { + this.ignoreNext = true; + return this; + }, + + /** + * Resets the state and fires the {@link #unmatched} event. + * + * @chainable + */ + unmatch: function() { + this.lastMatched = null; + this.fire( 'unmatched' ); + return this; + }, + + /** + * Destroys the text watcher instance. The DOM event listeners will be cleaned up. + */ + destroy: function() { + CKEDITOR.tools.array.forEach( this._listeners, function( obj ) { + obj.removeListener(); + } ); + this._listeners = []; + } + }; + + CKEDITOR.event.implementOn( TextWatcher.prototype ); + + CKEDITOR.plugins.textWatcher = TextWatcher; + +} )(); diff --git a/public/themes/circlepro/stylesheets/application.css b/public/themes/circlepro/stylesheets/application.css index be754d9..6077c2c 100644 --- a/public/themes/circlepro/stylesheets/application.css +++ b/public/themes/circlepro/stylesheets/application.css @@ -695,6 +695,10 @@ div.wiki ul.toc li { min-width: auto; padding-top: 1em; } +#preview span.cke_widget_drag_handler_container { + display: none; +} + /**********************************************************/ /* 11. DOCUMENTS */