8
0

deletecontent.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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 ( !selRange.start.isTouching( selRange.end ) ) {
  78. writer.remove( selRange );
  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.
  110. //
  111. // Examples:
  112. // <heading1>[foo</heading1>
  113. // <paragraph>bar</paragraph>
  114. // <heading1>]abc</heading1> <-- this block is not considered as selected
  115. //
  116. // This is the same behavior as in Selection#getSelectedBlocks() "special case".
  117. function getLivePositionsForSelectedBlocks( range ) {
  118. const model = range.root.document.model;
  119. const startPosition = range.start;
  120. let endPosition = range.end;
  121. // If the end of selection is at the start position of last block in the selection, then
  122. // shrink it to not include that trailing block. Note that this should happen only for not empty selection.
  123. if ( model.hasContent( range ) ) {
  124. const endBlock = getParentBlock( endPosition );
  125. if ( endBlock && endPosition.isTouching( model.createPositionAt( endBlock, 0 ) ) ) {
  126. // Create forward selection as a probe to find a valid position after excluding last block from the range.
  127. const selection = model.createSelection( range );
  128. // Modify the forward selection in backward direction to shrink it and remove first position of following block from it.
  129. // This is how modifySelection works and here we are making use of it.
  130. model.modifySelection( selection, { direction: 'backward' } );
  131. endPosition = selection.getLastPosition();
  132. }
  133. }
  134. return [
  135. LivePosition.fromPosition( startPosition, 'toPrevious' ),
  136. LivePosition.fromPosition( endPosition, 'toNext' )
  137. ];
  138. }
  139. // Finds the lowest element in position's ancestors which is a block.
  140. // Returns null if a limit element is encountered before reaching a block element.
  141. function getParentBlock( position ) {
  142. const element = position.parent;
  143. const schema = element.root.document.model.schema;
  144. const ancestors = element.getAncestors( { parentFirst: true, includeSelf: true } );
  145. for ( const element of ancestors ) {
  146. if ( schema.isLimit( element ) ) {
  147. return null;
  148. }
  149. if ( schema.isBlock( element ) ) {
  150. return element;
  151. }
  152. }
  153. }
  154. // This function is a result of reaching the Ballmer's peak for just the right amount of time.
  155. // Even I had troubles documenting it after a while and after reading it again I couldn't believe that it really works.
  156. function mergeBranches( writer, startPosition, endPosition ) {
  157. const model = writer.model;
  158. // Verify if there is a need and possibility to merge.
  159. if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
  160. return;
  161. }
  162. // If the start element on the common ancestor level is empty, and the end element on the same level is not empty
  163. // then merge those to the right element so that it's properties are preserved (name, attributes).
  164. // Because of OT merging is used instead of removing elements.
  165. //
  166. // Merge left:
  167. // <heading1>foo[</heading1> -> <heading1>foo[]bar</heading1>
  168. // <paragraph>]bar</paragraph> -> --^
  169. //
  170. // Merge right:
  171. // <heading1>[</heading1> ->
  172. // <paragraph>]bar</paragraph> -> <paragraph>[]bar</paragraph>
  173. //
  174. // Merge left:
  175. // <blockQuote> -> <blockQuote>
  176. // <heading1>foo[</heading1> -> <heading1>foo[]bar</heading1>
  177. // <paragraph>]bar</paragraph> -> --^
  178. // </blockQuote> -> </blockQuote>
  179. //
  180. // Merge right:
  181. // <blockQuote> -> <blockQuote>
  182. // <heading1>[</heading1> ->
  183. // <paragraph>]bar</paragraph> -> <paragraph>[]bar</paragraph>
  184. // </blockQuote> -> </blockQuote>
  185. // Merging should not go deeper than common ancestor.
  186. const [ startAncestor, endAncestor ] = getAncestorsJustBelowCommonAncestor( startPosition, endPosition );
  187. if ( !model.hasContent( startAncestor ) && model.hasContent( endAncestor ) ) {
  188. mergeBranchesRight( writer, startPosition, endPosition, startAncestor.parent );
  189. } else {
  190. mergeBranchesLeft( writer, startPosition, endPosition, startAncestor.parent );
  191. }
  192. }
  193. // Merging blocks to the left (properties of the left block are preserved).
  194. // Simple example:
  195. // <heading1>foo[</heading1> -> <heading1>foo[bar</heading1>]
  196. // <paragraph>]bar</paragraph> -> --^
  197. //
  198. // Nested example:
  199. // <blockQuote> -> <blockQuote>
  200. // <heading1>foo[</heading1> -> <heading1>foo[bar</heading1>
  201. // </blockQuote> -> </blockQuote>] ^
  202. // <blockBlock> -> |
  203. // <paragraph>]bar</paragraph> -> ---
  204. // </blockBlock> ->
  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. // <blockQuote> -> <blockQuote>
  218. // <heading1>foo[</heading1> -> <heading1>foo</heading1>[<paragraph>bar</paragraph>
  219. // </blockQuote> -> </blockQuote> ^
  220. // <blockBlock> -> <blockBlock> |
  221. // <paragraph>]bar</paragraph> -> ] ---
  222. // </blockBlock> -> </blockBlock>
  223. writer.insert( endElement, startPosition );
  224. }
  225. // Merge two siblings (nodes on sides of startPosition):
  226. // <blockQuote> -> <blockQuote>
  227. // <heading1>foo</heading1>[<paragraph>bar</paragraph> -> <heading1>foo[bar</heading1>
  228. // </blockQuote> -> </blockQuote>
  229. // <blockBlock> -> <blockBlock>
  230. // ] -> ]
  231. // </blockBlock> -> </blockBlock>
  232. //
  233. // Or in simple case (without moving elements in above if):
  234. // <heading1>foo</heading1>[<paragraph>bar</paragraph>] -> <heading1>foo[bar</heading1>]
  235. //
  236. writer.merge( startPosition );
  237. // Remove empty end ancestors:
  238. // <blockQuote> -> <blockQuote>
  239. // <heading1>foo[bar</heading1> -> <heading1>foo[bar</heading1>
  240. // </blockQuote> -> </blockQuote>
  241. // <blockBlock> ->
  242. // ] -> ]
  243. // </blockBlock> ->
  244. while ( endPosition.parent.isEmpty ) {
  245. const parentToRemove = endPosition.parent;
  246. endPosition = writer.createPositionBefore( parentToRemove );
  247. writer.remove( parentToRemove );
  248. }
  249. // Verify if there is a need and possibility to merge next level.
  250. if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
  251. return;
  252. }
  253. // Continue merging next level (blockQuote with blockBlock in the examples above if it would not be empty and got removed).
  254. mergeBranchesLeft( writer, startPosition, endPosition, commonAncestor );
  255. }
  256. // Merging blocks to the right (properties of the right block are preserved).
  257. // Simple example:
  258. // <heading1>foo[</heading1> -> --v
  259. // <paragraph>]bar</paragraph> -> [<paragraph>foo]bar</paragraph>
  260. //
  261. // Nested example:
  262. // <blockQuote> ->
  263. // <heading1>foo[</heading1> -> ---
  264. // </blockQuote> -> |
  265. // <blockBlock> -> [<blockBlock> v
  266. // <paragraph>]bar</paragraph> -> <paragraph>foo]bar</paragraph>
  267. // </blockBlock> -> </blockBlock>
  268. function mergeBranchesRight( writer, startPosition, endPosition, commonAncestor ) {
  269. const startElement = startPosition.parent;
  270. const endElement = endPosition.parent;
  271. // Merging reached the common ancestor element, stop here.
  272. if ( startElement == commonAncestor || endElement == commonAncestor ) {
  273. return;
  274. }
  275. // Remember next positions to merge in next recursive step (also used as modification points pointers).
  276. startPosition = writer.createPositionAfter( startElement );
  277. endPosition = writer.createPositionBefore( endElement );
  278. // Move startElement just before endElement if they aren't siblings.
  279. if ( !endPosition.isEqual( startPosition ) ) {
  280. // <blockQuote> -> <blockQuote>
  281. // <heading1>foo[</heading1> -> [ ---
  282. // </blockQuote> -> </blockQuote> |
  283. // <blockBlock> -> <blockBlock> v
  284. // <paragraph>]bar</paragraph> -> <heading1>foo</heading1>]<paragraph>bar</paragraph>
  285. // </blockBlock> -> </blockBlock>
  286. writer.insert( startElement, endPosition );
  287. }
  288. // Remove empty end ancestors:
  289. // <blockQuote> ->
  290. // [ -> [
  291. // </blockQuote> ->
  292. // <blockBlock> -> <blockBlock>
  293. // <heading1>foo</heading1>]<paragraph>bar</paragraph> -> <heading1>foo</heading1>]<paragraph>bar</paragraph>
  294. // </blockBlock> -> </blockBlock>
  295. while ( startPosition.parent.isEmpty ) {
  296. const parentToRemove = startPosition.parent;
  297. startPosition = writer.createPositionBefore( parentToRemove );
  298. writer.remove( parentToRemove );
  299. }
  300. // Update endPosition after inserting and removing elements.
  301. endPosition = writer.createPositionBefore( endElement );
  302. // Merge right two siblings (nodes on sides of endPosition):
  303. // ->
  304. // [ -> [
  305. // ->
  306. // <blockBlock> -> <blockBlock>
  307. // <heading1>foo</heading1>]<paragraph>bar</paragraph> -> <paragraph>foo]bar</paragraph>
  308. // </blockBlock> -> </blockBlock>
  309. //
  310. // Or in simple case (without moving elements in above if):
  311. // [<heading1>foo</heading1>]<paragraph>bar</paragraph> -> [<heading1>foo]bar</heading1>
  312. //
  313. mergeRight( writer, endPosition );
  314. // Verify if there is a need and possibility to merge next level.
  315. if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
  316. return;
  317. }
  318. // Continue merging next level (blockQuote with blockBlock in the examples above if it would not be empty and got removed).
  319. mergeBranchesRight( writer, startPosition, endPosition, commonAncestor );
  320. }
  321. // There is no right merge operation so we need to simulate it.
  322. function mergeRight( writer, position ) {
  323. const startElement = position.nodeBefore;
  324. const endElement = position.nodeAfter;
  325. if ( startElement.name != endElement.name ) {
  326. writer.rename( startElement, endElement.name );
  327. }
  328. writer.clearAttributes( startElement );
  329. writer.setAttributes( Object.fromEntries( endElement.getAttributes() ), startElement );
  330. writer.merge( position );
  331. }
  332. // Verifies if merging is needed and possible. It's not needed if both positions are in the same element
  333. // and it's not possible if some element is a limit or the range crosses a limit element.
  334. function checkShouldMerge( schema, startPosition, endPosition ) {
  335. const startElement = startPosition.parent;
  336. const endElement = endPosition.parent;
  337. // If both positions ended up in the same parent, then there's nothing more to merge:
  338. // <$root><p>x[</p><p>]y</p></$root> => <$root><p>xy</p>[]</$root>
  339. if ( startElement == endElement ) {
  340. return false;
  341. }
  342. // 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.
  343. if ( schema.isLimit( startElement ) || schema.isLimit( endElement ) ) {
  344. return false;
  345. }
  346. // Check if operations we'll need to do won't need to cross object or limit boundaries.
  347. // E.g., we can't merge endElement into startElement in this case:
  348. // <limit><startElement>x[</startElement></limit><endElement>]</endElement>
  349. return isCrossingLimitElement( startPosition, endPosition, schema );
  350. }
  351. // Returns the elements that are the ancestors of the provided positions that are direct children of the common ancestor.
  352. function getAncestorsJustBelowCommonAncestor( positionA, positionB ) {
  353. const ancestorsA = positionA.getAncestors();
  354. const ancestorsB = positionB.getAncestors();
  355. let i = 0;
  356. while ( ancestorsA[ i ] && ancestorsA[ i ] == ancestorsB[ i ] ) {
  357. i++;
  358. }
  359. return [ ancestorsA[ i ], ancestorsB[ i ] ];
  360. }
  361. function shouldAutoparagraph( schema, position ) {
  362. const isTextAllowed = schema.checkChild( position, '$text' );
  363. const isParagraphAllowed = schema.checkChild( position, 'paragraph' );
  364. return !isTextAllowed && isParagraphAllowed;
  365. }
  366. // Check if parents of two positions can be merged by checking if there are no limit/object
  367. // boundaries between those two positions.
  368. //
  369. // E.g. in <bQ><p>x[]</p></bQ><widget><caption>{}</caption></widget>
  370. // we'll check <p>, <bQ>, <widget> and <caption>.
  371. // Usually, widget and caption are marked as objects/limits in the schema, so in this case merging will be blocked.
  372. function isCrossingLimitElement( leftPos, rightPos, schema ) {
  373. const rangeToCheck = new Range( leftPos, rightPos );
  374. for ( const value of rangeToCheck.getWalker() ) {
  375. if ( schema.isLimit( value.item ) ) {
  376. return false;
  377. }
  378. }
  379. return true;
  380. }
  381. function insertParagraph( writer, position, selection ) {
  382. const paragraph = writer.createElement( 'paragraph' );
  383. writer.insert( paragraph, position );
  384. collapseSelectionAt( writer, selection, writer.createPositionAt( paragraph, 0 ) );
  385. }
  386. function replaceEntireContentWithParagraph( writer, selection ) {
  387. const limitElement = writer.model.schema.getLimitElement( selection );
  388. writer.remove( writer.createRangeIn( limitElement ) );
  389. insertParagraph( writer, writer.createPositionAt( limitElement, 0 ), selection );
  390. }
  391. // We want to replace the entire content with a paragraph when:
  392. // * the entire content is selected,
  393. // * selection contains at least two elements,
  394. // * whether the paragraph is allowed in schema in the common ancestor.
  395. function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
  396. const limitElement = schema.getLimitElement( selection );
  397. if ( !selection.containsEntireContent( limitElement ) ) {
  398. return false;
  399. }
  400. const range = selection.getFirstRange();
  401. if ( range.start.parent == range.end.parent ) {
  402. return false;
  403. }
  404. return schema.checkChild( limitElement, 'paragraph' );
  405. }
  406. // Helper function that sets the selection. Depending whether given `selection` is a document selection or not,
  407. // uses a different method to set it.
  408. function collapseSelectionAt( writer, selection, positionOrRange ) {
  409. if ( selection instanceof DocumentSelection ) {
  410. writer.setSelection( positionOrRange );
  411. } else {
  412. selection.setTo( positionOrRange );
  413. }
  414. }