renderer.js 33 KB

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