/** * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module engine/model/utils/deletecontent */ import LivePosition from '../liveposition'; import Range from '../range'; import DocumentSelection from '../documentselection'; /** * Deletes content of the selection and merge siblings. The resulting selection is always collapsed. * * **Note:** Use {@link module:engine/model/model~Model#deleteContent} instead of this function. * This function is only exposed to be reusable in algorithms * which change the {@link module:engine/model/model~Model#deleteContent} * method's behavior. * * @param {module:engine/model/model~Model} model The model in context of which the insertion * should be performed. * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection * Selection of which the content should be deleted. * @param {module:engine/model/batch~Batch} batch Batch to which the operations will be added. * @param {Object} [options] * @param {Boolean} [options.leaveUnmerged=false] Whether to merge elements after removing the content of the selection. * * For example `x[xy]y` will become: * * * `x^y` with the option disabled (`leaveUnmerged == false`) * * `x^y` with enabled (`leaveUnmerged == true`). * * Note: {@link module:engine/model/schema~Schema#isObject object} and {@link module:engine/model/schema~Schema#isLimit limit} * elements will not be merged. * * @param {Boolean} [options.doNotResetEntireContent=false] Whether to skip replacing the entire content with a * paragraph when the entire content was selected. * * For example `[xy]` will become: * * * `^` with the option disabled (`doNotResetEntireContent == false`) * * `^` with enabled (`doNotResetEntireContent == true`). * * @param {Boolean} [options.doNotAutoparagraph=false] Whether to create a paragraph if after content deletion selection is moved * to a place where text cannot be inserted. * * For example `x[]` will become: * * * `x[]` with the option disabled (`doNotAutoparagraph == false`) * * `x[]` with the option enabled (`doNotAutoparagraph == true`). * * **Note:** if there is no valid position for the selection, the paragraph will always be created: * * `[]` -> `[]`. */ export default function deleteContent( model, selection, options = {} ) { if ( selection.isCollapsed ) { return; } const selRange = selection.getFirstRange(); // If the selection is already removed, don't do anything. if ( selRange.root.rootName == '$graveyard' ) { return; } const schema = model.schema; model.change( writer => { // 1. Replace the entire content with paragraph. // See: https://github.com/ckeditor/ckeditor5-engine/issues/1012#issuecomment-315017594. if ( !options.doNotResetEntireContent && shouldEntireContentBeReplacedWithParagraph( schema, selection ) ) { replaceEntireContentWithParagraph( writer, selection, schema ); return; } const startPos = selRange.start; const endPos = LivePosition.fromPosition( selRange.end, 'toNext' ); // 2. Remove the content if there is any. if ( !selRange.start.isTouching( selRange.end ) ) { writer.remove( selRange ); } // 3. Merge elements in the right branch to the elements in the left branch. // The only reasonable (in terms of data and selection correctness) case in which we need to do that is: // // Fo[]ar => Fo^ar // // However, the algorithm supports also merging deeper structures (up to the depth of the shallower branch), // as it's hard to imagine what should actually be the default behavior. Usually, specific features will // want to override that behavior anyway. if ( !options.leaveUnmerged ) { mergeBranches( writer, startPos, endPos ); // TMP this will be replaced with a postfixer. // We need to check and strip disallowed attributes in all nested nodes because after merge // some attributes could end up in a path where are disallowed. // // e.g. bold is disallowed for

//

Fo{o

b}ar

->

Fo{}ar

->

Fo{}ar

. schema.removeDisallowedAttributes( startPos.parent.getChildren(), writer ); } collapseSelectionAt( writer, selection, startPos ); // 4. Add a paragraph to set selection in it. // Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here). if ( shouldAutoparagraph( schema, startPos ) ) { // If auto-paragraphing is off, find the closest valid selection range and collapse the selection there. // If there is no valid selection range, create paragraph anyway and set selection there. const validSelectionRange = schema.getNearestSelectionRange( startPos ); if ( options.doNotAutoparagraph && validSelectionRange ) { collapseSelectionAt( writer, selection, validSelectionRange ); } else { insertParagraph( writer, startPos, selection ); } } endPos.detach(); } ); } // This function is a result of reaching the Ballmer's peak for just the right amount of time. // Even I had troubles documenting it after a while and after reading it again I couldn't believe that it really works. function mergeBranches( writer, startPos, endPos ) { const startParent = startPos.parent; const endParent = endPos.parent; // If both positions ended up in the same parent, then there's nothing more to merge: // <$root>

x[]

{}y

=> <$root>

xy

[]{} if ( startParent == endParent ) { return; } // 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. if ( writer.model.schema.isLimit( startParent ) || writer.model.schema.isLimit( endParent ) ) { return; } // Check if operations we'll need to do won't need to cross object or limit boundaries. // E.g., we can't merge endParent into startParent in this case: // x[]{} if ( !checkCanBeMerged( startPos, endPos, writer.model.schema ) ) { return; } // Remember next positions to merge. For example: // x[]{}y // will become: // xy[]{} startPos = writer.createPositionAfter( startParent ); endPos = writer.createPositionBefore( endParent ); if ( !endPos.isEqual( startPos ) ) { // In this case, before we merge, we need to move `endParent` to the `startPos`: // x[]{}y // becomes: // x[]y{} writer.insert( endParent, startPos ); } // Merge two siblings: // x[]y -> xy (the usual case) // x[]y -> xy[] (this is the "move parent" case shown above) writer.merge( startPos ); // Remove empty end ancestors: // fo[obar] // becomes: // fo[]{} // So we can remove and . while ( endPos.parent.isEmpty ) { const parentToRemove = endPos.parent; endPos = writer.createPositionBefore( parentToRemove ); writer.remove( parentToRemove ); } // Continue merging next level. mergeBranches( writer, startPos, endPos ); } function shouldAutoparagraph( schema, position ) { const isTextAllowed = schema.checkChild( position, '$text' ); const isParagraphAllowed = schema.checkChild( position, 'paragraph' ); return !isTextAllowed && isParagraphAllowed; } // Check if parents of two positions can be merged by checking if there are no limit/object // boundaries between those two positions. // // E.g. in

x[]

{} // we'll check

, , and . // Usually, widget and caption are marked as objects/limits in the schema, so in this case merging will be blocked. function checkCanBeMerged( leftPos, rightPos, schema ) { const rangeToCheck = new Range( leftPos, rightPos ); for ( const value of rangeToCheck.getWalker() ) { if ( schema.isLimit( value.item ) ) { return false; } } return true; } function insertParagraph( writer, position, selection ) { const paragraph = writer.createElement( 'paragraph' ); writer.insert( paragraph, position ); collapseSelectionAt( writer, selection, writer.createPositionAt( paragraph, 0 ) ); } function replaceEntireContentWithParagraph( writer, selection ) { const limitElement = writer.model.schema.getLimitElement( selection ); writer.remove( writer.createRangeIn( limitElement ) ); insertParagraph( writer, writer.createPositionAt( limitElement, 0 ), selection ); } // We want to replace the entire content with a paragraph when: // * the entire content is selected, // * selection contains at least two elements, // * whether the paragraph is allowed in schema in the common ancestor. function shouldEntireContentBeReplacedWithParagraph( schema, selection ) { const limitElement = schema.getLimitElement( selection ); if ( !selection.containsEntireContent( limitElement ) ) { return false; } const range = selection.getFirstRange(); if ( range.start.parent == range.end.parent ) { return false; } return schema.checkChild( limitElement, 'paragraph' ); } // Helper function that sets the selection. Depending whether given `selection` is a document selection or not, // uses a different method to set it. function collapseSelectionAt( writer, selection, positionOrRange ) { if ( selection instanceof DocumentSelection ) { writer.setSelection( positionOrRange ); } else { selection.setTo( positionOrRange ); } }