8
0

liveselection.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/model/liveselection
  7. */
  8. import Position from './position.js';
  9. import Range from './range.js';
  10. import LiveRange from './liverange.js';
  11. import Text from './text.js';
  12. import TextProxy from './textproxy.js';
  13. import toMap from '../../utils/tomap.js';
  14. import CKEditorError from '../../utils/ckeditorerror.js';
  15. import log from '../../utils/log.js';
  16. import Selection from './selection.js';
  17. const storePrefix = 'selection:';
  18. const attrOpTypes = new Set(
  19. [ 'addAttribute', 'removeAttribute', 'changeAttribute', 'addRootAttribute', 'removeRootAttribute', 'changeRootAttribute' ]
  20. );
  21. /**
  22. * `LiveSelection` is a type of {@link module:engine/model/selection~Selection selection} that listens to changes on a
  23. * {@link module:engine/model/document~Document document} and has it ranges updated accordingly. Internal implementation of this
  24. * mechanism bases on {@link module:engine/model/liverange~LiveRange live ranges}.
  25. *
  26. * Differences between {@link module:engine/model/selection~Selection} and `LiveSelection` are two:
  27. * * there is always a range in `LiveSelection` - even if no ranges were added there is a
  28. * {@link module:engine/model/liveselection~LiveSelection#_getDefaultRange "default range"} present in the selection,
  29. * * ranges added to this selection updates automatically when the document changes.
  30. *
  31. * Since `LiveSelection` uses {@link module:engine/model/liverange~LiveRange live ranges}
  32. * and is updated when {@link module:engine/model/document~Document document}
  33. * changes, it cannot be set on {@link module:engine/model/node~Node nodes}
  34. * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
  35. * If you need to represent a selection in document fragment,
  36. * use {@link module:engine/model/selection~Selection "normal" selection} instead.
  37. *
  38. * @extends module:engine/model/selection~Selection
  39. */
  40. export default class LiveSelection extends Selection {
  41. /**
  42. * Creates an empty live selection for given {@link module:engine/model/document~Document}.
  43. *
  44. * @param {module:engine/model/document~Document} document Document which owns this selection.
  45. */
  46. constructor( document ) {
  47. super();
  48. /**
  49. * Document which owns this selection.
  50. *
  51. * @protected
  52. * @member {module:engine/model/document~Document} module:engine/model/liveselection~LiveSelection#_document
  53. */
  54. this._document = document;
  55. /**
  56. * Keeps mapping of attribute name to priority with which the attribute got modified (added/changed/removed)
  57. * last time. Possible values of priority are: `'low'` and `'normal'`.
  58. *
  59. * Priorities are used by internal `LiveSelection` mechanisms. All attributes set using `LiveSelection`
  60. * attributes API are set with `'normal'` priority.
  61. *
  62. * @private
  63. * @member {Map} module:engine/model/liveselection~LiveSelection#_attributePriority
  64. */
  65. this._attributePriority = new Map();
  66. // Whenever attribute operation is performed on document, update attributes. This is not the most efficient
  67. // way to update selection attributes, but should be okay for now. `_updateAttributes` will be fired too often,
  68. // but it won't change attributes or fire `change:attribute` event if not needed.
  69. this.listenTo( this._document, 'change', ( evt, type ) => {
  70. if ( attrOpTypes.has( type ) ) {
  71. this._updateAttributes( false );
  72. }
  73. } );
  74. }
  75. /**
  76. * @inheritDoc
  77. */
  78. get isCollapsed() {
  79. const length = this._ranges.length;
  80. return length === 0 ? true : super.isCollapsed;
  81. }
  82. /**
  83. * @inheritDoc
  84. */
  85. get anchor() {
  86. return super.anchor || this._document._getDefaultRange().start;
  87. }
  88. /**
  89. * @inheritDoc
  90. */
  91. get focus() {
  92. return super.focus || this._document._getDefaultRange().start;
  93. }
  94. /**
  95. * @inheritDoc
  96. */
  97. get rangeCount() {
  98. return this._ranges.length ? this._ranges.length : 1;
  99. }
  100. /**
  101. * Unbinds all events previously bound by document selection.
  102. */
  103. destroy() {
  104. for ( let i = 0; i < this._ranges.length; i++ ) {
  105. this._ranges[ i ].detach();
  106. }
  107. this.stopListening();
  108. }
  109. /**
  110. * @inheritDoc
  111. */
  112. *getRanges() {
  113. if ( this._ranges.length ) {
  114. yield *super.getRanges();
  115. } else {
  116. yield this._document._getDefaultRange();
  117. }
  118. }
  119. /**
  120. * @inheritDoc
  121. */
  122. getFirstRange() {
  123. return super.getFirstRange() || this._document._getDefaultRange();
  124. }
  125. /**
  126. * @inheritDoc
  127. */
  128. getLastRange() {
  129. return super.getLastRange() || this._document._getDefaultRange();
  130. }
  131. /**
  132. * @inheritDoc
  133. */
  134. addRange( range, isBackward = false ) {
  135. super.addRange( range, isBackward );
  136. this.refreshAttributes();
  137. }
  138. /**
  139. * @inheritDoc
  140. */
  141. removeAllRanges() {
  142. super.removeAllRanges();
  143. this.refreshAttributes();
  144. }
  145. /**
  146. * @inheritDoc
  147. */
  148. setRanges( newRanges, isLastBackward = false ) {
  149. super.setRanges( newRanges, isLastBackward );
  150. this.refreshAttributes();
  151. }
  152. /**
  153. * @inheritDoc
  154. */
  155. setAttribute( key, value ) {
  156. // Store attribute in parent element if the selection is collapsed in an empty node.
  157. if ( this.isCollapsed && this.anchor.parent.childCount === 0 ) {
  158. this._storeAttribute( key, value );
  159. }
  160. if ( this._setAttribute( key, value ) ) {
  161. // Fire event with exact data.
  162. const attributeKeys = [ key ];
  163. this.fire( 'change:attribute', { attributeKeys, directChange: true } );
  164. }
  165. }
  166. /**
  167. * @inheritDoc
  168. */
  169. removeAttribute( key ) {
  170. // Remove stored attribute from parent element if the selection is collapsed in an empty node.
  171. if ( this.isCollapsed && this.anchor.parent.childCount === 0 ) {
  172. this._removeStoredAttribute( key );
  173. }
  174. if ( this._removeAttribute( key ) ) {
  175. // Fire event with exact data.
  176. const attributeKeys = [ key ];
  177. this.fire( 'change:attribute', { attributeKeys, directChange: true } );
  178. }
  179. }
  180. /**
  181. * @inheritDoc
  182. */
  183. setAttributesTo( attrs ) {
  184. attrs = toMap( attrs );
  185. if ( this.isCollapsed && this.anchor.parent.childCount === 0 ) {
  186. this._setStoredAttributesTo( attrs );
  187. }
  188. const changed = this._setAttributesTo( attrs );
  189. if ( changed.size > 0 ) {
  190. // Fire event with exact data (fire only if anything changed).
  191. const attributeKeys = Array.from( changed );
  192. this.fire( 'change:attribute', { attributeKeys, directChange: true } );
  193. }
  194. }
  195. /**
  196. * @inheritDoc
  197. */
  198. clearAttributes() {
  199. this.setAttributesTo( [] );
  200. }
  201. /**
  202. * Removes all attributes from the selection and sets attributes according to the surrounding nodes.
  203. */
  204. refreshAttributes() {
  205. this._updateAttributes( true );
  206. }
  207. /**
  208. * Creates and returns an instance of `LiveSelection` that is a clone of given selection, meaning that it has same
  209. * ranges and same direction as this selection.
  210. *
  211. * @params {module:engine/model/selection~Selection} otherSelection Selection to be cloned.
  212. * @returns {module:engine/model/liveselection~LiveSelection} `Selection` instance that is a clone of given selection.
  213. */
  214. static createFromSelection( otherSelection ) {
  215. const selection = new this( otherSelection._document );
  216. selection.setTo( otherSelection );
  217. return selection;
  218. }
  219. /**
  220. * @inheritDoc
  221. */
  222. _popRange() {
  223. this._ranges.pop().detach();
  224. }
  225. /**
  226. * @inheritDoc
  227. */
  228. _pushRange( range ) {
  229. const liveRange = this._prepareRange( range );
  230. // `undefined` is returned when given `range` is in graveyard root.
  231. if ( liveRange ) {
  232. this._ranges.push( liveRange );
  233. }
  234. }
  235. /**
  236. * Prepares given range to be added to selection. Checks if it is correct,
  237. * converts it to {@link module:engine/model/liverange~LiveRange LiveRange}
  238. * and sets listeners listening to the range's change event.
  239. *
  240. * @private
  241. * @param {module:engine/model/range~Range} range
  242. */
  243. _prepareRange( range ) {
  244. if ( !( range instanceof Range ) ) {
  245. /**
  246. * Trying to add an object that is not an instance of Range.
  247. *
  248. * @error model-selection-added-not-range
  249. */
  250. throw new CKEditorError( 'model-selection-added-not-range: Trying to add an object that is not an instance of Range.' );
  251. }
  252. if ( range.root == this._document.graveyard ) {
  253. /**
  254. * Trying to add a Range that is in the graveyard root. Range rejected.
  255. *
  256. * @warning model-selection-range-in-graveyard
  257. */
  258. log.warn( 'model-selection-range-in-graveyard: Trying to add a Range that is in the graveyard root. Range rejected.' );
  259. return;
  260. }
  261. this._checkRange( range );
  262. const liveRange = LiveRange.createFromRange( range );
  263. this.listenTo( liveRange, 'change', ( evt, oldRange ) => {
  264. if ( liveRange.root == this._document.graveyard ) {
  265. this._fixGraveyardSelection( liveRange, oldRange );
  266. }
  267. this.fire( 'change:range', { directChange: false } );
  268. } );
  269. return liveRange;
  270. }
  271. /**
  272. * Updates this selection attributes according to its ranges and the {@link module:engine/model/document~Document model document}.
  273. *
  274. * @protected
  275. * @param {Boolean} clearAll
  276. * @fires change:attribute
  277. */
  278. _updateAttributes( clearAll ) {
  279. const newAttributes = toMap( this._getSurroundingAttributes() );
  280. const oldAttributes = toMap( this.getAttributes() );
  281. if ( clearAll ) {
  282. // If `clearAll` remove all attributes and reset priorities.
  283. this._attributePriority = new Map();
  284. this._attrs = new Map();
  285. } else {
  286. // If not, remove only attributes added with `low` priority.
  287. for ( let [ key, priority ] of this._attributePriority ) {
  288. if ( priority == 'low' ) {
  289. this._attrs.delete( key );
  290. this._attributePriority.delete( key );
  291. }
  292. }
  293. }
  294. this._setAttributesTo( newAttributes, false );
  295. // Let's evaluate which attributes really changed.
  296. const changed = [];
  297. // First, loop through all attributes that are set on selection right now.
  298. // Check which of them are different than old attributes.
  299. for ( let [ newKey, newValue ] of this.getAttributes() ) {
  300. if ( !oldAttributes.has( newKey ) || oldAttributes.get( newKey ) !== newValue ) {
  301. changed.push( newKey );
  302. }
  303. }
  304. // Then, check which of old attributes got removed.
  305. for ( let [ oldKey ] of oldAttributes ) {
  306. if ( !this.hasAttribute( oldKey ) ) {
  307. changed.push( oldKey );
  308. }
  309. }
  310. // Fire event with exact data (fire only if anything changed).
  311. if ( changed.length > 0 ) {
  312. this.fire( 'change:attribute', { attributeKeys: changed, directChange: false } );
  313. }
  314. }
  315. /**
  316. * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
  317. *
  318. * @protected
  319. * @param {String} key Attribute key to convert.
  320. * @returns {String} Converted attribute key, applicable for selection store.
  321. */
  322. static _getStoreAttributeKey( key ) {
  323. return storePrefix + key;
  324. }
  325. /**
  326. * Internal method for setting `LiveSelection` attribute. Supports attribute priorities (through `directChange`
  327. * parameter).
  328. *
  329. * @private
  330. * @param {String} key Attribute key.
  331. * @param {*} value Attribute value.
  332. * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
  333. * is caused by `Batch` API.
  334. * @returns {Boolean} Whether value has changed.
  335. */
  336. _setAttribute( key, value, directChange = true ) {
  337. const priority = directChange ? 'normal' : 'low';
  338. if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
  339. // Priority too low.
  340. return false;
  341. }
  342. const oldValue = super.getAttribute( key );
  343. // Don't do anything if value has not changed.
  344. if ( oldValue === value ) {
  345. return false;
  346. }
  347. this._attrs.set( key, value );
  348. // Update priorities map.
  349. this._attributePriority.set( key, priority );
  350. return true;
  351. }
  352. /**
  353. * Internal method for removing `LiveSelection` attribute. Supports attribute priorities (through `directChange`
  354. * parameter).
  355. *
  356. * @private
  357. * @param {String} key Attribute key.
  358. * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
  359. * is caused by `Batch` API.
  360. * @returns {Boolean} Whether attribute was removed. May not be true if such attributes didn't exist or the
  361. * existing attribute had higher priority.
  362. */
  363. _removeAttribute( key, directChange = true ) {
  364. const priority = directChange ? 'normal' : 'low';
  365. if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
  366. // Priority too low.
  367. return false;
  368. }
  369. // Don't do anything if value has not changed.
  370. if ( !super.hasAttribute( key ) ) {
  371. return false;
  372. }
  373. this._attrs.delete( key );
  374. // Update priorities map.
  375. this._attributePriority.set( key, priority );
  376. return true;
  377. }
  378. /**
  379. * Internal method for setting multiple `LiveSelection` attributes. Supports attribute priorities (through
  380. * `directChange` parameter).
  381. *
  382. * @private
  383. * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
  384. * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
  385. * is caused by `Batch` API.
  386. * @returns {Set.<String>} Changed attribute keys.
  387. */
  388. _setAttributesTo( attrs, directChange = true ) {
  389. const changed = new Set();
  390. for ( let [ oldKey, oldValue ] of this.getAttributes() ) {
  391. // Do not remove attribute if attribute with same key and value is about to be set.
  392. if ( attrs.get( oldKey ) === oldValue ) {
  393. continue;
  394. }
  395. // Attribute still might not get removed because of priorities.
  396. if ( this._removeAttribute( oldKey, directChange ) ) {
  397. changed.add( oldKey );
  398. }
  399. }
  400. for ( let [ key, value ] of attrs ) {
  401. // Attribute may not be set because of attributes or because same key/value is already added.
  402. const gotAdded = this._setAttribute( key, value, directChange );
  403. if ( gotAdded ) {
  404. changed.add( key );
  405. }
  406. }
  407. return changed;
  408. }
  409. /**
  410. * Returns an iterator that iterates through all selection attributes stored in current selection's parent.
  411. *
  412. * @private
  413. * @returns {Iterable.<*>}
  414. */
  415. *_getStoredAttributes() {
  416. const selectionParent = this.getFirstPosition().parent;
  417. if ( this.isCollapsed && selectionParent.childCount === 0 ) {
  418. for ( let key of selectionParent.getAttributeKeys() ) {
  419. if ( key.indexOf( storePrefix ) === 0 ) {
  420. const realKey = key.substr( storePrefix.length );
  421. yield [ realKey, selectionParent.getAttribute( key ) ];
  422. }
  423. }
  424. }
  425. }
  426. /**
  427. * Removes attribute with given key from attributes stored in current selection's parent node.
  428. *
  429. * @private
  430. * @param {String} key Key of attribute to remove.
  431. */
  432. _removeStoredAttribute( key ) {
  433. const storeKey = LiveSelection._getStoreAttributeKey( key );
  434. this._document.batch().removeAttribute( this.anchor.parent, storeKey );
  435. }
  436. /**
  437. * Stores given attribute key and value in current selection's parent node.
  438. *
  439. * @private
  440. * @param {String} key Key of attribute to set.
  441. * @param {*} value Attribute value.
  442. */
  443. _storeAttribute( key, value ) {
  444. const storeKey = LiveSelection._getStoreAttributeKey( key );
  445. this._document.batch().setAttribute( this.anchor.parent, storeKey, value );
  446. }
  447. /**
  448. * Sets selection attributes stored in current selection's parent node to given set of attributes.
  449. *
  450. * @private
  451. * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
  452. */
  453. _setStoredAttributesTo( attrs ) {
  454. const selectionParent = this.anchor.parent;
  455. const batch = this._document.batch();
  456. for ( let [ oldKey ] of this._getStoredAttributes() ) {
  457. const storeKey = LiveSelection._getStoreAttributeKey( oldKey );
  458. batch.removeAttribute( selectionParent, storeKey );
  459. }
  460. for ( let [ key, value ] of attrs ) {
  461. const storeKey = LiveSelection._getStoreAttributeKey( key );
  462. batch.setAttribute( selectionParent, storeKey, value );
  463. }
  464. }
  465. /**
  466. * Checks model text nodes that are closest to the selection's first position and returns attributes of first
  467. * found element. If there are no text nodes in selection's first position parent, it returns selection
  468. * attributes stored in that parent.
  469. *
  470. * @private
  471. * @returns {Iterable.<*>} Collection of attributes.
  472. */
  473. _getSurroundingAttributes() {
  474. const position = this.getFirstPosition();
  475. let attrs = null;
  476. if ( !this.isCollapsed ) {
  477. // 1. If selection is a range...
  478. const range = this.getFirstRange();
  479. // ...look for a first character node in that range and take attributes from it.
  480. for ( let item of range ) {
  481. // This is not an optimal solution because of https://github.com/ckeditor/ckeditor5-engine/issues/454.
  482. // It can be done better by using `break;` instead of checking `attrs === null`.
  483. if ( item.type == 'text' && attrs === null ) {
  484. attrs = item.item.getAttributes();
  485. }
  486. }
  487. } else {
  488. // 2. If the selection is a caret or the range does not contain a character node...
  489. const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
  490. const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
  491. // ...look at the node before caret and take attributes from it if it is a character node.
  492. attrs = getAttrsIfCharacter( nodeBefore );
  493. // 3. If not, look at the node after caret...
  494. if ( !attrs ) {
  495. attrs = getAttrsIfCharacter( nodeAfter );
  496. }
  497. // 4. If not, try to find the first character on the left, that is in the same node.
  498. if ( !attrs ) {
  499. let node = nodeBefore;
  500. while ( node && !attrs ) {
  501. node = node.previousSibling;
  502. attrs = getAttrsIfCharacter( node );
  503. }
  504. }
  505. // 5. If not found, try to find the first character on the right, that is in the same node.
  506. if ( !attrs ) {
  507. let node = nodeAfter;
  508. while ( node && !attrs ) {
  509. node = node.nextSibling;
  510. attrs = getAttrsIfCharacter( node );
  511. }
  512. }
  513. // 6. If not found, selection should retrieve attributes from parent.
  514. if ( !attrs ) {
  515. attrs = this._getStoredAttributes();
  516. }
  517. }
  518. return attrs;
  519. }
  520. /**
  521. * Fixes a selection range after it ends up in graveyard root.
  522. *
  523. * @private
  524. * @param {module:engine/model/liverange~LiveRange} gyRange The range added in selection, that ended up in graveyard root.
  525. * @param {module:engine/model/range~Range} oldRange The state of that range before it was added to graveyard root.
  526. */
  527. _fixGraveyardSelection( gyRange, oldRange ) {
  528. const gyPath = gyRange.start.path;
  529. const newPathLength = oldRange.start.path.length - ( gyPath.length - 2 );
  530. const newPath = oldRange.start.path.slice( 0, newPathLength );
  531. newPath[ newPath.length - 1 ] -= gyPath[ 1 ];
  532. const newPosition = new Position( oldRange.root, newPath );
  533. const selectionPosition = this._document.getNearestSelectionPosition( newPosition );
  534. const newRange = this._prepareRange( new Range( selectionPosition ) );
  535. const index = this._ranges.indexOf( gyRange );
  536. this._ranges.splice( index, 1, newRange );
  537. gyRange.detach();
  538. }
  539. }
  540. /**
  541. * @event change:attribute
  542. */
  543. // Helper function for {@link module:engine/model/liveselection~LiveSelection#_updateAttributes}. It takes model item, checks whether
  544. // it is a text node (or text proxy) and if so, returns it's attributes. If not, returns `null`.
  545. //
  546. // @param {module:engine/model/item~Item} node
  547. // @returns {Boolean}
  548. function getAttrsIfCharacter( node ) {
  549. if ( node instanceof TextProxy || node instanceof Text ) {
  550. return node.getAttributes();
  551. }
  552. return null;
  553. }