/** * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @fileOverview A set of utilities to find and create horizontal spaces in edited content. */ 'use strict'; ( function() { CKEDITOR.plugins.add( 'lineutils' ); /** * Determines a position relative to an element in DOM (before). * * @readonly * @property {Number} [=0] * @member CKEDITOR */ CKEDITOR.LINEUTILS_BEFORE = 1; /** * Determines a position relative to an element in DOM (after). * * @readonly * @property {Number} [=2] * @member CKEDITOR */ CKEDITOR.LINEUTILS_AFTER = 2; /** * Determines a position relative to an element in DOM (inside). * * @readonly * @property {Number} [=4] * @member CKEDITOR */ CKEDITOR.LINEUTILS_INSIDE = 4; /** * A utility that traverses the DOM tree and discovers elements * (relations) matching user-defined lookups. * * @private * @class CKEDITOR.plugins.lineutils.finder * @constructor Creates a Finder class instance. * @param {CKEDITOR.editor} editor Editor instance that the Finder belongs to. * @param {Object} def Finder's definition. * @since 4.3 */ function Finder( editor, def ) { CKEDITOR.tools.extend( this, { editor: editor, editable: editor.editable(), doc: editor.document, win: editor.window }, def, true ); this.inline = this.editable.isInline(); if ( !this.inline ) { this.frame = this.win.getFrame(); } this.target = this[ this.inline ? 'editable' : 'doc' ]; } Finder.prototype = { /** * Initializes searching for elements with every mousemove event fired. * To stop searching use {@link #stop}. * * @param {Function} [callback] Function executed on every iteration. */ start: function( callback ) { var that = this, editor = this.editor, doc = this.doc, el, elfp, x, y; var moveBuffer = CKEDITOR.tools.eventsBuffer( 50, function() { if ( editor.readOnly || editor.mode != 'wysiwyg' ) return; that.relations = {}; // Sometimes it happens that elementFromPoint returns null (especially on IE). // Any further traversal makes no sense if there's no start point. Abort. // Note: In IE8 elementFromPoint may return zombie nodes of undefined nodeType, // so rejecting those as well. if ( !( elfp = doc.$.elementFromPoint( x, y ) ) || !elfp.nodeType ) { return; } el = new CKEDITOR.dom.element( elfp ); that.traverseSearch( el ); if ( !isNaN( x + y ) ) { that.pixelSearch( el, x, y ); } callback && callback( that.relations, x, y ); } ); // Searching starting from element from point on mousemove. this.listener = this.editable.attachListener( this.target, 'mousemove', function( evt ) { x = evt.data.$.clientX; y = evt.data.$.clientY; moveBuffer.input(); } ); this.editable.attachListener( this.inline ? this.editable : this.frame, 'mouseout', function() { moveBuffer.reset(); } ); }, /** * Stops observing mouse events attached by {@link #start}. */ stop: function() { if ( this.listener ) { this.listener.removeListener(); } }, /** * Returns a range representing the relation, according to its element * and type. * * @param {Object} location Location containing a unique identifier and type. * @returns {CKEDITOR.dom.range} Range representing the relation. */ getRange: ( function() { var where = {}; where[ CKEDITOR.LINEUTILS_BEFORE ] = CKEDITOR.POSITION_BEFORE_START; where[ CKEDITOR.LINEUTILS_AFTER ] = CKEDITOR.POSITION_AFTER_END; where[ CKEDITOR.LINEUTILS_INSIDE ] = CKEDITOR.POSITION_AFTER_START; return function( location ) { var range = this.editor.createRange(); range.moveToPosition( this.relations[ location.uid ].element, where[ location.type ] ); return range; }; } )(), /** * Stores given relation in a {@link #relations} object. Processes the relation * to normalize and avoid duplicates. * * @param {CKEDITOR.dom.element} el Element of the relation. * @param {Number} type Relation, one of `CKEDITOR.LINEUTILS_AFTER`, `CKEDITOR.LINEUTILS_BEFORE`, `CKEDITOR.LINEUTILS_INSIDE`. */ store: ( function() { function merge( el, type, relations ) { var uid = el.getUniqueId(); if ( uid in relations ) { relations[ uid ].type |= type; } else { relations[ uid ] = { element: el, type: type }; } } return function( el, type ) { var alt; // Normalization to avoid duplicates: // CKEDITOR.LINEUTILS_AFTER becomes CKEDITOR.LINEUTILS_BEFORE of el.getNext(). if ( is( type, CKEDITOR.LINEUTILS_AFTER ) && isStatic( alt = el.getNext() ) && alt.isVisible() ) { merge( alt, CKEDITOR.LINEUTILS_BEFORE, this.relations ); type ^= CKEDITOR.LINEUTILS_AFTER; } // Normalization to avoid duplicates: // CKEDITOR.LINEUTILS_INSIDE becomes CKEDITOR.LINEUTILS_BEFORE of el.getFirst(). if ( is( type, CKEDITOR.LINEUTILS_INSIDE ) && isStatic( alt = el.getFirst() ) && alt.isVisible() ) { merge( alt, CKEDITOR.LINEUTILS_BEFORE, this.relations ); type ^= CKEDITOR.LINEUTILS_INSIDE; } merge( el, type, this.relations ); }; } )(), /** * Traverses the DOM tree towards root, checking all ancestors * with lookup rules, avoiding duplicates. Stores positive relations * in the {@link #relations} object. * * @param {CKEDITOR.dom.element} el Element which is the starting point. */ traverseSearch: function( el ) { var l, type, uid; // Go down DOM towards root (or limit). do { uid = el.$[ 'data-cke-expando' ]; // This element was already visited and checked. if ( uid && uid in this.relations ) { continue; } if ( el.equals( this.editable ) ) { return; } if ( isStatic( el ) ) { // Collect all addresses yielded by lookups for that element. for ( l in this.lookups ) { if ( ( type = this.lookups[ l ]( el ) ) ) { this.store( el, type ); } } } } while ( !isLimit( el ) && ( el = el.getParent() ) ); }, /** * Iterates vertically pixel-by-pixel within a given element starting * from given coordinates, searching for elements in the neighborhood. * Once an element is found it is processed by {@link #traverseSearch}. * * @param {CKEDITOR.dom.element} el Element which is the starting point. * @param {Number} [x] Horizontal mouse coordinate relative to the viewport. * @param {Number} [y] Vertical mouse coordinate relative to the viewport. */ pixelSearch: ( function() { var contains = CKEDITOR.env.ie || CKEDITOR.env.webkit ? function( el, found ) { return el.contains( found ); } : function( el, found ) { return !!( el.compareDocumentPosition( found ) & 16 ); }; // Iterates pixel-by-pixel from starting coordinates, moving by defined // step and getting elementFromPoint in every iteration. Iteration stops when: // * A valid element is found. // * Condition function returns `false` (i.e. reached boundaries of viewport). // * No element is found (i.e. coordinates out of viewport). // * Element found is ascendant of starting element. // // @param {Object} doc Native DOM document. // @param {Object} el Native DOM element. // @param {Number} xStart Horizontal starting coordinate to use. // @param {Number} yStart Vertical starting coordinate to use. // @param {Number} step Step of the algorithm. // @param {Function} condition A condition relative to current vertical coordinate. function iterate( el, xStart, yStart, step, condition ) { var y = yStart, tryouts = 0, found; while ( condition( y ) ) { y += step; // If we try and we try, and still nothing's found, let's end // that party. if ( ++tryouts == 25 ) { return; } found = this.doc.$.elementFromPoint( xStart, y ); // Nothing found. This is crazy... but... // It might be that a line, which is in different document, // covers that pixel (elementFromPoint is doc-sensitive). // Better let's have another try. if ( !found ) { continue; } // Still in the same element. else if ( found == el ) { tryouts = 0; continue; } // Reached the edge of an element and found an ancestor or... // A line, that covers that pixel. Better let's have another try. else if ( !contains( el, found ) ) { continue; } tryouts = 0; // Found a valid element. Stop iterating. if ( isStatic( ( found = new CKEDITOR.dom.element( found ) ) ) ) { return found; } } } return function( el, x, y ) { var paneHeight = this.win.getViewPaneSize().height, // Try to find an element iterating *up* from the starting point. neg = iterate.call( this, el.$, x, y, -1, function( y ) { return y > 0; } ), // Try to find an element iterating *down* from the starting point. pos = iterate.call( this, el.$, x, y, 1, function( y ) { return y < paneHeight; } ); if ( neg ) { this.traverseSearch( neg ); // Iterate towards DOM root until neg is a direct child of el. while ( !neg.getParent().equals( el ) ) { neg = neg.getParent(); } } if ( pos ) { this.traverseSearch( pos ); // Iterate towards DOM root until pos is a direct child of el. while ( !pos.getParent().equals( el ) ) { pos = pos.getParent(); } } // Iterate forwards starting from neg and backwards from // pos to harvest all children of el between those elements. // Stop when neg and pos meet each other or there's none of them. // TODO (?) reduce number of hops forwards/backwards. while ( neg || pos ) { if ( neg ) { neg = neg.getNext( isStatic ); } if ( !neg || neg.equals( pos ) ) { break; } this.traverseSearch( neg ); if ( pos ) { pos = pos.getPrevious( isStatic ); } if ( !pos || pos.equals( neg ) ) { break; } this.traverseSearch( pos ); } }; } )(), /** * Unlike {@link #traverseSearch}, it collects **all** elements from editable's DOM tree * and runs lookups for every one of them, collecting relations. * * @returns {Object} {@link #relations}. */ greedySearch: function() { this.relations = {}; var all = this.editable.getElementsByTag( '*' ), i = 0, el, type, l; while ( ( el = all.getItem( i++ ) ) ) { // Don't consider editable, as it might be inline, // and i.e. checking it's siblings is pointless. if ( el.equals( this.editable ) ) { continue; } // On IE8 element.getElementsByTagName returns comments... sic! (https://dev.ckeditor.com/ticket/13176) if ( el.type != CKEDITOR.NODE_ELEMENT ) { continue; } // Don't visit non-editable internals, for example widget's // guts (above wrapper, below nested). Still check editable limits, // as they are siblings with editable contents. if ( !el.hasAttribute( 'contenteditable' ) && el.isReadOnly() ) { continue; } if ( isStatic( el ) && el.isVisible() ) { // Collect all addresses yielded by lookups for that element. for ( l in this.lookups ) { if ( ( type = this.lookups[ l ]( el ) ) ) { this.store( el, type ); } } } } return this.relations; } /** * Relations express elements in DOM that match user-defined {@link #lookups}. * Every relation has its own `type` that determines whether * it refers to the space before, after or inside the `element`. * This object stores relations found by {@link #traverseSearch} or {@link #greedySearch}, structured * in the following way: * * relations: { * // Unique identifier of the element. * Number: { * // Element of this relation. * element: {@link CKEDITOR.dom.element} * // Conjunction of CKEDITOR.LINEUTILS_BEFORE, CKEDITOR.LINEUTILS_AFTER and CKEDITOR.LINEUTILS_INSIDE. * type: Number * }, * ... * } * * @property {Object} relations * @readonly */ /** * A set of user-defined functions used by Finder to check if an element * is a valid relation, belonging to {@link #relations}. * When the criterion is met, lookup returns a logical conjunction of `CKEDITOR.LINEUTILS_BEFORE`, * `CKEDITOR.LINEUTILS_AFTER` or `CKEDITOR.LINEUTILS_INSIDE`. * * Lookups are passed along with Finder's definition. * * lookups: { * 'some lookup': function( el ) { * if ( someCondition ) * return CKEDITOR.LINEUTILS_BEFORE; * }, * ... * } * * @property {Object} lookups */ }; /** * A utility that analyses relations found by * CKEDITOR.plugins.lineutils.finder and locates them * in the viewport as horizontal lines of specific coordinates. * * @private * @class CKEDITOR.plugins.lineutils.locator * @constructor Creates a Locator class instance. * @param {CKEDITOR.editor} editor Editor instance that Locator belongs to. * @since 4.3 */ function Locator( editor, def ) { CKEDITOR.tools.extend( this, def, { editor: editor }, true ); } Locator.prototype = { /** * Locates the Y coordinate for all types of every single relation and stores * them in an object. * * @param {Object} relations {@link CKEDITOR.plugins.lineutils.finder#relations}. * @returns {Object} {@link #locations}. */ locate: ( function() { function locateSibling( rel, type ) { var sib = rel.element[ type === CKEDITOR.LINEUTILS_BEFORE ? 'getPrevious' : 'getNext' ](); // Return the middle point between siblings. if ( sib && isStatic( sib ) ) { rel.siblingRect = sib.getClientRect(); if ( type == CKEDITOR.LINEUTILS_BEFORE ) { return ( rel.siblingRect.bottom + rel.elementRect.top ) / 2; } else { return ( rel.elementRect.bottom + rel.siblingRect.top ) / 2; } } // If there's no sibling, use the edge of an element. else { if ( type == CKEDITOR.LINEUTILS_BEFORE ) { return rel.elementRect.top; } else { return rel.elementRect.bottom; } } } return function( relations ) { var rel; this.locations = {}; for ( var uid in relations ) { rel = relations[ uid ]; rel.elementRect = rel.element.getClientRect(); if ( is( rel.type, CKEDITOR.LINEUTILS_BEFORE ) ) { this.store( uid, CKEDITOR.LINEUTILS_BEFORE, locateSibling( rel, CKEDITOR.LINEUTILS_BEFORE ) ); } if ( is( rel.type, CKEDITOR.LINEUTILS_AFTER ) ) { this.store( uid, CKEDITOR.LINEUTILS_AFTER, locateSibling( rel, CKEDITOR.LINEUTILS_AFTER ) ); } // The middle point of the element. if ( is( rel.type, CKEDITOR.LINEUTILS_INSIDE ) ) { this.store( uid, CKEDITOR.LINEUTILS_INSIDE, ( rel.elementRect.top + rel.elementRect.bottom ) / 2 ); } } return this.locations; }; } )(), /** * Calculates distances from every location to given vertical coordinate * and sorts locations according to that distance. * * @param {Number} y The vertical coordinate used for sorting, used as a reference. * @param {Number} [howMany] Determines the number of "closest locations" to be returned. * @returns {Array} Sorted, array representation of {@link #locations}. */ sort: ( function() { var locations, sorted, dist, i; function distance( y, uid, type ) { return Math.abs( y - locations[ uid ][ type ] ); } return function( y, howMany ) { locations = this.locations; sorted = []; for ( var uid in locations ) { for ( var type in locations[ uid ] ) { dist = distance( y, uid, type ); // An array is empty. if ( !sorted.length ) { sorted.push( { uid: +uid, type: type, dist: dist } ); } else { // Sort the array on fly when it's populated. for ( i = 0; i < sorted.length; i++ ) { if ( dist < sorted[ i ].dist ) { sorted.splice( i, 0, { uid: +uid, type: type, dist: dist } ); break; } } // Nothing was inserted, so the distance is bigger than // any of already calculated: push to the end. if ( i == sorted.length ) { sorted.push( { uid: +uid, type: type, dist: dist } ); } } } } if ( typeof howMany != 'undefined' ) { return sorted.slice( 0, howMany ); } else { return sorted; } }; } )(), /** * Stores the location in a collection. * * @param {Number} uid Unique identifier of the relation. * @param {Number} type One of `CKEDITOR.LINEUTILS_BEFORE`, `CKEDITOR.LINEUTILS_AFTER` and `CKEDITOR.LINEUTILS_INSIDE`. * @param {Number} y Vertical position of the relation. */ store: function( uid, type, y ) { if ( !this.locations[ uid ] ) { this.locations[ uid ] = {}; } this.locations[ uid ][ type ] = y; } /** * @readonly * @property {Object} locations */ }; var tipCss = { display: 'block', width: '0px', height: '0px', 'border-color': 'transparent', 'border-style': 'solid', position: 'absolute', top: '-6px' }, lineStyle = { height: '0px', 'border-top': '1px dashed red', position: 'absolute', 'z-index': 9999 }, lineTpl = '