domconverter.js 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/view/domconverter
  7. */
  8. /* globals document, Node, NodeFilter */
  9. import ViewText from './text';
  10. import ViewElement from './element';
  11. import ViewPosition from './position';
  12. import ViewRange from './range';
  13. import ViewSelection from './selection';
  14. import ViewDocumentFragment from './documentfragment';
  15. import { BR_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, isInlineFiller, startsWithFiller, getDataWithoutFiller } from './filler';
  16. import { getTouchingTextNode } from './utils';
  17. import global from '@ckeditor/ckeditor5-utils/src/dom/global';
  18. import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof';
  19. import getAncestors from '@ckeditor/ckeditor5-utils/src/dom/getancestors';
  20. import getCommonAncestor from '@ckeditor/ckeditor5-utils/src/dom/getcommonancestor';
  21. /**
  22. * DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
  23. * {@link module:engine/view/domconverter~DomConverter#bindElements binding} these nodes.
  24. *
  25. * DomConverter does not check which nodes should be rendered (use {@link module:engine/view/renderer~Renderer}), does not keep a
  26. * state of a tree nor keeps synchronization between tree view and DOM tree (use {@link module:engine/view/document~Document}).
  27. *
  28. * DomConverter keeps DOM elements to View element bindings, so when the converter will be destroyed, the binding will
  29. * be lost. Two converters will keep separate binding maps, so one tree view can be bound with two DOM trees.
  30. */
  31. export default class DomConverter {
  32. /**
  33. * Creates DOM converter.
  34. *
  35. * @param {Object} options Object with configuration options.
  36. * @param {Function} [options.blockFiller=module:engine/view/filler~BR_FILLER] Block filler creator.
  37. */
  38. constructor( options = {} ) {
  39. // Using WeakMap prevent memory leaks: when the converter will be destroyed all referenced between View and DOM
  40. // will be removed. Also because it is a *Weak*Map when both view and DOM elements will be removed referenced
  41. // will be also removed, isn't it brilliant?
  42. //
  43. // Yes, PJ. It is.
  44. //
  45. // You guys so smart.
  46. //
  47. // I've been here. Seen stuff. Afraid of code now.
  48. /**
  49. * Block {@link module:engine/view/filler filler} creator, which is used to create all block fillers during the
  50. * view to DOM conversion and to recognize block fillers during the DOM to view conversion.
  51. *
  52. * @readonly
  53. * @member {Function} module:engine/view/domconverter~DomConverter#blockFiller
  54. */
  55. this.blockFiller = options.blockFiller || BR_FILLER;
  56. /**
  57. * Tag names of DOM `Element`s which are considered pre-formatted elements.
  58. *
  59. * @member {Array.<String>} module:engine/view/domconverter~DomConverter#preElements
  60. */
  61. this.preElements = [ 'pre', 'code' ];
  62. /**
  63. * Tag names of DOM `Element`s which are considered block elements.
  64. *
  65. * @member {Array.<String>} module:engine/view/domconverter~DomConverter#blockElements
  66. */
  67. this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ];
  68. /**
  69. * DOM to View mapping.
  70. *
  71. * @private
  72. * @member {WeakMap} module:engine/view/domconverter~DomConverter#_domToViewMapping
  73. */
  74. this._domToViewMapping = new WeakMap();
  75. /**
  76. * View to DOM mapping.
  77. *
  78. * @private
  79. * @member {WeakMap} module:engine/view/domconverter~DomConverter#_viewToDomMapping
  80. */
  81. this._viewToDomMapping = new WeakMap();
  82. /**
  83. * Holds mapping between fake selection containers and corresponding view selections.
  84. *
  85. * @private
  86. * @member {WeakMap} module:engine/view/domconverter~DomConverter#_fakeSelectionMapping
  87. */
  88. this._fakeSelectionMapping = new WeakMap();
  89. }
  90. /**
  91. * Binds given DOM element that represents fake selection to {@link module:engine/view/selection~Selection view selection}.
  92. * View selection copy is stored and can be retrieved by {@link module:engine/view/domconverter~DomConverter#fakeSelectionToView}
  93. * method.
  94. *
  95. * @param {HTMLElement} domElement
  96. * @param {module:engine/view/selection~Selection} viewSelection
  97. */
  98. bindFakeSelection( domElement, viewSelection ) {
  99. this._fakeSelectionMapping.set( domElement, ViewSelection.createFromSelection( viewSelection ) );
  100. }
  101. /**
  102. * Returns {@link module:engine/view/selection~Selection view selection} instance corresponding to given DOM element that represents
  103. * fake selection. Returns `undefined` if binding to given DOM element does not exists.
  104. *
  105. * @param {HTMLElement} domElement
  106. * @returns {module:engine/view/selection~Selection|undefined}
  107. */
  108. fakeSelectionToView( domElement ) {
  109. return this._fakeSelectionMapping.get( domElement );
  110. }
  111. /**
  112. * Binds DOM and View elements, so it will be possible to get corresponding elements using
  113. * {@link module:engine/view/domconverter~DomConverter#mapDomToView} and
  114. * {@link module:engine/view/domconverter~DomConverter#mapViewToDom}.
  115. *
  116. * @param {HTMLElement} domElement DOM element to bind.
  117. * @param {module:engine/view/element~Element} viewElement View element to bind.
  118. */
  119. bindElements( domElement, viewElement ) {
  120. this._domToViewMapping.set( domElement, viewElement );
  121. this._viewToDomMapping.set( viewElement, domElement );
  122. }
  123. /**
  124. * Unbinds given `domElement` from the view element it was bound to. Unbinding is deep, meaning that all children of
  125. * `domElement` will be unbound too.
  126. *
  127. * @param {HTMLElement} domElement DOM element to unbind.
  128. */
  129. unbindDomElement( domElement ) {
  130. const viewElement = this._domToViewMapping.get( domElement );
  131. if ( viewElement ) {
  132. this._domToViewMapping.delete( domElement );
  133. this._viewToDomMapping.delete( viewElement );
  134. // Use Array.from because of MS Edge (#923).
  135. for ( const child of Array.from( domElement.childNodes ) ) {
  136. this.unbindDomElement( child );
  137. }
  138. }
  139. }
  140. /**
  141. * Binds DOM and View document fragments, so it will be possible to get corresponding document fragments using
  142. * {@link module:engine/view/domconverter~DomConverter#mapDomToView} and
  143. * {@link module:engine/view/domconverter~DomConverter#mapViewToDom}.
  144. *
  145. * @param {DocumentFragment} domFragment DOM document fragment to bind.
  146. * @param {module:engine/view/documentfragment~DocumentFragment} viewFragment View document fragment to bind.
  147. */
  148. bindDocumentFragments( domFragment, viewFragment ) {
  149. this._domToViewMapping.set( domFragment, viewFragment );
  150. this._viewToDomMapping.set( viewFragment, domFragment );
  151. }
  152. /**
  153. * Converts view to DOM. For all text nodes, not bound elements and document fragments new items will
  154. * be created. For bound elements and document fragments function will return corresponding items.
  155. *
  156. * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} viewNode
  157. * View node or document fragment to transform.
  158. * @param {Document} domDocument Document which will be used to create DOM nodes.
  159. * @param {Object} [options] Conversion options.
  160. * @param {Boolean} [options.bind=false] Determines whether new elements will be bound.
  161. * @param {Boolean} [options.withChildren=true] If true node's and document fragment's children will be converted too.
  162. * @returns {Node|DocumentFragment} Converted node or DocumentFragment.
  163. */
  164. viewToDom( viewNode, domDocument, options = {} ) {
  165. if ( viewNode.is( 'text' ) ) {
  166. const textData = this._processDataFromViewText( viewNode );
  167. return domDocument.createTextNode( textData );
  168. } else {
  169. if ( this.mapViewToDom( viewNode ) ) {
  170. return this.mapViewToDom( viewNode );
  171. }
  172. let domElement;
  173. if ( viewNode.is( 'documentFragment' ) ) {
  174. // Create DOM document fragment.
  175. domElement = domDocument.createDocumentFragment();
  176. if ( options.bind ) {
  177. this.bindDocumentFragments( domElement, viewNode );
  178. }
  179. } else if ( viewNode.is( 'uiElement' ) ) {
  180. // UIElement has its own render() method (see #799).
  181. domElement = viewNode.render( domDocument );
  182. if ( options.bind ) {
  183. this.bindElements( domElement, viewNode );
  184. }
  185. return domElement;
  186. } else {
  187. // Create DOM element.
  188. domElement = domDocument.createElement( viewNode.name );
  189. if ( options.bind ) {
  190. this.bindElements( domElement, viewNode );
  191. }
  192. // Copy element's attributes.
  193. for ( const key of viewNode.getAttributeKeys() ) {
  194. domElement.setAttribute( key, viewNode.getAttribute( key ) );
  195. }
  196. }
  197. if ( options.withChildren || options.withChildren === undefined ) {
  198. for ( const child of this.viewChildrenToDom( viewNode, domDocument, options ) ) {
  199. domElement.appendChild( child );
  200. }
  201. }
  202. return domElement;
  203. }
  204. }
  205. /**
  206. * Converts children of the view element to DOM using the
  207. * {@link module:engine/view/domconverter~DomConverter#viewToDom} method.
  208. * Additionally, this method adds block {@link module:engine/view/filler filler} to the list of children, if needed.
  209. *
  210. * @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElement Parent view element.
  211. * @param {Document} domDocument Document which will be used to create DOM nodes.
  212. * @param {Object} options See {@link module:engine/view/domconverter~DomConverter#viewToDom} options parameter.
  213. * @returns {Iterable.<Node>} DOM nodes.
  214. */
  215. * viewChildrenToDom( viewElement, domDocument, options = {} ) {
  216. const fillerPositionOffset = viewElement.getFillerOffset && viewElement.getFillerOffset();
  217. let offset = 0;
  218. for ( const childView of viewElement.getChildren() ) {
  219. if ( fillerPositionOffset === offset ) {
  220. yield this.blockFiller( domDocument );
  221. }
  222. yield this.viewToDom( childView, domDocument, options );
  223. offset++;
  224. }
  225. if ( fillerPositionOffset === offset ) {
  226. yield this.blockFiller( domDocument );
  227. }
  228. }
  229. /**
  230. * Converts view {@link module:engine/view/range~Range} to DOM range.
  231. * Inline and block {@link module:engine/view/filler fillers} are handled during the conversion.
  232. *
  233. * @param {module:engine/view/range~Range} viewRange View range.
  234. * @returns {Range} DOM range.
  235. */
  236. viewRangeToDom( viewRange ) {
  237. const domStart = this.viewPositionToDom( viewRange.start );
  238. const domEnd = this.viewPositionToDom( viewRange.end );
  239. const domRange = document.createRange();
  240. domRange.setStart( domStart.parent, domStart.offset );
  241. domRange.setEnd( domEnd.parent, domEnd.offset );
  242. return domRange;
  243. }
  244. /**
  245. * Converts view {@link module:engine/view/position~Position} to DOM parent and offset.
  246. *
  247. * Inline and block {@link module:engine/view/filler fillers} are handled during the conversion.
  248. * If the converted position is directly before inline filler it is moved inside the filler.
  249. *
  250. * @param {module:engine/view/position~Position} viewPosition View position.
  251. * @returns {Object|null} position DOM position or `null` if view position could not be converted to DOM.
  252. * @returns {Node} position.parent DOM position parent.
  253. * @returns {Number} position.offset DOM position offset.
  254. */
  255. viewPositionToDom( viewPosition ) {
  256. const viewParent = viewPosition.parent;
  257. if ( viewParent.is( 'text' ) ) {
  258. const domParent = this.findCorrespondingDomText( viewParent );
  259. if ( !domParent ) {
  260. // Position is in a view text node that has not been rendered to DOM yet.
  261. return null;
  262. }
  263. let offset = viewPosition.offset;
  264. if ( startsWithFiller( domParent ) ) {
  265. offset += INLINE_FILLER_LENGTH;
  266. }
  267. return { parent: domParent, offset };
  268. } else {
  269. // viewParent is instance of ViewElement.
  270. let domParent, domBefore, domAfter;
  271. if ( viewPosition.offset === 0 ) {
  272. domParent = this.mapViewToDom( viewParent );
  273. if ( !domParent ) {
  274. // Position is in a view element that has not been rendered to DOM yet.
  275. return null;
  276. }
  277. domAfter = domParent.childNodes[ 0 ];
  278. } else {
  279. const nodeBefore = viewPosition.nodeBefore;
  280. domBefore = nodeBefore.is( 'text' ) ?
  281. this.findCorrespondingDomText( nodeBefore ) :
  282. this.mapViewToDom( viewPosition.nodeBefore );
  283. if ( !domBefore ) {
  284. // Position is after a view element that has not been rendered to DOM yet.
  285. return null;
  286. }
  287. domParent = domBefore.parentNode;
  288. domAfter = domBefore.nextSibling;
  289. }
  290. // If there is an inline filler at position return position inside the filler. We should never return
  291. // the position before the inline filler.
  292. if ( this.isText( domAfter ) && startsWithFiller( domAfter ) ) {
  293. return { parent: domAfter, offset: INLINE_FILLER_LENGTH };
  294. }
  295. const offset = domBefore ? indexOf( domBefore ) + 1 : 0;
  296. return { parent: domParent, offset };
  297. }
  298. }
  299. /**
  300. * Converts DOM to view. For all text nodes, not bound elements and document fragments new items will
  301. * be created. For bound elements and document fragments function will return corresponding items. For
  302. * {@link module:engine/view/filler fillers} `null` will be returned.
  303. * For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
  304. *
  305. * @param {Node|DocumentFragment} domNode DOM node or document fragment to transform.
  306. * @param {Object} [options] Conversion options.
  307. * @param {Boolean} [options.bind=false] Determines whether new elements will be bound.
  308. * @param {Boolean} [options.withChildren=true] If `true`, node's and document fragment's children will be converted too.
  309. * @param {Boolean} [options.keepOriginalCase=false] If `false`, node's tag name will be converter to lower case.
  310. * @returns {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment|null} Converted node or document fragment
  311. * or `null` if DOM node is a {@link module:engine/view/filler filler} or the given node is an empty text node.
  312. */
  313. domToView( domNode, options = {} ) {
  314. if ( isBlockFiller( domNode, this.blockFiller ) ) {
  315. return null;
  316. }
  317. // When node is inside UIElement return that UIElement as it's view representation.
  318. const uiElement = this.getParentUIElement( domNode, this._domToViewMapping );
  319. if ( uiElement ) {
  320. return uiElement;
  321. }
  322. if ( this.isText( domNode ) ) {
  323. if ( isInlineFiller( domNode ) ) {
  324. return null;
  325. } else {
  326. const textData = this._processDataFromDomText( domNode );
  327. return textData === '' ? null : new ViewText( textData );
  328. }
  329. } else if ( this.isComment( domNode ) ) {
  330. return null;
  331. } else {
  332. if ( this.mapDomToView( domNode ) ) {
  333. return this.mapDomToView( domNode );
  334. }
  335. let viewElement;
  336. if ( this.isDocumentFragment( domNode ) ) {
  337. // Create view document fragment.
  338. viewElement = new ViewDocumentFragment();
  339. if ( options.bind ) {
  340. this.bindDocumentFragments( domNode, viewElement );
  341. }
  342. } else {
  343. // Create view element.
  344. const viewName = options.keepOriginalCase ? domNode.tagName : domNode.tagName.toLowerCase();
  345. viewElement = new ViewElement( viewName );
  346. if ( options.bind ) {
  347. this.bindElements( domNode, viewElement );
  348. }
  349. // Copy element's attributes.
  350. const attrs = domNode.attributes;
  351. for ( let i = attrs.length - 1; i >= 0; i-- ) {
  352. viewElement.setAttribute( attrs[ i ].name, attrs[ i ].value );
  353. }
  354. }
  355. if ( options.withChildren || options.withChildren === undefined ) {
  356. for ( const child of this.domChildrenToView( domNode, options ) ) {
  357. viewElement.appendChildren( child );
  358. }
  359. }
  360. return viewElement;
  361. }
  362. }
  363. /**
  364. * Converts children of the DOM element to view nodes using
  365. * the {@link module:engine/view/domconverter~DomConverter#domToView} method.
  366. * Additionally this method omits block {@link module:engine/view/filler filler}, if it exists in the DOM parent.
  367. *
  368. * @param {HTMLElement} domElement Parent DOM element.
  369. * @param {Object} options See {@link module:engine/view/domconverter~DomConverter#domToView} options parameter.
  370. * @returns {Iterable.<module:engine/view/node~Node>} View nodes.
  371. */
  372. * domChildrenToView( domElement, options = {} ) {
  373. for ( let i = 0; i < domElement.childNodes.length; i++ ) {
  374. const domChild = domElement.childNodes[ i ];
  375. const viewChild = this.domToView( domChild, options );
  376. if ( viewChild !== null ) {
  377. yield viewChild;
  378. }
  379. }
  380. }
  381. /**
  382. * Converts DOM selection to view {@link module:engine/view/selection~Selection}.
  383. * Ranges which cannot be converted will be omitted.
  384. *
  385. * @param {Selection} domSelection DOM selection.
  386. * @returns {module:engine/view/selection~Selection} View selection.
  387. */
  388. domSelectionToView( domSelection ) {
  389. // DOM selection might be placed in fake selection container.
  390. // If container contains fake selection - return corresponding view selection.
  391. if ( domSelection.rangeCount === 1 ) {
  392. let container = domSelection.getRangeAt( 0 ).startContainer;
  393. // The DOM selection might be moved to the text node inside the fake selection container.
  394. if ( this.isText( container ) ) {
  395. container = container.parentNode;
  396. }
  397. const viewSelection = this.fakeSelectionToView( container );
  398. if ( viewSelection ) {
  399. return viewSelection;
  400. }
  401. }
  402. const viewSelection = new ViewSelection();
  403. const isBackward = this.isDomSelectionBackward( domSelection );
  404. for ( let i = 0; i < domSelection.rangeCount; i++ ) {
  405. // DOM Range have correct start and end, no matter what is the DOM Selection direction. So we don't have to fix anything.
  406. const domRange = domSelection.getRangeAt( i );
  407. const viewRange = this.domRangeToView( domRange );
  408. if ( viewRange ) {
  409. viewSelection.addRange( viewRange, isBackward );
  410. }
  411. }
  412. return viewSelection;
  413. }
  414. /**
  415. * Converts DOM Range to view {@link module:engine/view/range~Range}.
  416. * If the start or end position can not be converted `null` is returned.
  417. *
  418. * @param {Range} domRange DOM range.
  419. * @returns {module:engine/view/range~Range|null} View range.
  420. */
  421. domRangeToView( domRange ) {
  422. const viewStart = this.domPositionToView( domRange.startContainer, domRange.startOffset );
  423. const viewEnd = this.domPositionToView( domRange.endContainer, domRange.endOffset );
  424. if ( viewStart && viewEnd ) {
  425. return new ViewRange( viewStart, viewEnd );
  426. }
  427. return null;
  428. }
  429. /**
  430. * Converts DOM parent and offset to view {@link module:engine/view/position~Position}.
  431. *
  432. * If the position is inside a {@link module:engine/view/filler filler} which has no corresponding view node,
  433. * position of the filler will be converted and returned.
  434. *
  435. * If the position is inside DOM element rendered by {@link module:engine/view/uielement~UIElement}
  436. * that position will be converted to view position before that UIElement.
  437. *
  438. * If structures are too different and it is not possible to find corresponding position then `null` will be returned.
  439. *
  440. * @param {Node} domParent DOM position parent.
  441. * @param {Number} domOffset DOM position offset.
  442. * @returns {module:engine/view/position~Position} viewPosition View position.
  443. */
  444. domPositionToView( domParent, domOffset ) {
  445. if ( isBlockFiller( domParent, this.blockFiller ) ) {
  446. return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
  447. }
  448. // If position is somewhere inside UIElement - return position before that element.
  449. const viewElement = this.mapDomToView( domParent );
  450. if ( viewElement && viewElement.is( 'uiElement' ) ) {
  451. return ViewPosition.createBefore( viewElement );
  452. }
  453. if ( this.isText( domParent ) ) {
  454. if ( isInlineFiller( domParent ) ) {
  455. return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
  456. }
  457. const viewParent = this.findCorrespondingViewText( domParent );
  458. let offset = domOffset;
  459. if ( !viewParent ) {
  460. return null;
  461. }
  462. if ( startsWithFiller( domParent ) ) {
  463. offset -= INLINE_FILLER_LENGTH;
  464. offset = offset < 0 ? 0 : offset;
  465. }
  466. return new ViewPosition( viewParent, offset );
  467. }
  468. // domParent instanceof HTMLElement.
  469. else {
  470. if ( domOffset === 0 ) {
  471. const viewParent = this.mapDomToView( domParent );
  472. if ( viewParent ) {
  473. return new ViewPosition( viewParent, 0 );
  474. }
  475. } else {
  476. const domBefore = domParent.childNodes[ domOffset - 1 ];
  477. const viewBefore = this.isText( domBefore ) ?
  478. this.findCorrespondingViewText( domBefore ) :
  479. this.mapDomToView( domBefore );
  480. // TODO #663
  481. if ( viewBefore && viewBefore.parent ) {
  482. return new ViewPosition( viewBefore.parent, viewBefore.index + 1 );
  483. }
  484. }
  485. return null;
  486. }
  487. }
  488. /**
  489. * Returns corresponding view {@link module:engine/view/element~Element Element} or
  490. * {@link module:engine/view/documentfragment~DocumentFragment} for provided DOM element or
  491. * document fragment. If there is no view item {@link module:engine/view/domconverter~DomConverter#bindElements bound}
  492. * to the given DOM - `undefined` is returned.
  493. * For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
  494. *
  495. * @param {DocumentFragment|Element} domElementOrDocumentFragment DOM element or document fragment.
  496. * @returns {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment|undefined}
  497. * Corresponding view element, document fragment or `undefined` if no element was bound.
  498. */
  499. mapDomToView( domElementOrDocumentFragment ) {
  500. return this.getParentUIElement( domElementOrDocumentFragment ) || this._domToViewMapping.get( domElementOrDocumentFragment );
  501. }
  502. /**
  503. * Finds corresponding text node. Text nodes are not {@link module:engine/view/domconverter~DomConverter#bindElements bound},
  504. * corresponding text node is returned based on the sibling or parent.
  505. *
  506. * If the directly previous sibling is a {@link module:engine/view/domconverter~DomConverter#bindElements bound} element, it is used
  507. * to find the corresponding text node.
  508. *
  509. * If this is a first child in the parent and the parent is a {@link module:engine/view/domconverter~DomConverter#bindElements bound}
  510. * element, it is used to find the corresponding text node.
  511. *
  512. * For all text nodes rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
  513. *
  514. * Otherwise `null` is returned.
  515. *
  516. * Note that for the block or inline {@link module:engine/view/filler filler} this method returns `null`.
  517. *
  518. * @param {Text} domText DOM text node.
  519. * @returns {module:engine/view/text~Text|null} Corresponding view text node or `null`, if it was not possible to find a
  520. * corresponding node.
  521. */
  522. findCorrespondingViewText( domText ) {
  523. if ( isInlineFiller( domText ) ) {
  524. return null;
  525. }
  526. // If DOM text was rendered by UIElement - return that element.
  527. const uiElement = this.getParentUIElement( domText );
  528. if ( uiElement ) {
  529. return uiElement;
  530. }
  531. const previousSibling = domText.previousSibling;
  532. // Try to use previous sibling to find the corresponding text node.
  533. if ( previousSibling ) {
  534. if ( !( this.isElement( previousSibling ) ) ) {
  535. // The previous is text or comment.
  536. return null;
  537. }
  538. const viewElement = this.mapDomToView( previousSibling );
  539. if ( viewElement ) {
  540. const nextSibling = viewElement.nextSibling;
  541. // It might be filler which has no corresponding view node.
  542. if ( nextSibling instanceof ViewText ) {
  543. return viewElement.nextSibling;
  544. } else {
  545. return null;
  546. }
  547. }
  548. }
  549. // Try to use parent to find the corresponding text node.
  550. else {
  551. const viewElement = this.mapDomToView( domText.parentNode );
  552. if ( viewElement ) {
  553. const firstChild = viewElement.getChild( 0 );
  554. // It might be filler which has no corresponding view node.
  555. if ( firstChild instanceof ViewText ) {
  556. return firstChild;
  557. } else {
  558. return null;
  559. }
  560. }
  561. }
  562. return null;
  563. }
  564. /**
  565. * Returns corresponding DOM item for provided {@link module:engine/view/element~Element Element} or
  566. * {@link module:engine/view/documentfragment~DocumentFragment DocumentFragment}.
  567. * To find a corresponding text for {@link module:engine/view/text~Text view Text instance}
  568. * use {@link #findCorrespondingDomText}.
  569. *
  570. * @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewNode
  571. * View element or document fragment.
  572. * @returns {Node|DocumentFragment|undefined} Corresponding DOM node or document fragment.
  573. */
  574. mapViewToDom( documentFragmentOrElement ) {
  575. return this._viewToDomMapping.get( documentFragmentOrElement );
  576. }
  577. /**
  578. * Finds corresponding text node. Text nodes are not {@link module:engine/view/domconverter~DomConverter#bindElements bound},
  579. * corresponding text node is returned based on the sibling or parent.
  580. *
  581. * If the directly previous sibling is a {@link module:engine/view/domconverter~DomConverter#bindElements bound} element, it is used
  582. * to find the corresponding text node.
  583. *
  584. * If this is a first child in the parent and the parent is a {@link module:engine/view/domconverter~DomConverter#bindElements bound}
  585. * element, it is used to find the corresponding text node.
  586. *
  587. * Otherwise `null` is returned.
  588. *
  589. * @param {module:engine/view/text~Text} viewText View text node.
  590. * @returns {Text|null} Corresponding DOM text node or `null`, if it was not possible to find a corresponding node.
  591. */
  592. findCorrespondingDomText( viewText ) {
  593. const previousSibling = viewText.previousSibling;
  594. // Try to use previous sibling to find the corresponding text node.
  595. if ( previousSibling && this.mapViewToDom( previousSibling ) ) {
  596. return this.mapViewToDom( previousSibling ).nextSibling;
  597. }
  598. // If this is a first node, try to use parent to find the corresponding text node.
  599. if ( !previousSibling && viewText.parent && this.mapViewToDom( viewText.parent ) ) {
  600. return this.mapViewToDom( viewText.parent ).childNodes[ 0 ];
  601. }
  602. return null;
  603. }
  604. /**
  605. * Focuses DOM editable that is corresponding to provided {@link module:engine/view/editableelement~EditableElement}.
  606. *
  607. * @param {module:engine/view/editableelement~EditableElement} viewEditable
  608. */
  609. focus( viewEditable ) {
  610. const domEditable = this.mapViewToDom( viewEditable );
  611. if ( domEditable && domEditable.ownerDocument.activeElement !== domEditable ) {
  612. // Save the scrollX and scrollY positions before the focus.
  613. const { scrollX, scrollY } = global.window;
  614. const scrollPositions = [];
  615. // Save all scrollLeft and scrollTop values starting from domEditable up to
  616. // document#documentElement.
  617. forEachDomNodeAncestor( domEditable, node => {
  618. const { scrollLeft, scrollTop } = node;
  619. scrollPositions.push( [ scrollLeft, scrollTop ] );
  620. } );
  621. domEditable.focus();
  622. // Restore scrollLeft and scrollTop values starting from domEditable up to
  623. // document#documentElement.
  624. // https://github.com/ckeditor/ckeditor5-engine/issues/951
  625. // https://github.com/ckeditor/ckeditor5-engine/issues/957
  626. forEachDomNodeAncestor( domEditable, node => {
  627. const [ scrollLeft, scrollTop ] = scrollPositions.shift();
  628. node.scrollLeft = scrollLeft;
  629. node.scrollTop = scrollTop;
  630. } );
  631. // Restore the scrollX and scrollY positions after the focus.
  632. // https://github.com/ckeditor/ckeditor5-engine/issues/951
  633. global.window.scrollTo( scrollX, scrollY );
  634. }
  635. }
  636. /**
  637. * Returns `true` when `node.nodeType` equals `Node.TEXT_NODE`.
  638. *
  639. * @param {Node} node Node to check.
  640. * @returns {Boolean}
  641. */
  642. isText( node ) {
  643. return node && node.nodeType == Node.TEXT_NODE;
  644. }
  645. /**
  646. * Returns `true` when `node.nodeType` equals `Node.ELEMENT_NODE`.
  647. *
  648. * @param {Node} node Node to check.
  649. * @returns {Boolean}
  650. */
  651. isElement( node ) {
  652. return node && node.nodeType == Node.ELEMENT_NODE;
  653. }
  654. /**
  655. * Returns `true` when `node.nodeType` equals `Node.DOCUMENT_FRAGMENT_NODE`.
  656. *
  657. * @param {Node} node Node to check.
  658. * @returns {Boolean}
  659. */
  660. isDocumentFragment( node ) {
  661. return node && node.nodeType == Node.DOCUMENT_FRAGMENT_NODE;
  662. }
  663. /**
  664. * Returns `true` when `node.nodeType` equals `Node.COMMENT_NODE`.
  665. *
  666. * @param {Node} node Node to check.
  667. * @returns {Boolean}
  668. */
  669. isComment( node ) {
  670. return node && node.nodeType == Node.COMMENT_NODE;
  671. }
  672. /**
  673. * Returns `true` if given selection is a backward selection, that is, if it's `focus` is before `anchor`.
  674. *
  675. * @param {Selection} DOM Selection instance to check.
  676. * @returns {Boolean}
  677. */
  678. isDomSelectionBackward( selection ) {
  679. if ( selection.isCollapsed ) {
  680. return false;
  681. }
  682. // Since it takes multiple lines of code to check whether a "DOM Position" is before/after another "DOM Position",
  683. // we will use the fact that range will collapse if it's end is before it's start.
  684. const range = document.createRange();
  685. range.setStart( selection.anchorNode, selection.anchorOffset );
  686. range.setEnd( selection.focusNode, selection.focusOffset );
  687. const backward = range.collapsed;
  688. range.detach();
  689. return backward;
  690. }
  691. /**
  692. * Returns parent {@link module:engine/view/uielement~UIElement} for provided DOM node. Returns `null` if there is no
  693. * parent UIElement.
  694. *
  695. * @param {Node} domNode
  696. * @return {module:engine/view/uielement~UIElement|null}
  697. */
  698. getParentUIElement( domNode ) {
  699. const ancestors = getAncestors( domNode );
  700. // Remove domNode from the list.
  701. ancestors.pop();
  702. while ( ancestors.length ) {
  703. const domNode = ancestors.pop();
  704. const viewNode = this._domToViewMapping.get( domNode );
  705. if ( viewNode && viewNode.is( 'uiElement' ) ) {
  706. return viewNode;
  707. }
  708. }
  709. return null;
  710. }
  711. /**
  712. * Checks if given selection's boundaries are at correct places.
  713. *
  714. * The following places are considered as incorrect for selection boundaries:
  715. * * before or in the middle of the inline filler sequence,
  716. * * inside the DOM element which represents {@link module:engine/view/uielement~UIElement a view ui element}.
  717. *
  718. * @param {Selection} domSelection DOM Selection object to be checked.
  719. * @returns {Boolean} `true` if the given selection is at a correct place, `false` otherwise.
  720. */
  721. isDomSelectionCorrect( domSelection ) {
  722. return this._isDomSelectionPositionCorrect( domSelection.anchorNode, domSelection.anchorOffset ) &&
  723. this._isDomSelectionPositionCorrect( domSelection.focusNode, domSelection.focusOffset );
  724. }
  725. /**
  726. * Checks if the given DOM position is a correct place for selection boundary. See {@link #isDomSelectionCorrect}.
  727. *
  728. * @private
  729. * @param {Element} domParent Position parent.
  730. * @param {Number} offset Position offset.
  731. * @returns {Boolean} `true` if given position is at a correct place for selection boundary, `false` otherwise.
  732. */
  733. _isDomSelectionPositionCorrect( domParent, offset ) {
  734. // If selection is before or in the middle of inline filler string, it is incorrect.
  735. if ( this.isText( domParent ) && startsWithFiller( domParent ) && offset < INLINE_FILLER_LENGTH ) {
  736. // Selection in a text node, at wrong position (before or in the middle of filler).
  737. return false;
  738. }
  739. if ( this.isElement( domParent ) && startsWithFiller( domParent.childNodes[ offset ] ) ) {
  740. // Selection in an element node, before filler text node.
  741. return false;
  742. }
  743. const viewParent = this.mapDomToView( domParent );
  744. // If selection is in `view.UIElement`, it is incorrect. Note that `mapDomToView()` returns `view.UIElement`
  745. // also for any dom element that is inside the view ui element (so we don't need to perform any additional checks).
  746. if ( viewParent && viewParent.is( 'uiElement' ) ) {
  747. return false;
  748. }
  749. return true;
  750. }
  751. /**
  752. * Takes text data from given {@link module:engine/view/text~Text#data} and processes it so it is correctly displayed in DOM.
  753. *
  754. * Following changes are done:
  755. *
  756. * * multiple spaces are replaced to a chain of spaces and `&nbsp;`,
  757. * * space at the beginning and at the end of the text node is changed to `&nbsp;` if it is a first text node in it's container
  758. * element or if previous text node ends with space character,
  759. * * space at the end of the text node is changed to `&nbsp;` if it is a last text node in it's container.
  760. *
  761. * @private
  762. * @param {module:engine/view/text~Text} node View text node to process.
  763. * @returns {String} Processed text data.
  764. */
  765. _processDataFromViewText( node ) {
  766. let data = node.data;
  767. // If any of node ancestors has a name which is in `preElements` array, then currently processed
  768. // view text node is (will be) in preformatted element. We should not change whitespaces then.
  769. if ( node.getAncestors().some( parent => this.preElements.includes( parent.name ) ) ) {
  770. return data;
  771. }
  772. // 1. Replace first space with nbsp if previous node ends with space or there is no previous node (container element boundary).
  773. if ( data.charAt( 0 ) == ' ' ) {
  774. const prevNode = getTouchingTextNode( node, false );
  775. const prevEndsWithSpace = prevNode && this._nodeEndsWithSpace( prevNode );
  776. if ( prevEndsWithSpace || !prevNode ) {
  777. data = '\u00A0' + data.substr( 1 );
  778. }
  779. }
  780. // 2. Replace last space with nbsp if it is the last text node (container element boundary).
  781. if ( data.charAt( data.length - 1 ) == ' ' ) {
  782. const nextNode = getTouchingTextNode( node, true );
  783. if ( !nextNode ) {
  784. data = data.substr( 0, data.length - 1 ) + '\u00A0';
  785. }
  786. }
  787. return data.replace( / {2}/gi, ' \u00A0' );
  788. }
  789. /**
  790. * Checks whether given node ends with a space character after changing appropriate space characters to `&nbsp;`s.
  791. *
  792. * @private
  793. * @param {module:engine/view/text~Text} node Node to check.
  794. * @returns {Boolean} `true` if given `node` ends with space, `false` otherwise.
  795. */
  796. _nodeEndsWithSpace( node ) {
  797. if ( node.getAncestors().some( parent => this.preElements.includes( parent.name ) ) ) {
  798. return false;
  799. }
  800. const data = this._processDataFromViewText( node );
  801. return data.charAt( data.length - 1 ) == ' ';
  802. }
  803. /**
  804. * Takes text data from native `Text` node and processes it to a correct {@link module:engine/view/text~Text view text node} data.
  805. *
  806. * Following changes are done:
  807. * * multiple whitespaces are replaced to a single space,
  808. * * space at the beginning of the text node is removed, if it is a first text node in it's container
  809. * element or if previous text node ends by space character,
  810. * * space at the end of the text node is removed, if it is a last text node in it's container.
  811. *
  812. * @param {Node} node DOM text node to process.
  813. * @returns {String} Processed data.
  814. * @private
  815. */
  816. _processDataFromDomText( node ) {
  817. let data = getDataWithoutFiller( node );
  818. if ( _hasDomParentOfType( node, this.preElements ) ) {
  819. return data;
  820. }
  821. // Change all consecutive whitespace characters (from the [ \n\t\r] set –
  822. // see https://github.com/ckeditor/ckeditor5-engine/issues/822#issuecomment-311670249) to a single space character.
  823. // That's how multiple whitespaces are treated when rendered, so we normalize those whitespaces.
  824. // We're replacing 1+ (and not 2+) to also normalize singular \n\t\r characters (#822).
  825. data = data.replace( /[ \n\t\r]{1,}/g, ' ' );
  826. const prevNode = this._getTouchingDomTextNode( node, false );
  827. const nextNode = this._getTouchingDomTextNode( node, true );
  828. // If previous dom text node does not exist or it ends by whitespace character, remove space character from the beginning
  829. // of this text node. Such space character is treated as a whitespace.
  830. if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
  831. data = data.replace( /^ /, '' );
  832. }
  833. // If next text node does not exist remove space character from the end of this text node.
  834. if ( !nextNode ) {
  835. data = data.replace( / $/, '' );
  836. }
  837. // At this point we should have removed all whitespaces from DOM text data.
  838. // Now we have to change &nbsp; chars, that were in DOM text data because of rendering reasons, to spaces.
  839. // First, change all ` \u00A0` pairs (space + &nbsp;) to two spaces. DOM converter changes two spaces from model/view as
  840. // ` \u00A0` to ensure proper rendering. Since here we convert back, we recognize those pairs and change them
  841. // to ` ` which is what we expect to have in model/view.
  842. data = data.replace( / \u00A0/g, ' ' );
  843. // Then, change &nbsp; character that is at the beginning of the text node to space character.
  844. // As above, that &nbsp; was created for rendering reasons but it's real meaning is just a space character.
  845. // We do that replacement only if this is the first node or the previous node ends on whitespace character.
  846. if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
  847. data = data.replace( /^\u00A0/, ' ' );
  848. }
  849. // Since input text data could be: `x_ _`, we would not replace the first &nbsp; after `x` character.
  850. // We have to fix it. Since we already change all ` &nbsp;`, we will have something like this at the end of text data:
  851. // `x_ _ _` -> `x_ `. Find &nbsp; at the end of string (can be followed only by spaces).
  852. // We do that replacement only if this is the last node or the next node starts by &nbsp;.
  853. if ( !nextNode || nextNode.data.charAt( 0 ) == '\u00A0' ) {
  854. data = data.replace( /\u00A0( *)$/, ' $1' );
  855. }
  856. // At this point, all whitespaces should be removed and all &nbsp; created for rendering reasons should be
  857. // changed to normal space. All left &nbsp; are &nbsp; inserted intentionally.
  858. return data;
  859. }
  860. /**
  861. * Helper function. For given `Text` node, it finds previous or next sibling that is contained in the same block element.
  862. * If there is no such sibling, `null` is returned.
  863. *
  864. * @private
  865. * @param {Text} node
  866. * @param {Boolean} getNext
  867. * @returns {Text|null}
  868. */
  869. _getTouchingDomTextNode( node, getNext ) {
  870. if ( !node.parentNode ) {
  871. return null;
  872. }
  873. const direction = getNext ? 'nextNode' : 'previousNode';
  874. const document = node.ownerDocument;
  875. const treeWalker = document.createTreeWalker( document.childNodes[ 0 ], NodeFilter.SHOW_TEXT );
  876. treeWalker.currentNode = node;
  877. const touchingNode = treeWalker[ direction ]();
  878. if ( touchingNode !== null ) {
  879. const lca = getCommonAncestor( node, touchingNode );
  880. // If there is common ancestor between the text node and next/prev text node,
  881. // and there are no block elements on a way from the text node to that ancestor,
  882. // and there are no block elements on a way from next/prev text node to that ancestor...
  883. if (
  884. lca &&
  885. !_hasDomParentOfType( node, this.blockElements, lca ) &&
  886. !_hasDomParentOfType( touchingNode, this.blockElements, lca )
  887. ) {
  888. // Then they are in the same container element.
  889. return touchingNode;
  890. }
  891. }
  892. return null;
  893. }
  894. }
  895. // Helper function.
  896. // Used to check if given native `Element` or `Text` node has parent with tag name from `types` array.
  897. //
  898. // @param {Node} node
  899. // @param {Array.<String>} types
  900. // @param {Boolean} [boundaryParent] Can be given if parents should be checked up to a given element (excluding that element).
  901. // @returns {Boolean} `true` if such parent exists or `false` if it does not.
  902. function _hasDomParentOfType( node, types, boundaryParent ) {
  903. let parents = getAncestors( node );
  904. if ( boundaryParent ) {
  905. parents = parents.slice( parents.indexOf( boundaryParent ) + 1 );
  906. }
  907. return parents.some( parent => parent.tagName && types.includes( parent.tagName.toLowerCase() ) );
  908. }
  909. // A helper that executes given callback for each DOM node's ancestor, starting from the given node
  910. // and ending in document#documentElement.
  911. //
  912. // @param {Node} node
  913. // @param {Function} callback A callback to be executed for each ancestor.
  914. function forEachDomNodeAncestor( node, callback ) {
  915. while ( node && node != global.document ) {
  916. callback( node );
  917. node = node.parentNode;
  918. }
  919. }