writer.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. /**
  2. * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. import Position from './position.js';
  7. import ContainerElement from './containerelement.js';
  8. import AttributeElement from './attributeelement.js';
  9. import Text from './text.js';
  10. import Range from './range.js';
  11. import CKEditorError from '../../utils/ckeditorerror.js';
  12. import DocumentFragment from './documentfragment.js';
  13. /**
  14. * Tree View Writer class.
  15. * Writer defines a high-level API for TreeView manipulations.
  16. *
  17. * @memberOf engine.treeView
  18. */
  19. export default class Writer {
  20. /**
  21. * Returns first parent container of specified {@link engine.treeView.Position Position}.
  22. * Position's parent node is checked as first, then next parents are checked.
  23. *
  24. * @param {engine.treeView.Position} position Position used as a start point to locate parent container.
  25. * @returns {engine.treeView.Element|undefined} Parent container element or `undefined` if container is not found.
  26. */
  27. getParentContainer( position ) {
  28. let parent = position.parent;
  29. while ( !( parent instanceof ContainerElement ) ) {
  30. if ( !parent ) {
  31. return undefined;
  32. }
  33. parent = parent.parent;
  34. }
  35. return parent;
  36. }
  37. /**
  38. * Breaks attribute nodes at provided position. It breaks `attribute` nodes inside `container` node.
  39. *
  40. * In following examples `<p>` is a container, `<b>` and `<u>` are attribute nodes:
  41. *
  42. * <p>{foo}<b><u>{bar}|</u></b></p> -> <p>{foo}<b><u>{bar}</u></b>|</p>
  43. * <p>{foo}<b><u>|{bar}</u></b></p> -> <p>{foo}|<b><u>{bar}</u></b></p>
  44. * <p>{foo}<b><u>{b|ar}</u></b></p> -> <p>{foo}<b><u>{b}</u></b>|<b><u>{ar}</u></b></p>
  45. *
  46. * @see engine.treeView.Writer#isContainer
  47. * @see engine.treeView.Writer#isAttribute
  48. *
  49. * @param {engine.treeView.Position} position Position where to break attributes.
  50. * @returns {engine.treeView.Position} New position after breaking the attributes.
  51. */
  52. breakAttributes( position ) {
  53. const positionOffset = position.offset;
  54. const positionParent = position.parent;
  55. // Position's parent is container, so no attributes to break.
  56. if ( positionParent instanceof ContainerElement ) {
  57. return Position.createFromPosition( position );
  58. }
  59. const parentIsText = positionParent instanceof Text;
  60. const length = parentIsText ? positionParent.data.length : positionParent.getChildCount();
  61. // <p>foo<b><u>bar|</u></b></p>
  62. // <p>foo<b><u>bar</u>|</b></p>
  63. // <p>foo<b><u>bar</u></b>|</p>
  64. if ( positionOffset == length ) {
  65. const newPosition = new Position( positionParent.parent, positionParent.getIndex() + 1 );
  66. return this.breakAttributes( newPosition );
  67. } else
  68. // <p>foo<b><u>|bar</u></b></p>
  69. // <p>foo<b>|<u>bar</u></b></p>
  70. // <p>foo|<b><u>bar</u></b></p>
  71. if ( positionOffset === 0 ) {
  72. const newPosition = new Position( positionParent.parent, positionParent.getIndex() );
  73. return this.breakAttributes( newPosition );
  74. }
  75. // <p>foo<b><u>"b|ar"</u></b></p>
  76. // <p>foo<b><u>"b"|"ar"</u></b></p>
  77. // <p>foo<b><u>b</u>|<u>ar</u></b></p>
  78. // <p>foo<b><u>b</u></b>|<b><u>ar</u></b></p>
  79. else {
  80. const offsetAfter = positionParent.getIndex() + 1;
  81. if ( parentIsText ) {
  82. // Break text.
  83. // Get part of the text that need to be moved.
  84. const textToMove = positionParent.data.slice( positionOffset );
  85. // Leave rest of the text in position's parent.
  86. positionParent.data = positionParent.data.slice( 0, positionOffset );
  87. // Insert new text node after position's parent text node.
  88. positionParent.parent.insertChildren( offsetAfter, new Text( textToMove ) );
  89. // Create new position to work on.
  90. const newPosition = new Position( positionParent.parent, offsetAfter );
  91. return this.breakAttributes( newPosition );
  92. } else {
  93. // Break element.
  94. const clonedNode = positionParent.clone();
  95. // Insert cloned node to position's parent node.
  96. positionParent.parent.insertChildren( offsetAfter, clonedNode );
  97. // Get nodes to move.
  98. const count = positionParent.getChildCount() - positionOffset;
  99. const nodesToMove = positionParent.removeChildren( positionOffset, count );
  100. // Move nodes to cloned node.
  101. clonedNode.appendChildren( nodesToMove );
  102. // Create new position to work on.
  103. const newPosition = new Position( positionParent.parent, offsetAfter );
  104. return this.breakAttributes( newPosition );
  105. }
  106. }
  107. }
  108. /**
  109. * Uses {@link engine.treeView.Writer#breakAttributes breakAttribute} method to break attributes on
  110. * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions of
  111. * provided {@link engine.treeView.Range Range}.
  112. *
  113. * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
  114. * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions are not placed inside
  115. * same parent container.
  116. *
  117. * @see engine.treeView.Writer#breakAttribute
  118. * @param {engine.treeView.Range} range Range which `start` and `end` positions will be used to break attributes.
  119. * @returns {engine.treeView.Range} New range with located at break positions.
  120. */
  121. breakRange( range ) {
  122. const rangeStart = range.start;
  123. const rangeEnd = range.end;
  124. // Range should be placed inside one container.
  125. if ( this.getParentContainer( rangeStart ) !== this.getParentContainer( rangeEnd ) ) {
  126. /**
  127. * Range is not placed inside same container.
  128. *
  129. * @error treeview-writer-invalid-range-container
  130. */
  131. throw new CKEditorError( 'treeview-writer-invalid-range-container' );
  132. }
  133. // Break at the collapsed position. Return new collapsed range.
  134. if ( range.isCollapsed ) {
  135. const position = this.breakAttributes( range.start );
  136. return new Range( position, position );
  137. }
  138. const breakEnd = this.breakAttributes( rangeEnd );
  139. const count = breakEnd.parent.getChildCount();
  140. const breakStart = this.breakAttributes( rangeStart );
  141. // Calculate new break end offset.
  142. breakEnd.offset += breakEnd.parent.getChildCount() - count;
  143. return new Range( breakStart, breakEnd );
  144. }
  145. /**
  146. * Merges attribute nodes. It also merges text nodes if needed.
  147. * Only {@link engine.treeView.Element#isSimilar similar} `attribute` nodes, with same priority can be merged.
  148. *
  149. * In following examples `<p>` is a container and `<b>` is an attribute node:
  150. *
  151. * <p>{foo}|{bar}</p> -> <p>{foo|bar}</p>
  152. * <p><b>{foo}</b>|<b>{bar}</b> -> <p><b>{foo|bar}</b></b>
  153. * <p><b foo="bar">{a}</b>|<b foo="baz">{b}</b> -> <p><b foo="bar">{a}</b>|<b foo="baz">{b}</b>
  154. *
  155. * @see engine.treeView.Writer#isContainer
  156. * @see engine.treeView.Writer#isAttribute
  157. * @param {engine.treeView.Position} position Merge position.
  158. * @returns {engine.treeView.Position} Position after merge.
  159. */
  160. mergeAttributes( position ) {
  161. const positionOffset = position.offset;
  162. const positionParent = position.parent;
  163. // When inside text node - nothing to merge.
  164. if ( positionParent instanceof Text ) {
  165. return position;
  166. }
  167. const nodeBefore = positionParent.getChild( positionOffset - 1 );
  168. const nodeAfter = positionParent.getChild( positionOffset );
  169. // Position should be placed between two nodes.
  170. if ( !nodeBefore || !nodeAfter ) {
  171. return position;
  172. }
  173. // When one or both nodes are containers - no attributes to merge.
  174. if ( ( nodeBefore instanceof ContainerElement ) || ( nodeAfter instanceof ContainerElement ) ) {
  175. return position;
  176. }
  177. // When selection is between two text nodes.
  178. if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
  179. // Merge text data into first text node and remove second one.
  180. const nodeBeforeLength = nodeBefore.data.length;
  181. nodeBefore.data += nodeAfter.data;
  182. positionParent.removeChildren( positionOffset );
  183. return new Position( nodeBefore, nodeBeforeLength );
  184. }
  185. // When selection is between same nodes.
  186. else if ( nodeBefore.isSimilar( nodeAfter ) ) {
  187. // Do not merge same nodes with different priorities.
  188. if ( !( nodeBefore instanceof AttributeElement ) || nodeBefore.priority !== nodeAfter.priority ) {
  189. return Position.createFromPosition( position );
  190. }
  191. // Move all children nodes from node placed after selection and remove that node.
  192. const count = nodeBefore.getChildCount();
  193. nodeBefore.appendChildren( nodeAfter.getChildren() );
  194. nodeAfter.remove();
  195. // New position is located inside the first node, before new nodes.
  196. // Call this method recursively to merge again if needed.
  197. return this.mergeAttributes( new Position( nodeBefore, count ) );
  198. }
  199. return position;
  200. }
  201. /**
  202. * Insert node or nodes at specified position. Takes care about breaking attributes before insertion
  203. * and merging them afterwards.
  204. *
  205. * @param {engine.treeView.Position} position Insertion position.
  206. * @param {engine.treeView.Node|Iterable.<engine.treeView.Node>} nodes Node or nodes to insert.
  207. * @returns {engine.treeView.Range} Range around inserted nodes.
  208. */
  209. insert( position, nodes ) {
  210. const container = this.getParentContainer( position );
  211. const insertionPosition = this.breakAttributes( position );
  212. const length = container.insertChildren( insertionPosition.offset, nodes );
  213. const endPosition = insertionPosition.getShiftedBy( length );
  214. const start = this.mergeAttributes( insertionPosition );
  215. // When no nodes were inserted - return collapsed range.
  216. if ( length === 0 ) {
  217. return new Range( start, start );
  218. } else {
  219. // If start position was merged - move end position.
  220. if ( !start.isEqual( insertionPosition ) ) {
  221. endPosition.offset--;
  222. }
  223. const end = this.mergeAttributes( endPosition );
  224. return new Range( start, end );
  225. }
  226. }
  227. /**
  228. * Removes provided range from the container.
  229. *
  230. * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
  231. * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions are not placed inside
  232. * same parent container.
  233. *
  234. * @param {engine.treeView.Range} range Range to remove from container. After removing, it will be updated
  235. * to a collapsed range showing the new position.
  236. * @returns {engine.treeView.DocumentFragment} Document fragment containing removed nodes.
  237. */
  238. remove( range ) {
  239. // Range should be placed inside one container.
  240. if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) {
  241. /**
  242. * Range is not placed inside same container.
  243. *
  244. * @error treeview-writer-invalid-range-container
  245. */
  246. throw new CKEditorError( 'treeview-writer-invalid-range-container' );
  247. }
  248. // If range is collapsed - nothing to remove.
  249. if ( range.isCollapsed ) {
  250. return new DocumentFragment();
  251. }
  252. // Break attributes at range start and end.
  253. const { start: breakStart, end: breakEnd } = this.breakRange( range );
  254. const parentContainer = breakStart.parent;
  255. const count = breakEnd.offset - breakStart.offset;
  256. // Remove nodes in range.
  257. const removed = parentContainer.removeChildren( breakStart.offset, count );
  258. // Merge after removing.
  259. const mergePosition = this.mergeAttributes( breakStart );
  260. range.start = mergePosition;
  261. range.end = Position.createFromPosition( mergePosition );
  262. // Return removed nodes.
  263. return new DocumentFragment( removed );
  264. }
  265. /**
  266. * Moves nodes from provided range to target position.
  267. *
  268. * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
  269. * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions are not placed inside
  270. * same parent container.
  271. *
  272. * @param {engine.treeView.Range} sourceRange Range containing nodes to move.
  273. * @param {engine.treeView.Position} targetPosition Position to insert.
  274. * @returns {engine.treeView.Range} Range in target container. Inserted nodes are placed between
  275. * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions.
  276. */
  277. move( sourceRange, targetPosition ) {
  278. const nodes = this.remove( sourceRange );
  279. return this.insert( targetPosition, nodes );
  280. }
  281. /**
  282. * Wraps elements within range with provided attribute element.
  283. *
  284. * Throws {@link utils.CKEditorError} `treeview-writer-invalid-range-container` when {@link engine.treeView.Range#start}
  285. * and {@link engine.treeView.Range#end} positions are not placed inside same parent container.
  286. *
  287. * @param {engine.treeView.Range} range Range to wrap.
  288. * @param {engine.treeView.AttributeElement} attribute Attribute element to use as wrapper.
  289. * @param {Number} priority Priority to set.
  290. */
  291. wrap( range, attribute, priority ) {
  292. if ( !( attribute instanceof AttributeElement ) ) {
  293. /**
  294. * Attribute element need to be instance of attribute element.
  295. *
  296. * @error treeview-writer-wrap-invalid-attribute
  297. */
  298. throw new CKEditorError( 'treeview-writer-wrap-invalid-attribute' );
  299. }
  300. // Range should be placed inside one container.
  301. if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) {
  302. /**
  303. * Range is not placed inside same container.
  304. *
  305. * @error treeview-writer-invalid-range-container
  306. */
  307. throw new CKEditorError( 'treeview-writer-invalid-range-container' );
  308. }
  309. // If range is collapsed - nothing to wrap.
  310. if ( range.isCollapsed ) {
  311. return range;
  312. }
  313. // Sets wrapper element priority.
  314. attribute.priority = priority;
  315. // Break attributes at range start and end.
  316. const { start: breakStart, end: breakEnd } = this.breakRange( range );
  317. const parentContainer = breakStart.parent;
  318. // Unwrap children located between break points.
  319. const unwrappedRange = unwrapChildren( this, parentContainer, breakStart.offset, breakEnd.offset, attribute );
  320. // Wrap all children with attribute.
  321. const newRange = wrapChildren( this, parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute );
  322. // Merge attributes at the both ends and return a new range.
  323. const start = this.mergeAttributes( newRange.start );
  324. // If start position was merged - move end position back.
  325. if ( !start.isEqual( newRange.start ) ) {
  326. newRange.end.offset--;
  327. }
  328. const end = this.mergeAttributes( newRange.end );
  329. return new Range( start, end );
  330. }
  331. /**
  332. * Unwraps nodes within provided range from attribute element.
  333. *
  334. * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
  335. * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions are not placed inside
  336. * same parent container.
  337. *
  338. * @param {engine.treeView.Range} range
  339. * @param {engine.treeView.AttributeElement} element
  340. */
  341. unwrap( range, attribute ) {
  342. if ( !( attribute instanceof AttributeElement ) ) {
  343. /**
  344. * Attribute element need to be instance of attribute element.
  345. *
  346. * @error treeview-writer-unwrap-invalid-attribute
  347. */
  348. throw new CKEditorError( 'treeview-writer-unwrap-invalid-attribute' );
  349. }
  350. // Range should be placed inside one container.
  351. if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) {
  352. /**
  353. * Range is not placed inside same container.
  354. *
  355. * @error treeview-writer-invalid-range-container
  356. */
  357. throw new CKEditorError( 'treeview-writer-invalid-range-container' );
  358. }
  359. // If range is collapsed - nothing to unwrap.
  360. if ( range.isCollapsed ) {
  361. return range;
  362. }
  363. // Break attributes at range start and end.
  364. const { start: breakStart, end: breakEnd } = this.breakRange( range );
  365. const parentContainer = breakStart.parent;
  366. // Unwrap children located between break points.
  367. const newRange = unwrapChildren( this, parentContainer, breakStart.offset, breakEnd.offset, attribute );
  368. // Merge attributes at the both ends and return a new range.
  369. const start = this.mergeAttributes( newRange.start );
  370. // If start position was merged - move end position back.
  371. if ( !start.isEqual( newRange.start ) ) {
  372. newRange.end.offset--;
  373. }
  374. const end = this.mergeAttributes( newRange.end );
  375. return new Range( start, end );
  376. }
  377. }
  378. // Unwraps children from provided `attribute`. Only children contained in `parent` element between
  379. // `startOffset` and `endOffset` will be unwrapped.
  380. //
  381. // @private
  382. // @param {engine.treeView.Writer} writer
  383. // @param {engine.treeView.Element} parent
  384. // @param {Number} startOffset
  385. // @param {Number} endOffset
  386. // @param {engine.treeView.Element} attribute
  387. function unwrapChildren( writer, parent, startOffset, endOffset, attribute ) {
  388. let i = startOffset;
  389. const unwrapPositions = [];
  390. // Iterate over each element between provided offsets inside parent.
  391. while ( i < endOffset ) {
  392. const child = parent.getChild( i );
  393. // If attributes are the similar and have same priority, then unwrap.
  394. if ( child.isSimilar( attribute ) && child.priority == attribute.priority ) {
  395. const unwrapped = child.getChildren();
  396. const count = child.getChildCount();
  397. // Replace wrapper element with its children
  398. child.remove();
  399. parent.insertChildren( i, unwrapped );
  400. // Save start and end position of moved items.
  401. unwrapPositions.push(
  402. new Position( parent, i ),
  403. new Position( parent, i + count )
  404. );
  405. // Skip elements that were unwrapped. Assuming that there won't be another element to unwrap in child
  406. // elements.
  407. i += count;
  408. endOffset += count - 1;
  409. } else {
  410. // If other nested attribute is found start unwrapping there.
  411. if ( child instanceof AttributeElement ) {
  412. unwrapChildren( writer, child, 0, child.getChildCount(), attribute );
  413. }
  414. i++;
  415. }
  416. }
  417. // Merge at each unwrap.
  418. let offsetChange = 0;
  419. for ( let position of unwrapPositions ) {
  420. position.offset -= offsetChange;
  421. // Do not merge with elements outside selected children.
  422. if ( position.offset == startOffset || position.offset == endOffset ) {
  423. continue;
  424. }
  425. const newPosition = writer.mergeAttributes( position );
  426. // If nodes were merged - other merge offsets will change.
  427. if ( !newPosition.isEqual( position ) ) {
  428. offsetChange++;
  429. endOffset--;
  430. }
  431. }
  432. return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
  433. }
  434. // Wraps children with provided `attribute`. Only children contained in `parent` element between
  435. // `startOffset` and `endOffset` will be wrapped.
  436. // @private
  437. // @param {engine.treeView.Writer} writer
  438. // @param {engine.treeView.Element} parent
  439. // @param {Number} startOffset
  440. // @param {Number} endOffset
  441. // @param {engine.treeView.Element} attribute
  442. function wrapChildren( writer, parent, startOffset, endOffset, attribute ) {
  443. let i = startOffset;
  444. const wrapPositions = [];
  445. while ( i < endOffset ) {
  446. const child = parent.getChild( i );
  447. const isText = child instanceof Text;
  448. const isAttribute = child instanceof AttributeElement;
  449. // Wrap text or attributes with higher or equal priority.
  450. if ( isText || ( isAttribute && attribute.priority <= child.priority ) ) {
  451. // Clone attribute.
  452. const newAttribute = attribute.clone();
  453. // Wrap current node with new attribute;
  454. child.remove();
  455. newAttribute.appendChildren( child );
  456. parent.insertChildren( i, newAttribute );
  457. wrapPositions.push( new Position( parent, i ) );
  458. } else {
  459. // If other nested attribute is found start wrapping there.
  460. if ( child instanceof AttributeElement ) {
  461. wrapChildren( writer, child, 0, child.getChildCount(), attribute );
  462. }
  463. }
  464. i++;
  465. }
  466. // Merge at each wrap.
  467. let offsetChange = 0;
  468. for ( let position of wrapPositions ) {
  469. // Do not merge with elements outside selected children.
  470. if ( position.offset == startOffset ) {
  471. continue;
  472. }
  473. const newPosition = writer.mergeAttributes( position );
  474. // If nodes were merged - other merge offsets will change.
  475. if ( !newPosition.isEqual( position ) ) {
  476. offsetChange++;
  477. endOffset--;
  478. }
  479. }
  480. return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
  481. }