domconverter.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101
  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 ViewTreeWalker from './treewalker';
  16. import { BR_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, isInlineFiller, startsWithFiller, getDataWithoutFiller } from './filler';
  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' ];
  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. * Takes text data from given {@link module:engine/view/text~Text#data} and processes it so it is correctly displayed in DOM.
  713. *
  714. * Following changes are done:
  715. *
  716. * * multiple spaces are replaced to a chain of spaces and `&nbsp;`,
  717. * * space at the beginning of the text node is changed to `&nbsp;` if it is a first text node in it's container
  718. * element or if previous text node ends by space character,
  719. * * space at the end of the text node is changed to `&nbsp;` if it is a last text node in it's container.
  720. *
  721. * @private
  722. * @param {module:engine/view/text~Text} node View text node to process.
  723. * @returns {String} Processed text data.
  724. */
  725. _processDataFromViewText( node ) {
  726. const data = node.data;
  727. // If any of node ancestors has a name which is in `preElements` array, then currently processed
  728. // view text node is (will be) in preformatted element. We should not change whitespaces then.
  729. if ( node.getAncestors().some( parent => this.preElements.includes( parent.name ) ) ) {
  730. return data;
  731. }
  732. const prevNode = this._getTouchingViewTextNode( node, false );
  733. const nextNode = this._getTouchingViewTextNode( node, true );
  734. // Second part of text data, from the space after the last non-space character to the end.
  735. // We separate `textEnd` and `textStart` because `textEnd` needs some special handling.
  736. let textEnd = data.match( / *$/ )[ 0 ];
  737. // First part of data, between first and last part of data.
  738. let textStart = data.substr( 0, data.length - textEnd.length );
  739. // If previous text node does not exist or it ends by space character, replace space character at the beginning of text.
  740. // ` x` -> `_x`
  741. // ` x` -> `_ x`
  742. // ` x` -> `_ x`
  743. if ( !prevNode || prevNode.data.charAt( prevNode.data.length - 1 ) == ' ' ) {
  744. textStart = textStart.replace( /^ /, '\u00A0' );
  745. }
  746. // Multiple consecutive spaces. Change them to ` &nbsp;` pairs.
  747. // `_x x` -> `_x _x`
  748. // `_ x x` -> `_ x _x`
  749. // `_ x x` -> `_ _x _x`
  750. // `_ x x` -> `_ _x _ x`
  751. // `_ x x` -> `_ _x _ _x`
  752. // `_ x x` -> `_ _ x _ _x`
  753. textStart = textStart.replace( / {2}/g, ' \u00A0' );
  754. // Process `textEnd` only if there is anything to process.
  755. if ( textEnd.length > 0 ) {
  756. // (1) We need special treatment for the last part of text node, it has to end on `&nbsp;`, not space:
  757. // `x ` -> `x_`
  758. // `x ` -> `x _`
  759. // `x ` -> `x_ _`
  760. // `x ` -> `x _ _`
  761. // (2) Different case when there is a node after:
  762. // `x <b>b</b>` -> `x <b>b</b>`
  763. // `x <b>b</b>` -> `x _<b>b</b>`
  764. // `x <b>b</b>` -> `x _ <b>b</b>`
  765. // `x <b>b</b>` -> `x _ _<b>b</b>`
  766. // (3) But different, when that node starts by &nbsp; (or space that will be converted to &nbsp;):
  767. // `x <b>_b</b>` -> `x <b>_b</b>`
  768. // `x <b>_b</b>` -> `x_ <b>_b</b>`
  769. // `x <b>_b</b>` -> `x _ <b>_b</b>`
  770. // `x <b>_b</b>` -> `x_ _ <b>_b</b>`
  771. // Let's assume that starting from space is normal behavior, because starting from &nbsp; is a less frequent case.
  772. let textEndStartsFromNbsp = false;
  773. if ( !nextNode ) {
  774. // (1)
  775. if ( textEnd.length % 2 ) {
  776. textEndStartsFromNbsp = true;
  777. }
  778. } else if ( nextNode.data.charAt( 0 ) == ' ' || nextNode.data.charAt( 0 ) == '\u00A0' ) {
  779. // (3)
  780. if ( textEnd.length % 2 === 0 ) {
  781. textEndStartsFromNbsp = true;
  782. }
  783. }
  784. if ( textEndStartsFromNbsp ) {
  785. textEnd = '\u00A0' + textEnd.substr( 0, textEnd.length - 1 );
  786. }
  787. textEnd = textEnd.replace( / {2}/g, ' \u00A0' );
  788. }
  789. return textStart + textEnd;
  790. }
  791. /**
  792. * Helper function. For given {@link module:engine/view/text~Text view text node}, it finds previous or next sibling that is contained
  793. * in the same block element. If there is no such sibling, `null` is returned.
  794. *
  795. * @private
  796. * @param {module:engine/view/text~Text} node
  797. * @param {Boolean} getNext
  798. * @returns {module:engine/view/text~Text}
  799. */
  800. _getTouchingViewTextNode( node, getNext ) {
  801. if ( !node.parent ) {
  802. return null;
  803. }
  804. const treeWalker = new ViewTreeWalker( {
  805. startPosition: getNext ? ViewPosition.createAfter( node ) : ViewPosition.createBefore( node ),
  806. direction: getNext ? 'forward' : 'backward'
  807. } );
  808. for ( const value of treeWalker ) {
  809. if ( value.item.is( 'containerElement' ) ) {
  810. // ViewContainerElement is found on a way to next ViewText node, so given `node` was first/last
  811. // text node in it's container element.
  812. return null;
  813. } else if ( value.item.is( 'text' ) ) {
  814. // Found a text node in the same container element.
  815. return value.item;
  816. }
  817. }
  818. return null;
  819. }
  820. /**
  821. * Takes text data from native `Text` node and processes it to a correct {@link module:engine/view/text~Text view text node} data.
  822. *
  823. * Following changes are done:
  824. * * multiple whitespaces are replaced to a single space,
  825. * * space at the beginning of the text node is removed, if it is a first text node in it's container
  826. * element or if previous text node ends by space character,
  827. * * space at the end of the text node is removed, if it is a last text node in it's container.
  828. *
  829. * @param {Node} node DOM text node to process.
  830. * @returns {String} Processed data.
  831. * @private
  832. */
  833. _processDataFromDomText( node ) {
  834. let data = getDataWithoutFiller( node );
  835. if ( _hasDomParentOfType( node, this.preElements ) ) {
  836. return data;
  837. }
  838. // Change all consecutive whitespace characters (from the [ \n\t\r] set –
  839. // see https://github.com/ckeditor/ckeditor5-engine/issues/822#issuecomment-311670249) to a single space character.
  840. // That's how multiple whitespaces are treated when rendered, so we normalize those whitespaces.
  841. // We're replacing 1+ (and not 2+) to also normalize singular \n\t\r characters (#822).
  842. data = data.replace( /[ \n\t\r]{1,}/g, ' ' );
  843. const prevNode = this._getTouchingDomTextNode( node, false );
  844. const nextNode = this._getTouchingDomTextNode( node, true );
  845. // If previous dom text node does not exist or it ends by whitespace character, remove space character from the beginning
  846. // of this text node. Such space character is treated as a whitespace.
  847. if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
  848. data = data.replace( /^ /, '' );
  849. }
  850. // If next text node does not exist remove space character from the end of this text node.
  851. if ( !nextNode ) {
  852. data = data.replace( / $/, '' );
  853. }
  854. // At this point we should have removed all whitespaces from DOM text data.
  855. // Now we have to change &nbsp; chars, that were in DOM text data because of rendering reasons, to spaces.
  856. // First, change all ` \u00A0` pairs (space + &nbsp;) to two spaces. DOM converter changes two spaces from model/view as
  857. // ` \u00A0` to ensure proper rendering. Since here we convert back, we recognize those pairs and change them
  858. // to ` ` which is what we expect to have in model/view.
  859. data = data.replace( / \u00A0/g, ' ' );
  860. // Then, change &nbsp; character that is at the beginning of the text node to space character.
  861. // As above, that &nbsp; was created for rendering reasons but it's real meaning is just a space character.
  862. // We do that replacement only if this is the first node or the previous node ends on whitespace character.
  863. if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
  864. data = data.replace( /^\u00A0/, ' ' );
  865. }
  866. // Since input text data could be: `x_ _`, we would not replace the first &nbsp; after `x` character.
  867. // We have to fix it. Since we already change all ` &nbsp;`, we will have something like this at the end of text data:
  868. // `x_ _ _` -> `x_ `. Find &nbsp; at the end of string (can be followed only by spaces).
  869. // We do that replacement only if this is the last node or the next node starts by &nbsp;.
  870. if ( !nextNode || nextNode.data.charAt( 0 ) == '\u00A0' ) {
  871. data = data.replace( /\u00A0( *)$/, ' $1' );
  872. }
  873. // At this point, all whitespaces should be removed and all &nbsp; created for rendering reasons should be
  874. // changed to normal space. All left &nbsp; are &nbsp; inserted intentionally.
  875. return data;
  876. }
  877. /**
  878. * Helper function. For given `Text` node, it finds previous or next sibling that is contained in the same block element.
  879. * If there is no such sibling, `null` is returned.
  880. *
  881. * @private
  882. * @param {Text} node
  883. * @param {Boolean} getNext
  884. * @returns {Text|null}
  885. */
  886. _getTouchingDomTextNode( node, getNext ) {
  887. if ( !node.parentNode ) {
  888. return null;
  889. }
  890. const direction = getNext ? 'nextNode' : 'previousNode';
  891. const document = node.ownerDocument;
  892. const treeWalker = document.createTreeWalker( document.childNodes[ 0 ], NodeFilter.SHOW_TEXT );
  893. treeWalker.currentNode = node;
  894. const touchingNode = treeWalker[ direction ]();
  895. if ( touchingNode !== null ) {
  896. const lca = getCommonAncestor( node, touchingNode );
  897. // If there is common ancestor between the text node and next/prev text node,
  898. // and there are no block elements on a way from the text node to that ancestor,
  899. // and there are no block elements on a way from next/prev text node to that ancestor...
  900. if (
  901. lca &&
  902. !_hasDomParentOfType( node, this.blockElements, lca ) &&
  903. !_hasDomParentOfType( touchingNode, this.blockElements, lca )
  904. ) {
  905. // Then they are in the same container element.
  906. return touchingNode;
  907. }
  908. }
  909. return null;
  910. }
  911. }
  912. // Helper function.
  913. // Used to check if given native `Element` or `Text` node has parent with tag name from `types` array.
  914. //
  915. // @param {Node} node
  916. // @param {Array.<String>} types
  917. // @param {Boolean} [boundaryParent] Can be given if parents should be checked up to a given element (excluding that element).
  918. // @returns {Boolean} `true` if such parent exists or `false` if it does not.
  919. function _hasDomParentOfType( node, types, boundaryParent ) {
  920. let parents = getAncestors( node );
  921. if ( boundaryParent ) {
  922. parents = parents.slice( parents.indexOf( boundaryParent ) + 1 );
  923. }
  924. return parents.some( parent => parent.tagName && types.includes( parent.tagName.toLowerCase() ) );
  925. }
  926. // A helper that executes given callback for each DOM node's ancestor, starting from the given node
  927. // and ending in document#documentElement.
  928. //
  929. // @param {Node} node
  930. // @param {Function} callback A callback to be executed for each ancestor.
  931. function forEachDomNodeAncestor( node, callback ) {
  932. while ( node && node != global.document ) {
  933. callback( node );
  934. node = node.parentNode;
  935. }
  936. }