| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149 |
- /**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
- /**
- * @module engine/model/documentselection
- */
- import mix from '@ckeditor/ckeditor5-utils/src/mix';
- import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
- import Selection from './selection';
- import LiveRange from './liverange';
- import Text from './text';
- import TextProxy from './textproxy';
- import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
- import Collection from '@ckeditor/ckeditor5-utils/src/collection';
- import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
- import log from '@ckeditor/ckeditor5-utils/src/log';
- import uid from '@ckeditor/ckeditor5-utils/src/uid';
- const storePrefix = 'selection:';
- /**
- * `DocumentSelection` is a special selection which is used as the
- * {@link module:engine/model/document~Document#selection document's selection}.
- * There can be only one instance of `DocumentSelection` per document.
- *
- * Document selection can only be changed by using the {@link module:engine/model/writer~Writer} instance
- * inside the {@link module:engine/model/model~Model#change `change()`} block, as it provides a secure way to modify model.
- *
- * `DocumentSelection` is automatically updated upon changes in the {@link module:engine/model/document~Document document}
- * to always contain valid ranges. Its attributes are inherited from the text unless set explicitly.
- *
- * Differences between {@link module:engine/model/selection~Selection} and `DocumentSelection` are:
- * * there is always a range in `DocumentSelection` - even if no ranges were added there is a "default range"
- * present in the selection,
- * * ranges added to this selection updates automatically when the document changes,
- * * attributes of `DocumentSelection` are updated automatically according to selection ranges.
- *
- * Since `DocumentSelection` uses {@link module:engine/model/liverange~LiveRange live ranges}
- * and is updated when {@link module:engine/model/document~Document document}
- * changes, it cannot be set on {@link module:engine/model/node~Node nodes}
- * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
- * If you need to represent a selection in document fragment,
- * use {@link module:engine/model/selection~Selection Selection class} instead.
- *
- * @mixes module:utils/emittermixin~EmitterMixin
- */
- export default class DocumentSelection {
- /**
- * Creates an empty live selection for given {@link module:engine/model/document~Document}.
- *
- * @param {module:engine/model/document~Document} doc Document which owns this selection.
- */
- constructor( doc ) {
- /**
- * Selection used internally by that class (`DocumentSelection` is a proxy to that selection).
- *
- * @protected
- */
- this._selection = new LiveSelection( doc );
- this._selection.delegate( 'change:range' ).to( this );
- this._selection.delegate( 'change:attribute' ).to( this );
- }
- /**
- * Returns whether the selection is collapsed. Selection is collapsed when there is exactly one range which is
- * collapsed.
- *
- * @readonly
- * @type {Boolean}
- */
- get isCollapsed() {
- return this._selection.isCollapsed;
- }
- /**
- * Selection anchor. Anchor may be described as a position where the most recent part of the selection starts.
- * Together with {@link #focus} they define the direction of selection, which is important
- * when expanding/shrinking selection. Anchor is always {@link module:engine/model/range~Range#start start} or
- * {@link module:engine/model/range~Range#end end} position of the most recently added range.
- *
- * Is set to `null` if there are no ranges in selection.
- *
- * @see #focus
- * @readonly
- * @type {module:engine/model/position~Position|null}
- */
- get anchor() {
- return this._selection.anchor;
- }
- /**
- * Selection focus. Focus is a position where the selection ends.
- *
- * Is set to `null` if there are no ranges in selection.
- *
- * @see #anchor
- * @readonly
- * @type {module:engine/model/position~Position|null}
- */
- get focus() {
- return this._selection.focus;
- }
- /**
- * Returns number of ranges in selection.
- *
- * @readonly
- * @type {Number}
- */
- get rangeCount() {
- return this._selection.rangeCount;
- }
- /**
- * Describes whether `Documentselection` has own range(s) set, or if it is defaulted to
- * {@link module:engine/model/document~Document#_getDefaultRange document's default range}.
- *
- * @readonly
- * @type {Boolean}
- */
- get hasOwnRange() {
- return this._selection.hasOwnRange;
- }
- /**
- * Specifies whether the {@link #focus}
- * precedes {@link #anchor}.
- *
- * @readonly
- * @type {Boolean}
- */
- get isBackward() {
- return this._selection.isBackward;
- }
- /**
- * Describes whether the gravity is overridden (using {@link module:engine/model/writer~Writer#overrideSelectionGravity}) or not.
- *
- * Note that the gravity remains overridden as long as will not be restored the same number of times as it was overridden.
- *
- * @readonly
- * @returns {Boolean}
- */
- get isGravityOverridden() {
- return this._selection.isGravityOverridden;
- }
- /**
- * A collection of selection markers.
- * Marker is a selection marker when selection range is inside the marker range.
- *
- * @readonly
- * @type {module:utils/collection~Collection.<module:engine/model/markercollection~Marker>}
- */
- get markers() {
- return this._selection.markers;
- }
- /**
- * Used for the compatibility with the {@link module:engine/model/selection~Selection#isEqual} method.
- *
- * @protected
- */
- get _ranges() {
- return this._selection._ranges;
- }
- /**
- * Returns an iterable that iterates over copies of selection ranges.
- *
- * @returns {Iterable.<module:engine/model/range~Range>}
- */
- getRanges() {
- return this._selection.getRanges();
- }
- /**
- * Returns the first position in the selection.
- * First position is the position that {@link module:engine/model/position~Position#isBefore is before}
- * any other position in the selection.
- *
- * Returns `null` if there are no ranges in selection.
- *
- * @returns {module:engine/model/position~Position|null}
- */
- getFirstPosition() {
- return this._selection.getFirstPosition();
- }
- /**
- * Returns the last position in the selection.
- * Last position is the position that {@link module:engine/model/position~Position#isAfter is after}
- * any other position in the selection.
- *
- * Returns `null` if there are no ranges in selection.
- *
- * @returns {module:engine/model/position~Position|null}
- */
- getLastPosition() {
- return this._selection.getLastPosition();
- }
- /**
- * Returns a copy of the first range in the selection.
- * First range is the one which {@link module:engine/model/range~Range#start start} position
- * {@link module:engine/model/position~Position#isBefore is before} start position of all other ranges
- * (not to confuse with the first range added to the selection).
- *
- * Returns `null` if there are no ranges in selection.
- *
- * @returns {module:engine/model/range~Range|null}
- */
- getFirstRange() {
- return this._selection.getFirstRange();
- }
- /**
- * Returns a copy of the last range in the selection.
- * Last range is the one which {@link module:engine/model/range~Range#end end} position
- * {@link module:engine/model/position~Position#isAfter is after} end position of all other ranges (not to confuse with the range most
- * recently added to the selection).
- *
- * Returns `null` if there are no ranges in selection.
- *
- * @returns {module:engine/model/range~Range|null}
- */
- getLastRange() {
- return this._selection.getLastRange();
- }
- /**
- * Gets elements of type "block" touched by the selection.
- *
- * This method's result can be used for example to apply block styling to all blocks covered by this selection.
- *
- * **Note:** `getSelectedBlocks()` always returns the deepest block.
- *
- * In this case the function will return exactly all 3 paragraphs:
- *
- * <paragraph>[a</paragraph>
- * <quote>
- * <paragraph>b</paragraph>
- * </quote>
- * <paragraph>c]d</paragraph>
- *
- * In this case the paragraph will also be returned, despite the collapsed selection:
- *
- * <paragraph>[]a</paragraph>
- *
- * **Special case**: If a selection ends at the beginning of a block, that block is not returned as from user perspective
- * this block wasn't selected. See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details.
- *
- * <paragraph>[a</paragraph>
- * <paragraph>b</paragraph>
- * <paragraph>]c</paragraph> // this block will not be returned
- *
- * @returns {Iterable.<module:engine/model/element~Element>}
- */
- getSelectedBlocks() {
- return this._selection.getSelectedBlocks();
- }
- /**
- * Returns blocks that aren't nested in other selected blocks.
- *
- * In this case the method will return blocks A, B and E because C & D are children of block B:
- *
- * [<blockA></blockA>
- * <blockB>
- * <blockC></blockC>
- * <blockD></blockD>
- * </blockB>
- * <blockE></blockE>]
- *
- * **Note:** To get all selected blocks use {@link #getSelectedBlocks `getSelectedBlocks()`}.
- *
- * @returns {Iterable.<module:engine/model/element~Element>}
- */
- getTopMostBlocks() {
- return this._selection.getTopMostBlocks();
- }
- /**
- * Returns the selected element. {@link module:engine/model/element~Element Element} is considered as selected if there is only
- * one range in the selection, and that range contains exactly one element.
- * Returns `null` if there is no selected element.
- *
- * @returns {module:engine/model/element~Element|null}
- */
- getSelectedElement() {
- return this._selection.getSelectedElement();
- }
- /**
- * Checks whether the selection contains the entire content of the given element. This means that selection must start
- * at a position {@link module:engine/model/position~Position#isTouching touching} the element's start and ends at position
- * touching the element's end.
- *
- * By default, this method will check whether the entire content of the selection's current root is selected.
- * Useful to check if e.g. the user has just pressed <kbd>Ctrl</kbd> + <kbd>A</kbd>.
- *
- * @param {module:engine/model/element~Element} [element=this.anchor.root]
- * @returns {Boolean}
- */
- containsEntireContent( element ) {
- return this._selection.containsEntireContent( element );
- }
- /**
- * Unbinds all events previously bound by document selection.
- */
- destroy() {
- this._selection.destroy();
- }
- /**
- * Returns iterable that iterates over this selection's attribute keys.
- *
- * @returns {Iterable.<String>}
- */
- getAttributeKeys() {
- return this._selection.getAttributeKeys();
- }
- /**
- * Returns iterable that iterates over this selection's attributes.
- *
- * Attributes are returned as arrays containing two items. First one is attribute key and second is attribute value.
- * This format is accepted by native `Map` object and also can be passed in `Node` constructor.
- *
- * @returns {Iterable.<*>}
- */
- getAttributes() {
- return this._selection.getAttributes();
- }
- /**
- * Gets an attribute value for given key or `undefined` if that attribute is not set on the selection.
- *
- * @param {String} key Key of attribute to look for.
- * @returns {*} Attribute value or `undefined`.
- */
- getAttribute( key ) {
- return this._selection.getAttribute( key );
- }
- /**
- * Checks if the selection has an attribute for given key.
- *
- * @param {String} key Key of attribute to check.
- * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
- */
- hasAttribute( key ) {
- return this._selection.hasAttribute( key );
- }
- /**
- * Moves {@link module:engine/model/documentselection~DocumentSelection#focus} to the specified location.
- * Should be used only within the {@link module:engine/model/writer~Writer#setSelectionFocus} method.
- *
- * The location can be specified in the same form as
- * {@link module:engine/model/writer~Writer#createPositionAt writer.createPositionAt()} parameters.
- *
- * @see module:engine/model/writer~Writer#setSelectionFocus
- * @protected
- * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
- * @param {Number|'end'|'before'|'after'} [offset] Offset or one of the flags. Used only when
- * first parameter is a {@link module:engine/model/item~Item model item}.
- */
- _setFocus( itemOrPosition, offset ) {
- this._selection.setFocus( itemOrPosition, offset );
- }
- /**
- * Sets this selection's ranges and direction to the specified location based on the given
- * {@link module:engine/model/selection~Selection selection}, {@link module:engine/model/position~Position position},
- * {@link module:engine/model/node~Node node}, {@link module:engine/model/position~Position position},
- * {@link module:engine/model/range~Range range}, an iterable of {@link module:engine/model/range~Range ranges} or null.
- * Should be used only within the {@link module:engine/model/writer~Writer#setSelection} method.
- *
- * @see module:engine/model/writer~Writer#setSelection
- * @protected
- * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
- * module:engine/model/position~Position|module:engine/model/node~Node|
- * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
- * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
- * @param {Object} [options]
- * @param {Boolean} [options.backward] Sets this selection instance to be backward.
- */
- _setTo( selectable, placeOrOffset, options ) {
- this._selection.setTo( selectable, placeOrOffset, options );
- }
- /**
- * Sets attribute on the selection. If attribute with the same key already is set, it's value is overwritten.
- * Should be used only within the {@link module:engine/model/writer~Writer#setSelectionAttribute} method.
- *
- * @see module:engine/model/writer~Writer#setSelectionAttribute
- * @protected
- * @param {String} key Key of the attribute to set.
- * @param {*} value Attribute value.
- */
- _setAttribute( key, value ) {
- this._selection.setAttribute( key, value );
- }
- /**
- * Removes an attribute with given key from the selection.
- * If the given attribute was set on the selection, fires the {@link module:engine/model/selection~Selection#event:change:range}
- * event with removed attribute key.
- * Should be used only within the {@link module:engine/model/writer~Writer#removeSelectionAttribute} method.
- *
- * @see module:engine/model/writer~Writer#removeSelectionAttribute
- * @protected
- * @param {String} key Key of the attribute to remove.
- */
- _removeAttribute( key ) {
- this._selection.removeAttribute( key );
- }
- /**
- * Returns an iterable that iterates through all selection attributes stored in current selection's parent.
- *
- * @protected
- * @returns {Iterable.<*>}
- */
- _getStoredAttributes() {
- return this._selection._getStoredAttributes();
- }
- /**
- * Temporarily changes the gravity of the selection from the left to the right.
- *
- * The gravity defines from which direction the selection inherits its attributes. If it's the default left
- * gravity, the selection (after being moved by the the user) inherits attributes from its left hand side.
- * This method allows to temporarily override this behavior by forcing the gravity to the right.
- *
- * It returns an unique identifier which is required to restore the gravity. It guarantees the symmetry
- * of the process.
- *
- * @see module:engine/model/writer~Writer#overrideSelectionGravity
- * @protected
- * @returns {String} The unique id which allows restoring the gravity.
- */
- _overrideGravity() {
- return this._selection.overrideGravity();
- }
- /**
- * Restores the {@link ~DocumentSelection#_overrideGravity overridden gravity}.
- *
- * Restoring the gravity is only possible using the unique identifier returned by
- * {@link ~DocumentSelection#_overrideGravity}. Note that the gravity remains overridden as long as won't be restored
- * the same number of times it was overridden.
- *
- * @see module:engine/model/writer~Writer#restoreSelectionGravity
- * @protected
- * @param {String} uid The unique id returned by {@link #_overrideGravity}.
- */
- _restoreGravity( uid ) {
- this._selection.restoreGravity( uid );
- }
- /**
- * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
- *
- * @protected
- * @param {String} key Attribute key to convert.
- * @returns {String} Converted attribute key, applicable for selection store.
- */
- static _getStoreAttributeKey( key ) {
- return storePrefix + key;
- }
- /**
- * Checks whether the given attribute key is an attribute stored on an element.
- *
- * @protected
- * @param {String} key
- * @returns {Boolean}
- */
- static _isStoreAttributeKey( key ) {
- return key.startsWith( storePrefix );
- }
- }
- mix( DocumentSelection, EmitterMixin );
- /**
- * Fired when selection range(s) changed.
- *
- * @event change:range
- * @param {Boolean} directChange In case of {@link module:engine/model/selection~Selection} class it is always set
- * to `true` which indicates that the selection change was caused by a direct use of selection's API.
- * The {@link module:engine/model/documentselection~DocumentSelection}, however, may change because its position
- * was directly changed through the {@link module:engine/model/writer~Writer writer} or because its position was
- * changed because the structure of the model has been changed (which means an indirect change).
- * The indirect change does not occur in case of normal (detached) selections because they are "static" (as "not live")
- * which mean that they are not updated once the document changes.
- */
- /**
- * Fired when selection attribute changed.
- *
- * @event change:attribute
- * @param {Boolean} directChange In case of {@link module:engine/model/selection~Selection} class it is always set
- * to `true` which indicates that the selection change was caused by a direct use of selection's API.
- * The {@link module:engine/model/documentselection~DocumentSelection}, however, may change because its attributes
- * were directly changed through the {@link module:engine/model/writer~Writer writer} or because its position was
- * changed in the model and its attributes were refreshed (which means an indirect change).
- * The indirect change does not occur in case of normal (detached) selections because they are "static" (as "not live")
- * which mean that they are not updated once the document changes.
- * @param {Array.<String>} attributeKeys Array containing keys of attributes that changed.
- */
- // `LiveSelection` is used internally by {@link module:engine/model/documentselection~DocumentSelection} and shouldn't be used directly.
- //
- // LiveSelection` is automatically updated upon changes in the {@link module:engine/model/document~Document document}
- // to always contain valid ranges. Its attributes are inherited from the text unless set explicitly.
- //
- // Differences between {@link module:engine/model/selection~Selection} and `LiveSelection` are:
- // * there is always a range in `LiveSelection` - even if no ranges were added there is a "default range"
- // present in the selection,
- // * ranges added to this selection updates automatically when the document changes,
- // * attributes of `LiveSelection` are updated automatically according to selection ranges.
- //
- // @extends module:engine/model/selection~Selection
- //
- class LiveSelection extends Selection {
- // Creates an empty live selection for given {@link module:engine/model/document~Document}.
- // @param {module:engine/model/document~Document} doc Document which owns this selection.
- constructor( doc ) {
- super();
- // List of selection markers.
- // Marker is a selection marker when selection range is inside the marker range.
- //
- // @type {module:utils/collection~Collection}
- this.markers = new Collection( { idProperty: 'name' } );
- // Document which owns this selection.
- //
- // @protected
- // @member {module:engine/model/model~Model}
- this._model = doc.model;
- // Document which owns this selection.
- //
- // @protected
- // @member {module:engine/model/document~Document}
- this._document = doc;
- // Keeps mapping of attribute name to priority with which the attribute got modified (added/changed/removed)
- // last time. Possible values of priority are: `'low'` and `'normal'`.
- //
- // Priorities are used by internal `LiveSelection` mechanisms. All attributes set using `LiveSelection`
- // attributes API are set with `'normal'` priority.
- //
- // @private
- // @member {Map} module:engine/model/liveselection~LiveSelection#_attributePriority
- this._attributePriority = new Map();
- // Contains data required to fix ranges which have been moved to the graveyard.
- // @private
- // @member {Array} module:engine/model/liveselection~LiveSelection#_fixGraveyardRangesData
- this._fixGraveyardRangesData = [];
- // Flag that informs whether the selection ranges have changed. It is changed on true when `LiveRange#change:range` event is fired.
- // @private
- // @member {Array} module:engine/model/liveselection~LiveSelection#_hasChangedRange
- this._hasChangedRange = false;
- // Each overriding gravity adds an UID to the set and each removal removes it.
- // Gravity is overridden when there's at least one UID in the set.
- // Gravity is restored when the set is empty.
- // This is to prevent conflicts when gravity is overridden by more than one feature at the same time.
- // @private
- // @type {Set}
- this._overriddenGravityRegister = new Set();
- // Add events that will ensure selection correctness.
- this.on( 'change:range', () => {
- for ( const range of this.getRanges() ) {
- if ( !this._document._validateSelectionRange( range ) ) {
- /**
- * Range from {@link module:engine/model/documentselection~DocumentSelection document selection}
- * starts or ends at incorrect position.
- *
- * @error document-selection-wrong-position
- * @param {module:engine/model/range~Range} range
- */
- throw new CKEditorError(
- 'document-selection-wrong-position: Range from document selection starts or ends at incorrect position.',
- { range }
- );
- }
- }
- } );
- this.listenTo( this._document, 'change', ( evt, batch ) => {
- // Update selection's markers.
- this._updateMarkers();
- // Update selection's attributes.
- this._updateAttributes( false );
- // Clear selection attributes from element if no longer empty.
- clearAttributesStoredInElement( this._model, batch );
- } );
- this.listenTo( this._model, 'applyOperation', () => {
- while ( this._fixGraveyardRangesData.length ) {
- const { liveRange, sourcePosition } = this._fixGraveyardRangesData.shift();
- this._fixGraveyardSelection( liveRange, sourcePosition );
- }
- if ( this._hasChangedRange ) {
- this._hasChangedRange = false;
- this.fire( 'change:range', { directChange: false } );
- }
- }, { priority: 'lowest' } );
- }
- get isCollapsed() {
- const length = this._ranges.length;
- return length === 0 ? this._document._getDefaultRange().isCollapsed : super.isCollapsed;
- }
- get anchor() {
- return super.anchor || this._document._getDefaultRange().start;
- }
- get focus() {
- return super.focus || this._document._getDefaultRange().end;
- }
- get rangeCount() {
- return this._ranges.length ? this._ranges.length : 1;
- }
- // Describes whether `LiveSelection` has own range(s) set, or if it is defaulted to
- // {@link module:engine/model/document~Document#_getDefaultRange document's default range}.
- //
- // @readonly
- // @type {Boolean}
- get hasOwnRange() {
- return this._ranges.length > 0;
- }
- // When set to `true` then selection attributes on node before the caret won't be taken
- // into consideration while updating selection attributes.
- //
- // @protected
- // @type {Boolean}
- get isGravityOverridden() {
- return !!this._overriddenGravityRegister.size;
- }
- // Unbinds all events previously bound by live selection.
- destroy() {
- for ( let i = 0; i < this._ranges.length; i++ ) {
- this._ranges[ i ].detach();
- }
- this.stopListening();
- }
- * getRanges() {
- if ( this._ranges.length ) {
- yield* super.getRanges();
- } else {
- yield this._document._getDefaultRange();
- }
- }
- getFirstRange() {
- return super.getFirstRange() || this._document._getDefaultRange();
- }
- getLastRange() {
- return super.getLastRange() || this._document._getDefaultRange();
- }
- setTo( selectable, optionsOrPlaceOrOffset, options ) {
- super.setTo( selectable, optionsOrPlaceOrOffset, options );
- this._refreshAttributes();
- }
- setFocus( itemOrPosition, offset ) {
- super.setFocus( itemOrPosition, offset );
- this._refreshAttributes();
- }
- setAttribute( key, value ) {
- if ( this._setAttribute( key, value ) ) {
- // Fire event with exact data.
- const attributeKeys = [ key ];
- this.fire( 'change:attribute', { attributeKeys, directChange: true } );
- }
- }
- removeAttribute( key ) {
- if ( this._removeAttribute( key ) ) {
- // Fire event with exact data.
- const attributeKeys = [ key ];
- this.fire( 'change:attribute', { attributeKeys, directChange: true } );
- }
- }
- overrideGravity() {
- const overrideUid = uid();
- // Remember that another overriding has been requested. It will need to be removed
- // before the gravity is to be restored.
- this._overriddenGravityRegister.add( overrideUid );
- if ( this._overriddenGravityRegister.size === 1 ) {
- this._refreshAttributes();
- }
- return overrideUid;
- }
- restoreGravity( uid ) {
- if ( !this._overriddenGravityRegister.has( uid ) ) {
- /**
- * Restoring gravity for an unknown UID is not possible. Make sure you are using a correct
- * UID obtained from the {@link module:engine/model/writer~Writer#overrideSelectionGravity} to restore.
- *
- * @error document-selection-gravity-wrong-restore
- * @param {String} uid The unique identifier returned by
- * {@link module:engine/model/documentselection~DocumentSelection#_overrideGravity}.
- */
- throw new CKEditorError(
- 'document-selection-gravity-wrong-restore: Attempting to restore the selection gravity for an unknown UID.',
- { uid }
- );
- }
- this._overriddenGravityRegister.delete( uid );
- // Restore gravity only when all overriding have been restored.
- if ( !this.isGravityOverridden ) {
- this._refreshAttributes();
- }
- }
- // Removes all attributes from the selection and sets attributes according to the surrounding nodes.
- _refreshAttributes() {
- this._updateAttributes( true );
- }
- _popRange() {
- this._ranges.pop().detach();
- }
- _pushRange( range ) {
- const liveRange = this._prepareRange( range );
- // `undefined` is returned when given `range` is in graveyard root.
- if ( liveRange ) {
- this._ranges.push( liveRange );
- }
- }
- // Prepares given range to be added to selection. Checks if it is correct,
- // converts it to {@link module:engine/model/liverange~LiveRange LiveRange}
- // and sets listeners listening to the range's change event.
- //
- // @private
- // @param {module:engine/model/range~Range} range
- _prepareRange( range ) {
- this._checkRange( range );
- if ( range.root == this._document.graveyard ) {
- /**
- * Trying to add a Range that is in the graveyard root. Range rejected.
- *
- * @warning model-selection-range-in-graveyard
- */
- log.warn( 'model-selection-range-in-graveyard: Trying to add a Range that is in the graveyard root. Range rejected.' );
- return;
- }
- const liveRange = LiveRange.fromRange( range );
- liveRange.on( 'change:range', ( evt, oldRange, data ) => {
- this._hasChangedRange = true;
- // If `LiveRange` is in whole moved to the graveyard, save necessary data. It will be fixed on `Model#applyOperation` event.
- if ( liveRange.root == this._document.graveyard ) {
- this._fixGraveyardRangesData.push( {
- liveRange,
- sourcePosition: data.deletionPosition
- } );
- }
- } );
- return liveRange;
- }
- _updateMarkers() {
- const markers = [];
- for ( const marker of this._model.markers ) {
- const markerRange = marker.getRange();
- for ( const selectionRange of this.getRanges() ) {
- if ( markerRange.containsRange( selectionRange, !selectionRange.isCollapsed ) ) {
- markers.push( marker );
- }
- }
- }
- for ( const marker of markers ) {
- if ( this.markers.getIndex( marker ) < 0 ) {
- this.markers.add( marker );
- }
- }
- for ( const marker of Array.from( this.markers ) ) {
- if ( !markers.includes( marker ) ) {
- this.markers.remove( marker );
- }
- }
- }
- // Updates this selection attributes according to its ranges and the {@link module:engine/model/document~Document model document}.
- //
- // @protected
- // @param {Boolean} clearAll
- // @fires change:attribute
- _updateAttributes( clearAll ) {
- const newAttributes = toMap( this._getSurroundingAttributes() );
- const oldAttributes = toMap( this.getAttributes() );
- if ( clearAll ) {
- // If `clearAll` remove all attributes and reset priorities.
- this._attributePriority = new Map();
- this._attrs = new Map();
- } else {
- // If not, remove only attributes added with `low` priority.
- for ( const [ key, priority ] of this._attributePriority ) {
- if ( priority == 'low' ) {
- this._attrs.delete( key );
- this._attributePriority.delete( key );
- }
- }
- }
- this._setAttributesTo( newAttributes );
- // Let's evaluate which attributes really changed.
- const changed = [];
- // First, loop through all attributes that are set on selection right now.
- // Check which of them are different than old attributes.
- for ( const [ newKey, newValue ] of this.getAttributes() ) {
- if ( !oldAttributes.has( newKey ) || oldAttributes.get( newKey ) !== newValue ) {
- changed.push( newKey );
- }
- }
- // Then, check which of old attributes got removed.
- for ( const [ oldKey ] of oldAttributes ) {
- if ( !this.hasAttribute( oldKey ) ) {
- changed.push( oldKey );
- }
- }
- // Fire event with exact data (fire only if anything changed).
- if ( changed.length > 0 ) {
- this.fire( 'change:attribute', { attributeKeys: changed, directChange: false } );
- }
- }
- // Internal method for setting `LiveSelection` attribute. Supports attribute priorities (through `directChange`
- // parameter).
- //
- // @private
- // @param {String} key Attribute key.
- // @param {*} value Attribute value.
- // @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
- // is caused by `Batch` API.
- // @returns {Boolean} Whether value has changed.
- _setAttribute( key, value, directChange = true ) {
- const priority = directChange ? 'normal' : 'low';
- if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
- // Priority too low.
- return false;
- }
- const oldValue = super.getAttribute( key );
- // Don't do anything if value has not changed.
- if ( oldValue === value ) {
- return false;
- }
- this._attrs.set( key, value );
- // Update priorities map.
- this._attributePriority.set( key, priority );
- return true;
- }
- // Internal method for removing `LiveSelection` attribute. Supports attribute priorities (through `directChange`
- // parameter).
- //
- // NOTE: Even if attribute is not present in the selection but is provided to this method, it's priority will
- // be changed according to `directChange` parameter.
- //
- // @private
- // @param {String} key Attribute key.
- // @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
- // is caused by `Batch` API.
- // @returns {Boolean} Whether attribute was removed. May not be true if such attributes didn't exist or the
- // existing attribute had higher priority.
- _removeAttribute( key, directChange = true ) {
- const priority = directChange ? 'normal' : 'low';
- if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
- // Priority too low.
- return false;
- }
- // Update priorities map.
- this._attributePriority.set( key, priority );
- // Don't do anything if value has not changed.
- if ( !super.hasAttribute( key ) ) {
- return false;
- }
- this._attrs.delete( key );
- return true;
- }
- // Internal method for setting multiple `LiveSelection` attributes. Supports attribute priorities (through
- // `directChange` parameter).
- //
- // @private
- // @param {Map.<String,*>} attrs Iterable object containing attributes to be set.
- // @returns {Set.<String>} Changed attribute keys.
- _setAttributesTo( attrs ) {
- const changed = new Set();
- for ( const [ oldKey, oldValue ] of this.getAttributes() ) {
- // Do not remove attribute if attribute with same key and value is about to be set.
- if ( attrs.get( oldKey ) === oldValue ) {
- continue;
- }
- // All rest attributes will be removed so changed attributes won't change .
- this._removeAttribute( oldKey, false );
- }
- for ( const [ key, value ] of attrs ) {
- // Attribute may not be set because of attributes or because same key/value is already added.
- const gotAdded = this._setAttribute( key, value, false );
- if ( gotAdded ) {
- changed.add( key );
- }
- }
- return changed;
- }
- // Returns an iterable that iterates through all selection attributes stored in current selection's parent.
- //
- // @protected
- // @returns {Iterable.<*>}
- * _getStoredAttributes() {
- const selectionParent = this.getFirstPosition().parent;
- if ( this.isCollapsed && selectionParent.isEmpty ) {
- for ( const key of selectionParent.getAttributeKeys() ) {
- if ( key.startsWith( storePrefix ) ) {
- const realKey = key.substr( storePrefix.length );
- yield [ realKey, selectionParent.getAttribute( key ) ];
- }
- }
- }
- }
- // Checks model text nodes that are closest to the selection's first position and returns attributes of first
- // found element. If there are no text nodes in selection's first position parent, it returns selection
- // attributes stored in that parent.
- //
- // @private
- // @returns {Iterable.<*>} Collection of attributes.
- _getSurroundingAttributes() {
- const position = this.getFirstPosition();
- const schema = this._model.schema;
- let attrs = null;
- if ( !this.isCollapsed ) {
- // 1. If selection is a range...
- const range = this.getFirstRange();
- // ...look for a first character node in that range and take attributes from it.
- for ( const value of range ) {
- // If the item is an object, we don't want to get attributes from its children.
- if ( value.item.is( 'element' ) && schema.isObject( value.item ) ) {
- break;
- }
- // This is not an optimal solution because of https://github.com/ckeditor/ckeditor5-engine/issues/454.
- // It can be done better by using `break;` instead of checking `attrs === null`.
- if ( value.type == 'text' && attrs === null ) {
- attrs = value.item.getAttributes();
- }
- }
- } else {
- // 2. If the selection is a caret or the range does not contain a character node...
- const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
- const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
- // When gravity is overridden then don't take node before into consideration.
- if ( !this.isGravityOverridden ) {
- // ...look at the node before caret and take attributes from it if it is a character node.
- attrs = getAttrsIfCharacter( nodeBefore );
- }
- // 3. If not, look at the node after caret...
- if ( !attrs ) {
- attrs = getAttrsIfCharacter( nodeAfter );
- }
- // 4. If not, try to find the first character on the left, that is in the same node.
- // When gravity is overridden then don't take node before into consideration.
- if ( !this.isGravityOverridden && !attrs ) {
- let node = nodeBefore;
- while ( node && !attrs ) {
- node = node.previousSibling;
- attrs = getAttrsIfCharacter( node );
- }
- }
- // 5. If not found, try to find the first character on the right, that is in the same node.
- if ( !attrs ) {
- let node = nodeAfter;
- while ( node && !attrs ) {
- node = node.nextSibling;
- attrs = getAttrsIfCharacter( node );
- }
- }
- // 6. If not found, selection should retrieve attributes from parent.
- if ( !attrs ) {
- attrs = this._getStoredAttributes();
- }
- }
- return attrs;
- }
- // Fixes a selection range after it ends up in graveyard root.
- //
- // @private
- // @param {module:engine/model/liverange~LiveRange} liveRange The range from selection, that ended up in the graveyard root.
- // @param {module:engine/model/position~Position} removedRangeStart Start position of a range which was removed.
- _fixGraveyardSelection( liveRange, removedRangeStart ) {
- // The start of the removed range is the closest position to the `liveRange` - the original selection range.
- // This is a good candidate for a fixed selection range.
- const positionCandidate = removedRangeStart.clone();
- // Find a range that is a correct selection range and is closest to the start of removed range.
- const selectionRange = this._model.schema.getNearestSelectionRange( positionCandidate );
- // Remove the old selection range before preparing and adding new selection range. This order is important,
- // because new range, in some cases, may intersect with old range (it depends on `getNearestSelectionRange()` result).
- const index = this._ranges.indexOf( liveRange );
- this._ranges.splice( index, 1 );
- liveRange.detach();
- // If nearest valid selection range has been found - add it in the place of old range.
- if ( selectionRange ) {
- // Check the range, convert it to live range, bind events, etc.
- const newRange = this._prepareRange( selectionRange );
- // Add new range in the place of old range.
- this._ranges.splice( index, 0, newRange );
- }
- // If nearest valid selection range cannot be found - just removing the old range is fine.
- }
- }
- // Helper function for {@link module:engine/model/liveselection~LiveSelection#_updateAttributes}.
- //
- // It takes model item, checks whether it is a text node (or text proxy) and, if so, returns it's attributes. If not, returns `null`.
- //
- // @param {module:engine/model/item~Item|null} node
- // @returns {Boolean}
- function getAttrsIfCharacter( node ) {
- if ( node instanceof TextProxy || node instanceof Text ) {
- return node.getAttributes();
- }
- return null;
- }
- // Removes selection attributes from element which is not empty anymore.
- //
- // @private
- // @param {module:engine/model/model~Model} model
- // @param {module:engine/model/batch~Batch} batch
- function clearAttributesStoredInElement( model, batch ) {
- const differ = model.document.differ;
- for ( const entry of differ.getChanges() ) {
- if ( entry.type != 'insert' ) {
- continue;
- }
- const changeParent = entry.position.parent;
- const isNoLongerEmpty = entry.length === changeParent.maxOffset;
- if ( isNoLongerEmpty ) {
- model.enqueueChange( batch, writer => {
- const storedAttributes = Array.from( changeParent.getAttributeKeys() )
- .filter( key => key.startsWith( storePrefix ) );
- for ( const key of storedAttributes ) {
- writer.removeAttribute( key, changeParent );
- }
- } );
- }
- }
- }
|