8
0

renderer.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* globals Node */
  6. /**
  7. * @module engine/view/renderer
  8. */
  9. import ViewText from './text';
  10. import ViewPosition from './position';
  11. import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller, isBlockFiller } from './filler';
  12. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  13. import diff from '@ckeditor/ckeditor5-utils/src/diff';
  14. import insertAt from '@ckeditor/ckeditor5-utils/src/dom/insertat';
  15. import remove from '@ckeditor/ckeditor5-utils/src/dom/remove';
  16. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  17. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  18. import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
  19. import isNode from '@ckeditor/ckeditor5-utils/src/dom/isnode';
  20. import fastDiff from '@ckeditor/ckeditor5-utils/src/fastdiff';
  21. import env from '@ckeditor/ckeditor5-utils/src/env';
  22. /**
  23. * Renderer is responsible for updating the DOM structure and the DOM selection based on
  24. * the {@link module:engine/view/renderer~Renderer#markToSync information about updated view nodes}.
  25. * In other words, it renders the view to the DOM.
  26. *
  27. * Its main responsibility is to make only the necessary, minimal changes to the DOM. However, unlike in many
  28. * virtual DOM implementations, the primary reason for doing minimal changes is not the performance but ensuring
  29. * that native editing features such as text composition, autocompletion, spell checking, selection's x-index are
  30. * affected as little as possible.
  31. *
  32. * Renderer uses {@link module:engine/view/domconverter~DomConverter} to transform view nodes and positions
  33. * to and from the DOM.
  34. */
  35. export default class Renderer {
  36. /**
  37. * Creates a renderer instance.
  38. *
  39. * @param {module:engine/view/domconverter~DomConverter} domConverter Converter instance.
  40. * @param {module:engine/view/documentselection~DocumentSelection} selection View selection.
  41. */
  42. constructor( domConverter, selection ) {
  43. /**
  44. * Set of DOM Documents instances.
  45. *
  46. * @readonly
  47. * @member {Set.<Document>}
  48. */
  49. this.domDocuments = new Set();
  50. /**
  51. * Converter instance.
  52. *
  53. * @readonly
  54. * @member {module:engine/view/domconverter~DomConverter}
  55. */
  56. this.domConverter = domConverter;
  57. /**
  58. * Set of nodes which attributes changed and may need to be rendered.
  59. *
  60. * @readonly
  61. * @member {Set.<module:engine/view/node~Node>}
  62. */
  63. this.markedAttributes = new Set();
  64. /**
  65. * Set of elements which child lists changed and may need to be rendered.
  66. *
  67. * @readonly
  68. * @member {Set.<module:engine/view/node~Node>}
  69. */
  70. this.markedChildren = new Set();
  71. /**
  72. * Set of text nodes which text data changed and may need to be rendered.
  73. *
  74. * @readonly
  75. * @member {Set.<module:engine/view/node~Node>}
  76. */
  77. this.markedTexts = new Set();
  78. /**
  79. * View selection. Renderer updates DOM selection based on the view selection.
  80. *
  81. * @readonly
  82. * @member {module:engine/view/documentselection~DocumentSelection}
  83. */
  84. this.selection = selection;
  85. /**
  86. * Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if
  87. * this is set to `false`.
  88. *
  89. * @member {Boolean}
  90. */
  91. this.isFocused = false;
  92. /**
  93. * Indicates if the view document is composing.
  94. *
  95. * @member {Boolean}
  96. */
  97. this.isComposing = false;
  98. /**
  99. * The text node in which the inline filler was rendered.
  100. *
  101. * @private
  102. * @member {Text}
  103. */
  104. this._inlineFiller = null;
  105. /**
  106. * DOM element containing fake selection.
  107. *
  108. * @private
  109. * @type {null|HTMLElement}
  110. */
  111. this._fakeSelectionContainer = null;
  112. }
  113. /**
  114. * Marks a view node to be updated in the DOM by {@link #render `render()`}.
  115. *
  116. * Note that only view nodes whose parents have corresponding DOM elements need to be marked to be synchronized.
  117. *
  118. * @see #markedAttributes
  119. * @see #markedChildren
  120. * @see #markedTexts
  121. *
  122. * @param {module:engine/view/document~ChangeType} type Type of the change.
  123. * @param {module:engine/view/node~Node} node Node to be marked.
  124. */
  125. markToSync( type, node ) {
  126. if ( type === 'text' ) {
  127. if ( this.domConverter.mapViewToDom( node.parent ) ) {
  128. this.markedTexts.add( node );
  129. }
  130. } else {
  131. // If the node has no DOM element it is not rendered yet,
  132. // its children/attributes do not need to be marked to be sync.
  133. if ( !this.domConverter.mapViewToDom( node ) ) {
  134. return;
  135. }
  136. if ( type === 'attributes' ) {
  137. this.markedAttributes.add( node );
  138. } else if ( type === 'children' ) {
  139. this.markedChildren.add( node );
  140. } else {
  141. /**
  142. * Unknown type passed to Renderer.markToSync.
  143. *
  144. * @error renderer-unknown-type
  145. */
  146. throw new CKEditorError( 'view-renderer-unknown-type: Unknown type passed to Renderer.markToSync.', this );
  147. }
  148. }
  149. }
  150. /**
  151. * Renders all buffered changes ({@link #markedAttributes}, {@link #markedChildren} and {@link #markedTexts}) and
  152. * the current view selection (if needed) to the DOM by applying a minimal set of changes to it.
  153. *
  154. * Renderer tries not to break the text composition (e.g. IME) and x-index of the selection,
  155. * so it does as little as it is needed to update the DOM.
  156. *
  157. * Renderer also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed
  158. * at the selection position and adds or removes it. To prevent breaking text composition inline filler will not be
  159. * removed as long as the selection is in the text node which needed it at first.
  160. */
  161. render() {
  162. let inlineFillerPosition;
  163. // Refresh mappings.
  164. for ( const element of this.markedChildren ) {
  165. this._updateChildrenMappings( element );
  166. }
  167. // There was inline filler rendered in the DOM but it's not
  168. // at the selection position any more, so we can remove it
  169. // (cause even if it's needed, it must be placed in another location).
  170. if ( this._inlineFiller && !this._isSelectionInInlineFiller() ) {
  171. this._removeInlineFiller();
  172. }
  173. // If we've got the filler, let's try to guess its position in the view.
  174. if ( this._inlineFiller ) {
  175. inlineFillerPosition = this._getInlineFillerPosition();
  176. }
  177. // Otherwise, if it's needed, create it at the selection position.
  178. else if ( this._needsInlineFillerAtSelection() ) {
  179. inlineFillerPosition = this.selection.getFirstPosition();
  180. // Do not use `markToSync` so it will be added even if the parent is already added.
  181. this.markedChildren.add( inlineFillerPosition.parent );
  182. }
  183. for ( const element of this.markedAttributes ) {
  184. this._updateAttrs( element );
  185. }
  186. for ( const element of this.markedChildren ) {
  187. this._updateChildren( element, { inlineFillerPosition } );
  188. }
  189. for ( const node of this.markedTexts ) {
  190. if ( !this.markedChildren.has( node.parent ) && this.domConverter.mapViewToDom( node.parent ) ) {
  191. this._updateText( node, { inlineFillerPosition } );
  192. }
  193. }
  194. // Check whether the inline filler is required and where it really is in the DOM.
  195. // At this point in most cases it will be in the DOM, but there are exceptions.
  196. // For example, if the inline filler was deep in the created DOM structure, it will not be created.
  197. // Similarly, if it was removed at the beginning of this function and then neither text nor children were updated,
  198. // it will not be present.
  199. // Fix those and similar scenarios.
  200. if ( inlineFillerPosition ) {
  201. const fillerDomPosition = this.domConverter.viewPositionToDom( inlineFillerPosition );
  202. const domDocument = fillerDomPosition.parent.ownerDocument;
  203. if ( !startsWithFiller( fillerDomPosition.parent ) ) {
  204. // Filler has not been created at filler position. Create it now.
  205. this._inlineFiller = addInlineFiller( domDocument, fillerDomPosition.parent, fillerDomPosition.offset );
  206. } else {
  207. // Filler has been found, save it.
  208. this._inlineFiller = fillerDomPosition.parent;
  209. }
  210. } else {
  211. // There is no filler needed.
  212. this._inlineFiller = null;
  213. }
  214. this._updateSelection();
  215. this._updateFocus();
  216. this.markedTexts.clear();
  217. this.markedAttributes.clear();
  218. this.markedChildren.clear();
  219. }
  220. /**
  221. * Updates mappings of view element's children.
  222. *
  223. * Children that were replaced in the view structure by similar elements (same tag name) are treated as 'replaced'.
  224. * This means that their mappings can be updated so the new view elements are mapped to the existing DOM elements.
  225. * Thanks to that these elements do not need to be re-rendered completely.
  226. *
  227. * @private
  228. * @param {module:engine/view/node~Node} viewElement The view element whose children mappings will be updated.
  229. */
  230. _updateChildrenMappings( viewElement ) {
  231. const domElement = this.domConverter.mapViewToDom( viewElement );
  232. if ( !domElement ) {
  233. // If there is no `domElement` it means that it was already removed from DOM and there is no need to process it.
  234. return;
  235. }
  236. const actualDomChildren = this.domConverter.mapViewToDom( viewElement ).childNodes;
  237. const expectedDomChildren = Array.from(
  238. this.domConverter.viewChildrenToDom( viewElement, domElement.ownerDocument, { withChildren: false } )
  239. );
  240. const diff = this._diffNodeLists( actualDomChildren, expectedDomChildren );
  241. const actions = this._findReplaceActions( diff, actualDomChildren, expectedDomChildren );
  242. if ( actions.indexOf( 'replace' ) !== -1 ) {
  243. const counter = { equal: 0, insert: 0, delete: 0 };
  244. for ( const action of actions ) {
  245. if ( action === 'replace' ) {
  246. const insertIndex = counter.equal + counter.insert;
  247. const deleteIndex = counter.equal + counter.delete;
  248. const viewChild = viewElement.getChild( insertIndex );
  249. // The 'uiElement' is a special one and its children are not stored in a view (#799),
  250. // so we cannot use it with replacing flow (since it uses view children during rendering
  251. // which will always result in rendering empty element).
  252. if ( viewChild && !viewChild.is( 'uiElement' ) ) {
  253. this._updateElementMappings( viewChild, actualDomChildren[ deleteIndex ] );
  254. }
  255. remove( expectedDomChildren[ insertIndex ] );
  256. counter.equal++;
  257. } else {
  258. counter[ action ]++;
  259. }
  260. }
  261. }
  262. }
  263. /**
  264. * Updates mappings of a given view element.
  265. *
  266. * @private
  267. * @param {module:engine/view/node~Node} viewElement The view element whose mappings will be updated.
  268. * @param {Node} domElement The DOM element representing the given view element.
  269. */
  270. _updateElementMappings( viewElement, domElement ) {
  271. // Remap 'DomConverter' bindings.
  272. this.domConverter.unbindDomElement( domElement );
  273. this.domConverter.bindElements( domElement, viewElement );
  274. // View element may have children which needs to be updated, but are not marked, mark them to update.
  275. this.markedChildren.add( viewElement );
  276. // Because we replace new view element mapping with the existing one, the corresponding DOM element
  277. // will not be rerendered. The new view element may have different attributes than the previous one.
  278. // Since its corresponding DOM element will not be rerendered, new attributes will not be added
  279. // to the DOM, so we need to mark it here to make sure its attributes gets updated. See #1427 for more
  280. // detailed case study.
  281. // Also there are cases where replaced element is removed from the view structure and then has
  282. // its attributes changed or removed. In such cases the element will not be present in `markedAttributes`
  283. // and also may be the same (`element.isSimilar()`) as the reused element not having its attributes updated.
  284. // To prevent such situations we always mark reused element to have its attributes rerenderd (#1560).
  285. this.markedAttributes.add( viewElement );
  286. }
  287. /**
  288. * Gets the position of the inline filler based on the current selection.
  289. * Here, we assume that we know that the filler is needed and
  290. * {@link #_isSelectionInInlineFiller is at the selection position}, and, since it is needed,
  291. * it is somewhere at the selection position.
  292. *
  293. * Note: The filler position cannot be restored based on the filler's DOM text node, because
  294. * when this method is called (before rendering), the bindings will often be broken. View-to-DOM
  295. * bindings are only dependable after rendering.
  296. *
  297. * @private
  298. * @returns {module:engine/view/position~Position}
  299. */
  300. _getInlineFillerPosition() {
  301. const firstPos = this.selection.getFirstPosition();
  302. if ( firstPos.parent.is( 'text' ) ) {
  303. return ViewPosition._createBefore( this.selection.getFirstPosition().parent );
  304. } else {
  305. return firstPos;
  306. }
  307. }
  308. /**
  309. * Returns `true` if the selection has not left the inline filler's text node.
  310. * If it is `true`, it means that the filler had been added for a reason and the selection did not
  311. * leave the filler's text node. For example, the user can be in the middle of a composition so it should not be touched.
  312. *
  313. * @private
  314. * @returns {Boolean} `true` if the inline filler and selection are in the same place.
  315. */
  316. _isSelectionInInlineFiller() {
  317. if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
  318. return false;
  319. }
  320. // Note, we can't check if selection's position equals position of the
  321. // this._inlineFiller node, because of #663. We may not be able to calculate
  322. // the filler's position in the view at this stage.
  323. // Instead, we check it the other way – whether selection is anchored in
  324. // that text node or next to it.
  325. // Possible options are:
  326. // "FILLER{}"
  327. // "FILLERadded-text{}"
  328. const selectionPosition = this.selection.getFirstPosition();
  329. const position = this.domConverter.viewPositionToDom( selectionPosition );
  330. if ( position && isText( position.parent ) && startsWithFiller( position.parent ) ) {
  331. return true;
  332. }
  333. return false;
  334. }
  335. /**
  336. * Removes the inline filler.
  337. *
  338. * @private
  339. */
  340. _removeInlineFiller() {
  341. const domFillerNode = this._inlineFiller;
  342. // Something weird happened and the stored node doesn't contain the filler's text.
  343. if ( !startsWithFiller( domFillerNode ) ) {
  344. /**
  345. * The inline filler node was lost. Most likely, something overwrote the filler text node
  346. * in the DOM.
  347. *
  348. * @error view-renderer-filler-was-lost
  349. */
  350. throw new CKEditorError( 'view-renderer-filler-was-lost: The inline filler node was lost.', this );
  351. }
  352. if ( isInlineFiller( domFillerNode ) ) {
  353. domFillerNode.parentNode.removeChild( domFillerNode );
  354. } else {
  355. domFillerNode.data = domFillerNode.data.substr( INLINE_FILLER_LENGTH );
  356. }
  357. this._inlineFiller = null;
  358. }
  359. /**
  360. * Checks if the inline {@link module:engine/view/filler filler} should be added.
  361. *
  362. * @private
  363. * @returns {Boolean} `true` if the inline filler should be added.
  364. */
  365. _needsInlineFillerAtSelection() {
  366. if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
  367. return false;
  368. }
  369. const selectionPosition = this.selection.getFirstPosition();
  370. const selectionParent = selectionPosition.parent;
  371. const selectionOffset = selectionPosition.offset;
  372. // If there is no DOM root we do not care about fillers.
  373. if ( !this.domConverter.mapViewToDom( selectionParent.root ) ) {
  374. return false;
  375. }
  376. if ( !( selectionParent.is( 'element' ) ) ) {
  377. return false;
  378. }
  379. // Prevent adding inline filler inside elements with contenteditable=false.
  380. // https://github.com/ckeditor/ckeditor5-engine/issues/1170
  381. if ( !isEditable( selectionParent ) ) {
  382. return false;
  383. }
  384. // We have block filler, we do not need inline one.
  385. if ( selectionOffset === selectionParent.getFillerOffset() ) {
  386. return false;
  387. }
  388. const nodeBefore = selectionPosition.nodeBefore;
  389. const nodeAfter = selectionPosition.nodeAfter;
  390. if ( nodeBefore instanceof ViewText || nodeAfter instanceof ViewText ) {
  391. return false;
  392. }
  393. return true;
  394. }
  395. /**
  396. * Checks if text needs to be updated and possibly updates it.
  397. *
  398. * @private
  399. * @param {module:engine/view/text~Text} viewText View text to update.
  400. * @param {Object} options
  401. * @param {module:engine/view/position~Position} options.inlineFillerPosition The position where the inline
  402. * filler should be rendered.
  403. */
  404. _updateText( viewText, options ) {
  405. const domText = this.domConverter.findCorrespondingDomText( viewText );
  406. const newDomText = this.domConverter.viewToDom( viewText, domText.ownerDocument );
  407. const actualText = domText.data;
  408. let expectedText = newDomText.data;
  409. const filler = options.inlineFillerPosition;
  410. if ( filler && filler.parent == viewText.parent && filler.offset == viewText.index ) {
  411. expectedText = INLINE_FILLER + expectedText;
  412. }
  413. if ( actualText != expectedText ) {
  414. const actions = fastDiff( actualText, expectedText );
  415. for ( const action of actions ) {
  416. if ( action.type === 'insert' ) {
  417. domText.insertData( action.index, action.values.join( '' ) );
  418. } else { // 'delete'
  419. domText.deleteData( action.index, action.howMany );
  420. }
  421. }
  422. }
  423. }
  424. /**
  425. * Checks if attribute list needs to be updated and possibly updates it.
  426. *
  427. * @private
  428. * @param {module:engine/view/element~Element} viewElement The view element to update.
  429. */
  430. _updateAttrs( viewElement ) {
  431. const domElement = this.domConverter.mapViewToDom( viewElement );
  432. if ( !domElement ) {
  433. // If there is no `domElement` it means that 'viewElement' is outdated as its mapping was updated
  434. // in 'this._updateChildrenMappings()'. There is no need to process it as new view element which
  435. // replaced old 'viewElement' mapping was also added to 'this.markedAttributes'
  436. // in 'this._updateChildrenMappings()' so it will be processed separately.
  437. return;
  438. }
  439. const domAttrKeys = Array.from( domElement.attributes ).map( attr => attr.name );
  440. const viewAttrKeys = viewElement.getAttributeKeys();
  441. // Add or overwrite attributes.
  442. for ( const key of viewAttrKeys ) {
  443. domElement.setAttribute( key, viewElement.getAttribute( key ) );
  444. }
  445. // Remove from DOM attributes which do not exists in the view.
  446. for ( const key of domAttrKeys ) {
  447. if ( !viewElement.hasAttribute( key ) ) {
  448. domElement.removeAttribute( key );
  449. }
  450. }
  451. }
  452. /**
  453. * Checks if elements child list needs to be updated and possibly updates it.
  454. *
  455. * @private
  456. * @param {module:engine/view/element~Element} viewElement View element to update.
  457. * @param {Object} options
  458. * @param {module:engine/view/position~Position} options.inlineFillerPosition The position where the inline
  459. * filler should be rendered.
  460. */
  461. _updateChildren( viewElement, options ) {
  462. const domElement = this.domConverter.mapViewToDom( viewElement );
  463. if ( !domElement ) {
  464. // If there is no `domElement` it means that it was already removed from DOM.
  465. // There is no need to process it. It will be processed when re-inserted.
  466. return;
  467. }
  468. const inlineFillerPosition = options.inlineFillerPosition;
  469. const actualDomChildren = this.domConverter.mapViewToDom( viewElement ).childNodes;
  470. const expectedDomChildren = Array.from(
  471. this.domConverter.viewChildrenToDom( viewElement, domElement.ownerDocument, { bind: true, inlineFillerPosition } )
  472. );
  473. // Inline filler element has to be created as it is present in the DOM, but not in the view. It is required
  474. // during diffing so text nodes could be compared correctly and also during rendering to maintain
  475. // proper order and indexes while updating the DOM.
  476. if ( inlineFillerPosition && inlineFillerPosition.parent === viewElement ) {
  477. addInlineFiller( domElement.ownerDocument, expectedDomChildren, inlineFillerPosition.offset );
  478. }
  479. const diff = this._diffNodeLists( actualDomChildren, expectedDomChildren );
  480. let i = 0;
  481. const nodesToUnbind = new Set();
  482. for ( const action of diff ) {
  483. if ( action === 'insert' ) {
  484. insertAt( domElement, i, expectedDomChildren[ i ] );
  485. i++;
  486. } else if ( action === 'delete' ) {
  487. nodesToUnbind.add( actualDomChildren[ i ] );
  488. remove( actualDomChildren[ i ] );
  489. } else { // 'equal'
  490. // Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125).
  491. this._markDescendantTextToSync( this.domConverter.domToView( expectedDomChildren[ i ] ) );
  492. i++;
  493. }
  494. }
  495. // Unbind removed nodes. When node does not have a parent it means that it was removed from DOM tree during
  496. // comparision with the expected DOM. We don't need to check child nodes, because if child node was reinserted,
  497. // it was moved to DOM tree out of the removed node.
  498. for ( const node of nodesToUnbind ) {
  499. if ( !node.parentNode ) {
  500. this.domConverter.unbindDomElement( node );
  501. }
  502. }
  503. }
  504. /**
  505. * Shorthand for diffing two arrays or node lists of DOM nodes.
  506. *
  507. * @private
  508. * @param {Array.<Node>|NodeList} actualDomChildren Actual DOM children
  509. * @param {Array.<Node>|NodeList} expectedDomChildren Expected DOM children.
  510. * @returns {Array.<String>} The list of actions based on the {@link module:utils/diff~diff} function.
  511. */
  512. _diffNodeLists( actualDomChildren, expectedDomChildren ) {
  513. actualDomChildren = filterOutFakeSelectionContainer( actualDomChildren, this._fakeSelectionContainer );
  514. return diff( actualDomChildren, expectedDomChildren, sameNodes.bind( null, this.domConverter.blockFiller ) );
  515. }
  516. /**
  517. * Finds DOM nodes that were replaced with the similar nodes (same tag name) in the view. All nodes are compared
  518. * within one `insert`/`delete` action group, for example:
  519. *
  520. * Actual DOM: <p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p>
  521. * Expected DOM: <p>Bar<b>123</b><i>Baz</i><b>456</b></p>
  522. * Input actions: [ insert, insert, delete, delete, equal, insert, delete ]
  523. * Output actions: [ insert, replace, delete, equal, replace ]
  524. *
  525. * @private
  526. * @param {Array.<String>} actions Actions array which is a result of the {@link module:utils/diff~diff} function.
  527. * @param {Array.<Node>|NodeList} actualDom Actual DOM children
  528. * @param {Array.<Node>} expectedDom Expected DOM children.
  529. * @returns {Array.<String>} Actions array modified with the `replace` actions.
  530. */
  531. _findReplaceActions( actions, actualDom, expectedDom ) {
  532. // If there is no both 'insert' and 'delete' actions, no need to check for replaced elements.
  533. if ( actions.indexOf( 'insert' ) === -1 || actions.indexOf( 'delete' ) === -1 ) {
  534. return actions;
  535. }
  536. let newActions = [];
  537. let actualSlice = [];
  538. let expectedSlice = [];
  539. const counter = { equal: 0, insert: 0, delete: 0 };
  540. for ( const action of actions ) {
  541. if ( action === 'insert' ) {
  542. expectedSlice.push( expectedDom[ counter.equal + counter.insert ] );
  543. } else if ( action === 'delete' ) {
  544. actualSlice.push( actualDom[ counter.equal + counter.delete ] );
  545. } else { // equal
  546. newActions = newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) );
  547. newActions.push( 'equal' );
  548. // Reset stored elements on 'equal'.
  549. actualSlice = [];
  550. expectedSlice = [];
  551. }
  552. counter[ action ]++;
  553. }
  554. return newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) );
  555. }
  556. /**
  557. * Marks text nodes to be synchronized.
  558. *
  559. * If a text node is passed, it will be marked. If an element is passed, all descendant text nodes inside it will be marked.
  560. *
  561. * @private
  562. * @param {module:engine/view/node~Node} viewNode View node to sync.
  563. */
  564. _markDescendantTextToSync( viewNode ) {
  565. if ( !viewNode ) {
  566. return;
  567. }
  568. if ( viewNode.is( 'text' ) ) {
  569. this.markedTexts.add( viewNode );
  570. } else if ( viewNode.is( 'element' ) ) {
  571. for ( const child of viewNode.getChildren() ) {
  572. this._markDescendantTextToSync( child );
  573. }
  574. }
  575. }
  576. /**
  577. * Checks if the selection needs to be updated and possibly updates it.
  578. *
  579. * @private
  580. */
  581. _updateSelection() {
  582. // If there is no selection - remove DOM and fake selections.
  583. if ( this.selection.rangeCount === 0 ) {
  584. this._removeDomSelection();
  585. this._removeFakeSelection();
  586. return;
  587. }
  588. const domRoot = this.domConverter.mapViewToDom( this.selection.editableElement );
  589. // Do nothing if there is no focus, or there is no DOM element corresponding to selection's editable element.
  590. if ( !this.isFocused || !domRoot ) {
  591. return;
  592. }
  593. // Render selection.
  594. if ( this.selection.isFake ) {
  595. this._updateFakeSelection( domRoot );
  596. } else {
  597. this._removeFakeSelection();
  598. this._updateDomSelection( domRoot );
  599. }
  600. }
  601. /**
  602. * Updates the fake selection.
  603. *
  604. * @private
  605. * @param {HTMLElement} domRoot A valid DOM root where the fake selection container should be added.
  606. */
  607. _updateFakeSelection( domRoot ) {
  608. const domDocument = domRoot.ownerDocument;
  609. let container = this._fakeSelectionContainer;
  610. // Create fake selection container if one does not exist.
  611. if ( !container ) {
  612. this._fakeSelectionContainer = container = domDocument.createElement( 'div' );
  613. Object.assign( container.style, {
  614. position: 'fixed',
  615. top: 0,
  616. left: '-9999px',
  617. // See https://github.com/ckeditor/ckeditor5/issues/752.
  618. width: '42px'
  619. } );
  620. // Fill it with a text node so we can update it later.
  621. container.textContent = '\u00A0';
  622. }
  623. if ( !container.parentElement || container.parentElement != domRoot ) {
  624. domRoot.appendChild( container );
  625. }
  626. // Update contents.
  627. container.textContent = this.selection.fakeSelectionLabel || '\u00A0';
  628. // Update selection.
  629. const domSelection = domDocument.getSelection();
  630. const domRange = domDocument.createRange();
  631. domSelection.removeAllRanges();
  632. domRange.selectNodeContents( container );
  633. domSelection.addRange( domRange );
  634. // Bind fake selection container with current selection.
  635. this.domConverter.bindFakeSelection( container, this.selection );
  636. }
  637. /**
  638. * Updates the DOM selection.
  639. *
  640. * @private
  641. * @param {HTMLElement} domRoot A valid DOM root where the DOM selection should be rendered.
  642. */
  643. _updateDomSelection( domRoot ) {
  644. const domSelection = domRoot.ownerDocument.defaultView.getSelection();
  645. // Let's check whether DOM selection needs updating at all.
  646. if ( !this._domSelectionNeedsUpdate( domSelection ) ) {
  647. return;
  648. }
  649. // Multi-range selection is not available in most browsers, and, at least in Chrome, trying to
  650. // set such selection, that is not continuous, throws an error. Because of that, we will just use anchor
  651. // and focus of view selection.
  652. // Since we are not supporting multi-range selection, we also do not need to check if proper editable is
  653. // selected. If there is any editable selected, it is okay (editable is taken from selection anchor).
  654. const anchor = this.domConverter.viewPositionToDom( this.selection.anchor );
  655. const focus = this.domConverter.viewPositionToDom( this.selection.focus );
  656. // Focus the new editing host.
  657. // Otherwise, FF may throw an error (https://github.com/ckeditor/ckeditor5/issues/721).
  658. domRoot.focus();
  659. domSelection.collapse( anchor.parent, anchor.offset );
  660. domSelection.extend( focus.parent, focus.offset );
  661. // Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439).
  662. if ( env.isGecko ) {
  663. fixGeckoSelectionAfterBr( focus, domSelection );
  664. }
  665. }
  666. /**
  667. * Checks whether a given DOM selection needs to be updated.
  668. *
  669. * @private
  670. * @param {Selection} domSelection The DOM selection to check.
  671. * @returns {Boolean}
  672. */
  673. _domSelectionNeedsUpdate( domSelection ) {
  674. // Remain DOM selection untouched while composing (#1782)
  675. if ( this.isComposing ) {
  676. return false;
  677. }
  678. if ( !this.domConverter.isDomSelectionCorrect( domSelection ) ) {
  679. // Current DOM selection is in incorrect position. We need to update it.
  680. return true;
  681. }
  682. const oldViewSelection = domSelection && this.domConverter.domSelectionToView( domSelection );
  683. if ( oldViewSelection && this.selection.isEqual( oldViewSelection ) ) {
  684. return false;
  685. }
  686. // If selection is not collapsed, it does not need to be updated if it is similar.
  687. if ( !this.selection.isCollapsed && this.selection.isSimilar( oldViewSelection ) ) {
  688. // Selection did not changed and is correct, do not update.
  689. return false;
  690. }
  691. // Selections are not similar.
  692. return true;
  693. }
  694. /**
  695. * Removes the DOM selection.
  696. *
  697. * @private
  698. */
  699. _removeDomSelection() {
  700. for ( const doc of this.domDocuments ) {
  701. const domSelection = doc.getSelection();
  702. if ( domSelection.rangeCount ) {
  703. const activeDomElement = doc.activeElement;
  704. const viewElement = this.domConverter.mapDomToView( activeDomElement );
  705. if ( activeDomElement && viewElement ) {
  706. doc.getSelection().removeAllRanges();
  707. }
  708. }
  709. }
  710. }
  711. /**
  712. * Removes the fake selection.
  713. *
  714. * @private
  715. */
  716. _removeFakeSelection() {
  717. const container = this._fakeSelectionContainer;
  718. if ( container ) {
  719. container.remove();
  720. }
  721. }
  722. /**
  723. * Checks if focus needs to be updated and possibly updates it.
  724. *
  725. * @private
  726. */
  727. _updateFocus() {
  728. if ( this.isFocused ) {
  729. const editable = this.selection.editableElement;
  730. if ( editable ) {
  731. this.domConverter.focus( editable );
  732. }
  733. }
  734. }
  735. }
  736. mix( Renderer, ObservableMixin );
  737. // Checks if provided element is editable.
  738. //
  739. // @private
  740. // @param {module:engine/view/element~Element} element
  741. // @returns {Boolean}
  742. function isEditable( element ) {
  743. if ( element.getAttribute( 'contenteditable' ) == 'false' ) {
  744. return false;
  745. }
  746. const parent = element.findAncestor( element => element.hasAttribute( 'contenteditable' ) );
  747. return !parent || parent.getAttribute( 'contenteditable' ) == 'true';
  748. }
  749. // Adds inline filler at a given position.
  750. //
  751. // The position can be given as an array of DOM nodes and an offset in that array,
  752. // or a DOM parent element and an offset in that element.
  753. //
  754. // @private
  755. // @param {Document} domDocument
  756. // @param {Element|Array.<Node>} domParentOrArray
  757. // @param {Number} offset
  758. // @returns {Text} The DOM text node that contains an inline filler.
  759. function addInlineFiller( domDocument, domParentOrArray, offset ) {
  760. const childNodes = domParentOrArray instanceof Array ? domParentOrArray : domParentOrArray.childNodes;
  761. const nodeAfterFiller = childNodes[ offset ];
  762. if ( isText( nodeAfterFiller ) ) {
  763. nodeAfterFiller.data = INLINE_FILLER + nodeAfterFiller.data;
  764. return nodeAfterFiller;
  765. } else {
  766. const fillerNode = domDocument.createTextNode( INLINE_FILLER );
  767. if ( Array.isArray( domParentOrArray ) ) {
  768. childNodes.splice( offset, 0, fillerNode );
  769. } else {
  770. insertAt( domParentOrArray, offset, fillerNode );
  771. }
  772. return fillerNode;
  773. }
  774. }
  775. // Whether two DOM nodes should be considered as similar.
  776. // Nodes are considered similar if they have the same tag name.
  777. //
  778. // @private
  779. // @param {Node} node1
  780. // @param {Node} node2
  781. // @returns {Boolean}
  782. function areSimilar( node1, node2 ) {
  783. return isNode( node1 ) && isNode( node2 ) &&
  784. !isText( node1 ) && !isText( node2 ) &&
  785. node1.tagName.toLowerCase() === node2.tagName.toLowerCase();
  786. }
  787. // Whether two dom nodes should be considered as the same.
  788. // Two nodes which are considered the same are:
  789. //
  790. // * Text nodes with the same text.
  791. // * Element nodes represented by the same object.
  792. // * Two block filler elements.
  793. //
  794. // @private
  795. // @param {Function} blockFiller Block filler creator function, see {@link module:engine/view/domconverter~DomConverter#blockFiller}.
  796. // @param {Node} node1
  797. // @param {Node} node2
  798. // @returns {Boolean}
  799. function sameNodes( blockFiller, actualDomChild, expectedDomChild ) {
  800. // Elements.
  801. if ( actualDomChild === expectedDomChild ) {
  802. return true;
  803. }
  804. // Texts.
  805. else if ( isText( actualDomChild ) && isText( expectedDomChild ) ) {
  806. return actualDomChild.data === expectedDomChild.data;
  807. }
  808. // Block fillers.
  809. else if ( isBlockFiller( actualDomChild, blockFiller ) &&
  810. isBlockFiller( expectedDomChild, blockFiller ) ) {
  811. return true;
  812. }
  813. // Not matching types.
  814. return false;
  815. }
  816. // The following is a Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439).
  817. // When the native DOM selection is at the end of the block and preceded by <br /> e.g.
  818. //
  819. // <p>foo<br/>[]</p>
  820. //
  821. // which happens a lot when using the soft line break, the browser fails to (visually) move the
  822. // caret to the new line. A quick fix is as simple as force–refreshing the selection with the same range.
  823. function fixGeckoSelectionAfterBr( focus, domSelection ) {
  824. const parent = focus.parent;
  825. // This fix works only when the focus point is at the very end of an element.
  826. // There is no point in running it in cases unrelated to the browser bug.
  827. if ( parent.nodeType != Node.ELEMENT_NODE || focus.offset != parent.childNodes.length - 1 ) {
  828. return;
  829. }
  830. const childAtOffset = parent.childNodes[ focus.offset ];
  831. // To stay on the safe side, the fix being as specific as possible, it targets only the
  832. // selection which is at the very end of the element and preceded by <br />.
  833. if ( childAtOffset && childAtOffset.tagName == 'BR' ) {
  834. domSelection.addRange( domSelection.getRangeAt( 0 ) );
  835. }
  836. }
  837. function filterOutFakeSelectionContainer( domChildList, fakeSelectionContainer ) {
  838. const childList = Array.from( domChildList );
  839. if ( childList.length == 0 || !fakeSelectionContainer ) {
  840. return childList;
  841. }
  842. const last = childList[ childList.length - 1 ];
  843. if ( last == fakeSelectionContainer ) {
  844. childList.pop();
  845. }
  846. return childList;
  847. }