deletecontent.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module engine/model/utils/deletecontent
  7. */
  8. import LivePosition from '../liveposition';
  9. import Range from '../range';
  10. import DocumentSelection from '../documentselection';
  11. /**
  12. * Deletes content of the selection and merge siblings. The resulting selection is always collapsed.
  13. *
  14. * **Note:** Use {@link module:engine/model/model~Model#deleteContent} instead of this function.
  15. * This function is only exposed to be reusable in algorithms
  16. * which change the {@link module:engine/model/model~Model#deleteContent}
  17. * method's behavior.
  18. *
  19. * @param {module:engine/model/model~Model} model The model in context of which the insertion
  20. * should be performed.
  21. * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
  22. * Selection of which the content should be deleted.
  23. * @param {Object} [options]
  24. * @param {Boolean} [options.leaveUnmerged=false] Whether to merge elements after removing the content of the selection.
  25. *
  26. * For example `<heading>x[x</heading><paragraph>y]y</paragraph>` will become:
  27. *
  28. * * `<heading>x^y</heading>` with the option disabled (`leaveUnmerged == false`)
  29. * * `<heading>x^</heading><paragraph>y</paragraph>` with enabled (`leaveUnmerged == true`).
  30. *
  31. * Note: {@link module:engine/model/schema~Schema#isObject object} and {@link module:engine/model/schema~Schema#isLimit limit}
  32. * elements will not be merged.
  33. *
  34. * @param {Boolean} [options.doNotResetEntireContent=false] Whether to skip replacing the entire content with a
  35. * paragraph when the entire content was selected.
  36. *
  37. * For example `<heading>[x</heading><paragraph>y]</paragraph>` will become:
  38. *
  39. * * `<paragraph>^</paragraph>` with the option disabled (`doNotResetEntireContent == false`)
  40. * * `<heading>^</heading>` with enabled (`doNotResetEntireContent == true`).
  41. *
  42. * @param {Boolean} [options.doNotAutoparagraph=false] Whether to create a paragraph if after content deletion selection is moved
  43. * to a place where text cannot be inserted.
  44. *
  45. * For example `<paragraph>x</paragraph>[<image src="foo.jpg"></image>]` will become:
  46. *
  47. * * `<paragraph>x</paragraph><paragraph>[]</paragraph>` with the option disabled (`doNotAutoparagraph == false`)
  48. * * `<paragraph>x</paragraph>[]` with the option enabled (`doNotAutoparagraph == true`).
  49. *
  50. * If you use this option you need to make sure to handle invalid selections yourself or leave
  51. * them to the selection post-fixer (may not always work).
  52. *
  53. * **Note:** if there is no valid position for the selection, the paragraph will always be created:
  54. *
  55. * `[<image src="foo.jpg"></image>]` -> `<paragraph>[]</paragraph>`.
  56. */
  57. export default function deleteContent( model, selection, options = {} ) {
  58. if ( selection.isCollapsed ) {
  59. return;
  60. }
  61. const selRange = selection.getFirstRange();
  62. // If the selection is already removed, don't do anything.
  63. if ( selRange.root.rootName == '$graveyard' ) {
  64. return;
  65. }
  66. const schema = model.schema;
  67. model.change( writer => {
  68. // 1. Replace the entire content with paragraph.
  69. // See: https://github.com/ckeditor/ckeditor5-engine/issues/1012#issuecomment-315017594.
  70. if ( !options.doNotResetEntireContent && shouldEntireContentBeReplacedWithParagraph( schema, selection ) ) {
  71. replaceEntireContentWithParagraph( writer, selection, schema );
  72. return;
  73. }
  74. // Get the live positions for the range adjusted to span only blocks selected from the user perspective.
  75. const [ startPosition, endPosition ] = getLivePositionsForSelectedBlocks( selRange );
  76. // 2. Remove the content if there is any.
  77. if ( !startPosition.isTouching( endPosition ) ) {
  78. writer.remove( writer.createRange( startPosition, endPosition ) );
  79. }
  80. // 3. Merge elements in the right branch to the elements in the left branch.
  81. // The only reasonable (in terms of data and selection correctness) case in which we need to do that is:
  82. //
  83. // <heading type=1>Fo[</heading><paragraph>]ar</paragraph> => <heading type=1>Fo^ar</heading>
  84. //
  85. // However, the algorithm supports also merging deeper structures (up to the depth of the shallower branch),
  86. // as it's hard to imagine what should actually be the default behavior. Usually, specific features will
  87. // want to override that behavior anyway.
  88. if ( !options.leaveUnmerged ) {
  89. mergeBranches( writer, startPosition, endPosition );
  90. // TMP this will be replaced with a postfixer.
  91. // We need to check and strip disallowed attributes in all nested nodes because after merge
  92. // some attributes could end up in a path where are disallowed.
  93. //
  94. // e.g. bold is disallowed for <H1>
  95. // <h1>Fo{o</h1><p>b}a<b>r</b><p> -> <h1>Fo{}a<b>r</b><h1> -> <h1>Fo{}ar<h1>.
  96. schema.removeDisallowedAttributes( startPosition.parent.getChildren(), writer );
  97. }
  98. collapseSelectionAt( writer, selection, startPosition );
  99. // 4. Add a paragraph to set selection in it.
  100. // Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here).
  101. // If autoparagraphing is off, we assume that you know what you do so we leave the selection wherever it was.
  102. if ( !options.doNotAutoparagraph && shouldAutoparagraph( schema, startPosition ) ) {
  103. insertParagraph( writer, startPosition, selection );
  104. }
  105. startPosition.detach();
  106. endPosition.detach();
  107. } );
  108. }
  109. // Returns the live positions for the range adjusted to span only blocks selected from the user perspective. Example:
  110. //
  111. // <heading1>[foo</heading1>
  112. // <paragraph>bar</paragraph>
  113. // <heading1>]abc</heading1> <-- this block is not considered as selected
  114. //
  115. // This is the same behavior as in Selection#getSelectedBlocks() "special case".
  116. function getLivePositionsForSelectedBlocks( range ) {
  117. const model = range.root.document.model;
  118. const startPosition = range.start;
  119. let endPosition = range.end;
  120. // If the end of selection is at the start position of last block in the selection, then
  121. // shrink it to not include that trailing block. Note that this should happen only for not empty selection.
  122. if ( model.hasContent( range, { ignoreMarkers: true } ) ) {
  123. const endBlock = getParentBlock( endPosition );
  124. if ( endBlock && endPosition.isTouching( model.createPositionAt( endBlock, 0 ) ) ) {
  125. // Create forward selection as a probe to find a valid position after excluding last block from the range.
  126. const selection = model.createSelection( range );
  127. // Modify the forward selection in backward direction to shrink it and remove first position of following block from it.
  128. // This is how modifySelection works and here we are making use of it.
  129. model.modifySelection( selection, { direction: 'backward' } );
  130. endPosition = selection.getLastPosition();
  131. }
  132. }
  133. return [
  134. LivePosition.fromPosition( startPosition, 'toPrevious' ),
  135. LivePosition.fromPosition( endPosition, 'toNext' )
  136. ];
  137. }
  138. // Finds the lowest element in position's ancestors which is a block.
  139. // Returns null if a limit element is encountered before reaching a block element.
  140. function getParentBlock( position ) {
  141. const element = position.parent;
  142. const schema = element.root.document.model.schema;
  143. const ancestors = element.getAncestors( { parentFirst: true, includeSelf: true } );
  144. for ( const element of ancestors ) {
  145. if ( schema.isLimit( element ) ) {
  146. return null;
  147. }
  148. if ( schema.isBlock( element ) ) {
  149. return element;
  150. }
  151. }
  152. }
  153. // This function is a result of reaching the Ballmer's peak for just the right amount of time.
  154. // Even I had troubles documenting it after a while and after reading it again I couldn't believe that it really works.
  155. function mergeBranches( writer, startPosition, endPosition ) {
  156. const model = writer.model;
  157. // Verify if there is a need and possibility to merge.
  158. if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
  159. return;
  160. }
  161. // If the start element on the common ancestor level is empty, and the end element on the same level is not empty
  162. // then merge those to the right element so that it's properties are preserved (name, attributes).
  163. // Because of OT merging is used instead of removing elements.
  164. //
  165. // Merge left:
  166. // <heading1>foo[</heading1> -> <heading1>foo[]bar</heading1>
  167. // <paragraph>]bar</paragraph> -> --^
  168. //
  169. // Merge right:
  170. // <heading1>[</heading1> ->
  171. // <paragraph>]bar</paragraph> -> <paragraph>[]bar</paragraph>
  172. //
  173. // Merge left:
  174. // <blockQuote> -> <blockQuote>
  175. // <heading1>foo[</heading1> -> <heading1>foo[]bar</heading1>
  176. // <paragraph>]bar</paragraph> -> --^
  177. // </blockQuote> -> </blockQuote>
  178. //
  179. // Merge right:
  180. // <blockQuote> -> <blockQuote>
  181. // <heading1>[</heading1> ->
  182. // <paragraph>]bar</paragraph> -> <paragraph>[]bar</paragraph>
  183. // </blockQuote> -> </blockQuote>
  184. // Merging should not go deeper than common ancestor.
  185. const [ startAncestor, endAncestor ] = getAncestorsJustBelowCommonAncestor( startPosition, endPosition );
  186. if ( !model.hasContent( startAncestor, { ignoreMarkers: true } ) && model.hasContent( endAncestor, { ignoreMarkers: true } ) ) {
  187. mergeBranchesRight( writer, startPosition, endPosition, startAncestor.parent );
  188. } else {
  189. mergeBranchesLeft( writer, startPosition, endPosition, startAncestor.parent );
  190. }
  191. }
  192. // Merging blocks to the left (properties of the left block are preserved).
  193. // Simple example:
  194. // <heading1>foo[</heading1> -> <heading1>foo[bar</heading1>]
  195. // <paragraph>]bar</paragraph> -> --^
  196. //
  197. // Nested example:
  198. // <blockQuote> -> <blockQuote>
  199. // <heading1>foo[</heading1> -> <heading1>foo[bar</heading1>
  200. // </blockQuote> -> </blockQuote>] ^
  201. // <blockBlock> -> |
  202. // <paragraph>]bar</paragraph> -> ---
  203. // </blockBlock> ->
  204. //
  205. function mergeBranchesLeft( writer, startPosition, endPosition, commonAncestor ) {
  206. const startElement = startPosition.parent;
  207. const endElement = endPosition.parent;
  208. // Merging reached the common ancestor element, stop here.
  209. if ( startElement == commonAncestor || endElement == commonAncestor ) {
  210. return;
  211. }
  212. // Remember next positions to merge in next recursive step (also used as modification points pointers).
  213. startPosition = writer.createPositionAfter( startElement );
  214. endPosition = writer.createPositionBefore( endElement );
  215. // Move endElement just after startElement if they aren't siblings.
  216. if ( !endPosition.isEqual( startPosition ) ) {
  217. //
  218. // <blockQuote> -> <blockQuote>
  219. // <heading1>foo[</heading1> -> <heading1>foo</heading1>[<paragraph>bar</paragraph>
  220. // </blockQuote> -> </blockQuote> ^
  221. // <blockBlock> -> <blockBlock> |
  222. // <paragraph>]bar</paragraph> -> ] ---
  223. // </blockBlock> -> </blockBlock>
  224. //
  225. writer.insert( endElement, startPosition );
  226. }
  227. // Merge two siblings (nodes on sides of startPosition):
  228. //
  229. // <blockQuote> -> <blockQuote>
  230. // <heading1>foo</heading1>[<paragraph>bar</paragraph> -> <heading1>foo[bar</heading1>
  231. // </blockQuote> -> </blockQuote>
  232. // <blockBlock> -> <blockBlock>
  233. // ] -> ]
  234. // </blockBlock> -> </blockBlock>
  235. //
  236. // Or in simple case (without moving elements in above if):
  237. // <heading1>foo</heading1>[<paragraph>bar</paragraph>] -> <heading1>foo[bar</heading1>]
  238. //
  239. writer.merge( startPosition );
  240. // Remove empty end ancestors:
  241. //
  242. // <blockQuote> -> <blockQuote>
  243. // <heading1>foo[bar</heading1> -> <heading1>foo[bar</heading1>
  244. // </blockQuote> -> </blockQuote>
  245. // <blockBlock> ->
  246. // ] -> ]
  247. // </blockBlock> ->
  248. //
  249. while ( endPosition.parent.isEmpty ) {
  250. const parentToRemove = endPosition.parent;
  251. endPosition = writer.createPositionBefore( parentToRemove );
  252. writer.remove( parentToRemove );
  253. }
  254. // Verify if there is a need and possibility to merge next level.
  255. if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
  256. return;
  257. }
  258. // Continue merging next level (blockQuote with blockBlock in the examples above if it would not be empty and got removed).
  259. mergeBranchesLeft( writer, startPosition, endPosition, commonAncestor );
  260. }
  261. // Merging blocks to the right (properties of the right block are preserved).
  262. // Simple example:
  263. // <heading1>foo[</heading1> -> --v
  264. // <paragraph>]bar</paragraph> -> [<paragraph>foo]bar</paragraph>
  265. //
  266. // Nested example:
  267. // <blockQuote> ->
  268. // <heading1>foo[</heading1> -> ---
  269. // </blockQuote> -> |
  270. // <blockBlock> -> [<blockBlock> v
  271. // <paragraph>]bar</paragraph> -> <paragraph>foo]bar</paragraph>
  272. // </blockBlock> -> </blockBlock>
  273. //
  274. function mergeBranchesRight( writer, startPosition, endPosition, commonAncestor ) {
  275. const startElement = startPosition.parent;
  276. const endElement = endPosition.parent;
  277. // Merging reached the common ancestor element, stop here.
  278. if ( startElement == commonAncestor || endElement == commonAncestor ) {
  279. return;
  280. }
  281. // Remember next positions to merge in next recursive step (also used as modification points pointers).
  282. startPosition = writer.createPositionAfter( startElement );
  283. endPosition = writer.createPositionBefore( endElement );
  284. // Move startElement just before endElement if they aren't siblings.
  285. if ( !endPosition.isEqual( startPosition ) ) {
  286. //
  287. // <blockQuote> -> <blockQuote>
  288. // <heading1>foo[</heading1> -> [ ---
  289. // </blockQuote> -> </blockQuote> |
  290. // <blockBlock> -> <blockBlock> v
  291. // <paragraph>]bar</paragraph> -> <heading1>foo</heading1>]<paragraph>bar</paragraph>
  292. // </blockBlock> -> </blockBlock>
  293. //
  294. writer.insert( startElement, endPosition );
  295. }
  296. // Remove empty end ancestors:
  297. //
  298. // <blockQuote> ->
  299. // [ -> [
  300. // </blockQuote> ->
  301. // <blockBlock> -> <blockBlock>
  302. // <heading1>foo</heading1>]<paragraph>bar</paragraph> -> <heading1>foo</heading1>]<paragraph>bar</paragraph>
  303. // </blockBlock> -> </blockBlock>
  304. //
  305. while ( startPosition.parent.isEmpty ) {
  306. const parentToRemove = startPosition.parent;
  307. startPosition = writer.createPositionBefore( parentToRemove );
  308. writer.remove( parentToRemove );
  309. }
  310. // Update endPosition after inserting and removing elements.
  311. endPosition = writer.createPositionBefore( endElement );
  312. // Merge right two siblings (nodes on sides of endPosition):
  313. // ->
  314. // [ -> [
  315. // ->
  316. // <blockBlock> -> <blockBlock>
  317. // <heading1>foo</heading1>]<paragraph>bar</paragraph> -> <paragraph>foo]bar</paragraph>
  318. // </blockBlock> -> </blockBlock>
  319. //
  320. // Or in simple case (without moving elements in above if):
  321. // [<heading1>foo</heading1>]<paragraph>bar</paragraph> -> [<heading1>foo]bar</heading1>
  322. //
  323. mergeRight( writer, endPosition );
  324. // Verify if there is a need and possibility to merge next level.
  325. if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
  326. return;
  327. }
  328. // Continue merging next level (blockQuote with blockBlock in the examples above if it would not be empty and got removed).
  329. mergeBranchesRight( writer, startPosition, endPosition, commonAncestor );
  330. }
  331. // There is no right merge operation so we need to simulate it.
  332. function mergeRight( writer, position ) {
  333. const startElement = position.nodeBefore;
  334. const endElement = position.nodeAfter;
  335. if ( startElement.name != endElement.name ) {
  336. writer.rename( startElement, endElement.name );
  337. }
  338. writer.clearAttributes( startElement );
  339. writer.setAttributes( Object.fromEntries( endElement.getAttributes() ), startElement );
  340. writer.merge( position );
  341. }
  342. // Verifies if merging is needed and possible. It's not needed if both positions are in the same element
  343. // and it's not possible if some element is a limit or the range crosses a limit element.
  344. function checkShouldMerge( schema, startPosition, endPosition ) {
  345. const startElement = startPosition.parent;
  346. const endElement = endPosition.parent;
  347. // If both positions ended up in the same parent, then there's nothing more to merge:
  348. // <$root><p>x[</p><p>]y</p></$root> => <$root><p>xy</p>[]</$root>
  349. if ( startElement == endElement ) {
  350. return false;
  351. }
  352. // If one of the positions is a limit element, then there's nothing to merge because we don't want to cross the limit boundaries.
  353. if ( schema.isLimit( startElement ) || schema.isLimit( endElement ) ) {
  354. return false;
  355. }
  356. // Check if operations we'll need to do won't need to cross object or limit boundaries.
  357. // E.g., we can't merge endElement into startElement in this case:
  358. // <limit><startElement>x[</startElement></limit><endElement>]</endElement>
  359. return isCrossingLimitElement( startPosition, endPosition, schema );
  360. }
  361. // Returns the elements that are the ancestors of the provided positions that are direct children of the common ancestor.
  362. function getAncestorsJustBelowCommonAncestor( positionA, positionB ) {
  363. const ancestorsA = positionA.getAncestors();
  364. const ancestorsB = positionB.getAncestors();
  365. let i = 0;
  366. while ( ancestorsA[ i ] && ancestorsA[ i ] == ancestorsB[ i ] ) {
  367. i++;
  368. }
  369. return [ ancestorsA[ i ], ancestorsB[ i ] ];
  370. }
  371. function shouldAutoparagraph( schema, position ) {
  372. const isTextAllowed = schema.checkChild( position, '$text' );
  373. const isParagraphAllowed = schema.checkChild( position, 'paragraph' );
  374. return !isTextAllowed && isParagraphAllowed;
  375. }
  376. // Check if parents of two positions can be merged by checking if there are no limit/object
  377. // boundaries between those two positions.
  378. //
  379. // E.g. in <bQ><p>x[]</p></bQ><widget><caption>{}</caption></widget>
  380. // we'll check <p>, <bQ>, <widget> and <caption>.
  381. // Usually, widget and caption are marked as objects/limits in the schema, so in this case merging will be blocked.
  382. function isCrossingLimitElement( leftPos, rightPos, schema ) {
  383. const rangeToCheck = new Range( leftPos, rightPos );
  384. for ( const value of rangeToCheck.getWalker() ) {
  385. if ( schema.isLimit( value.item ) ) {
  386. return false;
  387. }
  388. }
  389. return true;
  390. }
  391. function insertParagraph( writer, position, selection ) {
  392. const paragraph = writer.createElement( 'paragraph' );
  393. writer.insert( paragraph, position );
  394. collapseSelectionAt( writer, selection, writer.createPositionAt( paragraph, 0 ) );
  395. }
  396. function replaceEntireContentWithParagraph( writer, selection ) {
  397. const limitElement = writer.model.schema.getLimitElement( selection );
  398. writer.remove( writer.createRangeIn( limitElement ) );
  399. insertParagraph( writer, writer.createPositionAt( limitElement, 0 ), selection );
  400. }
  401. // We want to replace the entire content with a paragraph when:
  402. // * the entire content is selected,
  403. // * selection contains at least two elements,
  404. // * whether the paragraph is allowed in schema in the common ancestor.
  405. function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
  406. const limitElement = schema.getLimitElement( selection );
  407. if ( !selection.containsEntireContent( limitElement ) ) {
  408. return false;
  409. }
  410. const range = selection.getFirstRange();
  411. if ( range.start.parent == range.end.parent ) {
  412. return false;
  413. }
  414. return schema.checkChild( limitElement, 'paragraph' );
  415. }
  416. // Helper function that sets the selection. Depending whether given `selection` is a document selection or not,
  417. // uses a different method to set it.
  418. function collapseSelectionAt( writer, selection, positionOrRange ) {
  419. if ( selection instanceof DocumentSelection ) {
  420. writer.setSelection( positionOrRange );
  421. } else {
  422. selection.setTo( positionOrRange );
  423. }
  424. }