renderer.js 34 KB

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