/** * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module module:engine/view/writer */ import Position from './position'; import ContainerElement from './containerelement'; import AttributeElement from './attributeelement'; import EmptyElement from './emptyelement'; import UIElement from './uielement'; import Text from './text'; import Range from './range'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import DocumentFragment from './documentfragment'; import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable'; /** * Contains functions used for composing view tree. * * @namespace writer */ const writer = { breakAttributes, breakContainer, mergeAttributes, mergeContainers, insert, remove, clear, move, wrap, wrapPosition, unwrap, rename }; export default writer; /** * Breaks attribute nodes at provided position or at boundaries of provided range. It breaks attribute elements inside * up to a container element. * * In following examples `

` is a container, `` and `` are attribute nodes: * *

foobar{}

->

foobar[]

*

foo{}bar

->

foo{}bar

*

foob{}ar

->

foob[]ar

*

fo{oba}r

->

foobar

* * **Note:** {@link module:engine/view/documentfragment~DocumentFragment DocumentFragment} is treated like a container. * * **Note:** Difference between {@link module:engine/view/writer~writer.breakAttributes breakAttributes} and * {@link module:engine/view/writer~writer.breakContainer breakContainer} is that `breakAttributes` breaks all * {@link module:engine/view/attributeelement~AttributeElement attribute elements} that are ancestors of given `position`, up to the first * encountered {@link module:engine/view/containerelement~ContainerElement container element}. `breakContainer` assumes that given * `position` * is directly in container element and breaks that container element. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` * when {@link module:engine/view/range~Range#start start} * and {@link module:engine/view/range~Range#end end} positions of a passed range are not placed inside same parent container. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` * when trying to break attributes * inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-ui-element` * when trying to break attributes * inside {@link module:engine/view/uielement~UIElement UIElement}. * * @see module:engine/view/attributeelement~AttributeElement * @see module:engine/view/containerelement~ContainerElement * @see module:engine/view/writer~writer.breakContainer * @function module:engine/view/writer~writer.breakAttributes * @param {module:engine/view/position~Position|module:engine/view/range~Range} positionOrRange Position where to break attribute elements. * @returns {module:engine/view/position~Position|module:engine/view/range~Range} New position or range, after breaking the attribute * elements. */ export function breakAttributes( positionOrRange ) { if ( positionOrRange instanceof Position ) { return _breakAttributes( positionOrRange ); } else { return _breakAttributesRange( positionOrRange ); } } /** * Breaks {@link module:engine/view/containerelement~ContainerElement container view element} into two, at the given position. Position * has to be directly inside container element and cannot be in root. Does not break if position is at the beginning * or at the end of it's parent element. * *

foo^bar

->

foo

bar

*

foo

^

bar

->

foo

bar

*

^foobar

-> ^

foobar

*

foobar^

->

foobar

^ * * **Note:** Difference between {@link module:engine/view/writer~writer.breakAttributes breakAttributes} and * {@link module:engine/view/writer~writer.breakContainer breakContainer} is that `breakAttributes` breaks all * {@link module:engine/view/attributeelement~AttributeElement attribute elements} that are ancestors of given `position`, up to the first * encountered {@link module:engine/view/containerelement~ContainerElement container element}. `breakContainer` assumes that given * `position` * is directly in container element and breaks that container element. * * @see module:engine/view/attributeelement~AttributeElement * @see module:engine/view/containerelement~ContainerElement * @see module:engine/view/writer~writer.breakAttributes * @function module:engine/view/writer~writer.breakContainer * @param {module:engine/view/position~Position} position Position where to break element. * @returns {module:engine/view/position~Position} Position between broken elements. If element has not been broken, the returned position * is placed either before it or after it. */ export function breakContainer( position ) { const element = position.parent; if ( !( element.is( 'containerElement' ) ) ) { /** * Trying to break an element which is not a container element. * * @error view-writer-break-non-container-element */ throw new CKEditorError( 'view-writer-break-non-container-element: Trying to break an element which is not a container element.' ); } if ( !element.parent ) { /** * Trying to break root element. * * @error view-writer-break-root */ throw new CKEditorError( 'view-writer-break-root: Trying to break root element.' ); } if ( position.isAtStart ) { return Position.createBefore( element ); } else if ( !position.isAtEnd ) { const newElement = element.clone( false ); insert( Position.createAfter( element ), newElement ); const sourceRange = new Range( position, Position.createAt( element, 'end' ) ); const targetPosition = new Position( newElement, 0 ); move( sourceRange, targetPosition ); } return Position.createAfter( element ); } /** * Merges {@link module:engine/view/attributeelement~AttributeElement attribute elements}. It also merges text nodes if needed. * Only {@link module:engine/view/attributeelement~AttributeElement#isSimilar similar} attribute elements can be merged. * * In following examples `

` is a container and `` is an attribute element: * *

foo[]bar

->

foo{}bar

*

foo[]bar

->

foo{}bar

*

a[]b

->

a[]b

* * It will also take care about empty attributes when merging: * *

[]

->

[]

*

foo[]bar

->

foo{}bar

* * **Note:** Difference between {@link module:engine/view/writer~writer.mergeAttributes mergeAttributes} and * {@link module:engine/view/writer~writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two * {@link module:engine/view/attributeelement~AttributeElement attribute elements} or {@link module:engine/view/text~Text text nodes} * while `mergeContainer` merges two {@link module:engine/view/containerelement~ContainerElement container elements}. * * @see module:engine/view/attributeelement~AttributeElement * @see module:engine/view/containerelement~ContainerElement * @see module:engine/view/writer~writer.mergeContainers * @function module:engine/view/writer~writer.mergeAttributes * @param {module:engine/view/position~Position} position Merge position. * @returns {module:engine/view/position~Position} Position after merge. */ export function mergeAttributes( position ) { const positionOffset = position.offset; const positionParent = position.parent; // When inside text node - nothing to merge. if ( positionParent.is( 'text' ) ) { return position; } // When inside empty attribute - remove it. if ( positionParent.is( 'attributeElement' ) && positionParent.childCount === 0 ) { const parent = positionParent.parent; const offset = positionParent.index; positionParent.remove(); return mergeAttributes( new Position( parent, offset ) ); } const nodeBefore = positionParent.getChild( positionOffset - 1 ); const nodeAfter = positionParent.getChild( positionOffset ); // Position should be placed between two nodes. if ( !nodeBefore || !nodeAfter ) { return position; } // When position is between two text nodes. if ( nodeBefore.is( 'text' ) && nodeAfter.is( 'text' ) ) { return mergeTextNodes( nodeBefore, nodeAfter ); } // When selection is between two same attribute elements. else if ( nodeBefore.is( 'attributeElement' ) && nodeAfter.is( 'attributeElement' ) && nodeBefore.isSimilar( nodeAfter ) ) { // Move all children nodes from node placed after selection and remove that node. const count = nodeBefore.childCount; nodeBefore.appendChildren( nodeAfter.getChildren() ); nodeAfter.remove(); // New position is located inside the first node, before new nodes. // Call this method recursively to merge again if needed. return mergeAttributes( new Position( nodeBefore, count ) ); } return position; } /** * Merges two {@link module:engine/view/containerelement~ContainerElement container elements} that are before and after given position. * Precisely, the element after the position is removed and it's contents are moved to element before the position. * *

foo

^

bar

->

foo^bar

*
foo
^

bar

->
foo^bar
* * **Note:** Difference between {@link module:engine/view/writer~writer.mergeAttributes mergeAttributes} and * {@link module:engine/view/writer~writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two * {@link module:engine/view/attributeelement~AttributeElement attribute elements} or {@link module:engine/view/text~Text text nodes} * while `mergeContainer` merges two {@link module:engine/view/containerelement~ContainerElement container elements}. * * @see module:engine/view/attributeelement~AttributeElement * @see module:engine/view/containerelement~ContainerElement * @see module:engine/view/writer~writer.mergeAttributes * @function module:engine/view/writer~writer.mergeContainers * @param {module:engine/view/position~Position} position Merge position. * @returns {module:engine/view/position~Position} Position after merge. */ export function mergeContainers( position ) { const prev = position.nodeBefore; const next = position.nodeAfter; if ( !prev || !next || !prev.is( 'containerElement' ) || !next.is( 'containerElement' ) ) { /** * Element before and after given position cannot be merged. * * @error view-writer-merge-containers-invalid-position */ throw new CKEditorError( 'view-writer-merge-containers-invalid-position: ' + 'Element before and after given position cannot be merged.' ); } const lastChild = prev.getChild( prev.childCount - 1 ); const newPosition = lastChild instanceof Text ? Position.createAt( lastChild, 'end' ) : Position.createAt( prev, 'end' ); move( Range.createIn( next ), Position.createAt( prev, 'end' ) ); remove( Range.createOn( next ) ); return newPosition; } /** * Insert node or nodes at specified position. Takes care about breaking attributes before insertion * and merging them afterwards. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert * contains instances that are not {@link module:engine/view/text~Text Texts}, * {@link module:engine/view/attributeelement~AttributeElement AttributeElements}, * {@link module:engine/view/containerelement~ContainerElement ContainerElements}, * {@link module:engine/view/emptyelement~EmptyElement EmptyElements} or * {@link module:engine/view/uielement~UIElement UIElements}. * * @function insert * @param {module:engine/view/position~Position} position Insertion position. * @param {module:engine/view/text~Text|module:engine/view/attributeelement~AttributeElement| * module:engine/view/containerelement~ContainerElement|module:engine/view/emptyelement~EmptyElement| * module:engine/view/uielement~UIElement|Iterable.} nodes Node or nodes to insert. * @returns {module:engine/view/range~Range} Range around inserted nodes. */ export function insert( position, nodes ) { nodes = isIterable( nodes ) ? [ ...nodes ] : [ nodes ]; // Check if nodes to insert are instances of AttributeElements, ContainerElements, EmptyElements, UIElements or Text. validateNodesToInsert( nodes ); const container = getParentContainer( position ); if ( !container ) { /** * Position's parent container cannot be found. * * @error view-writer-invalid-position-container */ throw new CKEditorError( 'view-writer-invalid-position-container' ); } const insertionPosition = _breakAttributes( position, true ); const length = container.insertChildren( insertionPosition.offset, nodes ); let endPosition = insertionPosition.getShiftedBy( length ); const start = mergeAttributes( insertionPosition ); // When no nodes were inserted - return collapsed range. if ( length === 0 ) { return new Range( start, start ); } else { // If start position was merged - move end position. if ( !start.isEqual( insertionPosition ) ) { endPosition = endPosition.getShiftedBy( -1 ); } const end = mergeAttributes( endPosition ); return new Range( start, end ); } } /** * Removes provided range from the container. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions are not placed inside * same parent container. * * @function module:engine/view/writer~writer.remove * @param {module:engine/view/range~Range} range Range to remove from container. After removing, it will be updated * to a collapsed range showing the new position. * @returns {module:engine/view/documentfragment~DocumentFragment} Document fragment containing removed nodes. */ export function remove( range ) { validateRangeContainer( range ); // If range is collapsed - nothing to remove. if ( range.isCollapsed ) { return new DocumentFragment(); } // Break attributes at range start and end. const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true ); const parentContainer = breakStart.parent; const count = breakEnd.offset - breakStart.offset; // Remove nodes in range. const removed = parentContainer.removeChildren( breakStart.offset, count ); // Merge after removing. mergeAttributes( breakStart ); // Return removed nodes. return new DocumentFragment( removed ); } /** * Removes matching elements from given range. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions are not placed inside * same parent container. * * @function module:engine/view/writer~writer.clear * @param {module:engine/view/range~Range} range Range to clear. * @param {module:engine/view/element~Element} element Element to remove. */ export function clear( range, element ) { validateRangeContainer( range ); // Create walker on given range. // We walk backward because when we remove element during walk it modifies range end position. const walker = range.getWalker( { direction: 'backward', ignoreElementEnd: true } ); // Let's walk. for ( const current of walker ) { const item = current.item; let rangeToRemove; // When current item matches to the given element. if ( item.is( 'element' ) && element.isSimilar( item ) ) { // Create range on this element. rangeToRemove = Range.createOn( item ); // When range starts inside Text or TextProxy element. } else if ( !current.nextPosition.isAfter( range.start ) && ( item.is( 'text' ) || item.is( 'textProxy' ) ) ) { // We need to check if parent of this text matches to given element. const parentElement = item.getAncestors().find( ancestor => { return ancestor.is( 'element' ) && element.isSimilar( ancestor ); } ); // If it is then create range inside this element. if ( parentElement ) { rangeToRemove = Range.createIn( parentElement ); } } // If we have found element to remove. if ( rangeToRemove ) { let rangeEnd = rangeToRemove.end; let rangeStart = rangeToRemove.start; // We need to check if element range stick out of the given range and truncate if it is. if ( rangeToRemove.end.isAfter( range.end ) ) { rangeEnd = range.end; } if ( rangeToRemove.start.isBefore( range.start ) ) { rangeStart = range.start; } // At the end we remove range with found element. remove( new Range( rangeStart, rangeEnd ) ); } } } /** * Moves nodes from provided range to target position. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions are not placed inside * same parent container. * * @function module:engine/view/writer~writer.move * @param {module:engine/view/range~Range} sourceRange Range containing nodes to move. * @param {module:engine/view/position~Position} targetPosition Position to insert. * @returns {module:engine/view/range~Range} Range in target container. Inserted nodes are placed between * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions. */ export function move( sourceRange, targetPosition ) { let nodes; if ( targetPosition.isAfter( sourceRange.end ) ) { targetPosition = _breakAttributes( targetPosition, true ); const parent = targetPosition.parent; const countBefore = parent.childCount; sourceRange = _breakAttributesRange( sourceRange, true ); nodes = remove( sourceRange ); targetPosition = targetPosition.getShiftedBy( parent.childCount - countBefore ); } else { nodes = remove( sourceRange ); } return insert( targetPosition, nodes ); } /** * Wraps elements within range with provided {@link module:engine/view/attributeelement~AttributeElement AttributeElement}. * * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-invalid-range-container` * when {@link module:engine/view/range~Range#start} * and {@link module:engine/view/range~Range#end} positions are not placed inside same parent container. * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not * an instance of {module:engine/view/attributeelement~AttributeElement AttributeElement}. * * @function module:engine/view/writer~writer.wrap * @param {module:engine/view/range~Range} range Range to wrap. * @param {module:engine/view/attributeelement~AttributeElement} attribute Attribute element to use as wrapper. */ export function wrap( range, attribute ) { if ( !( attribute instanceof AttributeElement ) ) { throw new CKEditorError( 'view-writer-wrap-invalid-attribute' ); } validateRangeContainer( range ); // If range is collapsed - nothing to wrap. if ( range.isCollapsed ) { return range; } // Range around one element. if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) { const node = range.start.nodeAfter; if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) { return range; } } // Range is inside single attribute and spans on all children. if ( rangeSpansOnAllChildren( range ) && wrapAttributeElement( attribute, range.start.parent ) ) { const parent = range.start.parent.parent; const index = range.start.parent.index; return Range.createFromParentsAndOffsets( parent, index, parent, index + 1 ); } // Break attributes at range start and end. const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true ); const parentContainer = breakStart.parent; // Unwrap children located between break points. const unwrappedRange = unwrapChildren( parentContainer, breakStart.offset, breakEnd.offset, attribute ); // Wrap all children with attribute. const newRange = wrapChildren( parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute ); // Merge attributes at the both ends and return a new range. const start = mergeAttributes( newRange.start ); // If start position was merged - move end position back. let rangeEnd = newRange.end; if ( !start.isEqual( newRange.start ) ) { rangeEnd = rangeEnd.getShiftedBy( -1 ); } const end = mergeAttributes( rangeEnd ); return new Range( start, end ); } /** * Wraps position with provided attribute. Returns new position after wrapping. This method will also merge newly * added attribute with its siblings whenever possible. * * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not * an instance of {module:engine/view/attributeelement~AttributeElement AttributeElement}. * * @param {module:engine/view/position~Position} position * @param {module:engine/view/attributeelement~AttributeElement} attribute * @returns {module:engine/view/position~Position} New position after wrapping. */ export function wrapPosition( position, attribute ) { if ( !( attribute instanceof AttributeElement ) ) { throw new CKEditorError( 'view-writer-wrap-invalid-attribute' ); } // Return same position when trying to wrap with attribute similar to position parent. if ( attribute.isSimilar( position.parent ) ) { return movePositionToTextNode( position ); } // When position is inside text node - break it and place new position between two text nodes. if ( position.parent.is( 'text' ) ) { position = breakTextNode( position ); } // Create fake element that will represent position, and will not be merged with other attributes. const fakePosition = new AttributeElement(); fakePosition.priority = Number.POSITIVE_INFINITY; fakePosition.isSimilar = () => false; // Insert fake element in position location. position.parent.insertChildren( position.offset, fakePosition ); // Range around inserted fake attribute element. const wrapRange = new Range( position, position.getShiftedBy( 1 ) ); // Wrap fake element with attribute (it will also merge if possible). wrap( wrapRange, attribute ); // Remove fake element and place new position there. const newPosition = new Position( fakePosition.parent, fakePosition.index ); fakePosition.remove(); // If position is placed between text nodes - merge them and return position inside. const nodeBefore = newPosition.nodeBefore; const nodeAfter = newPosition.nodeAfter; if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) { return mergeTextNodes( nodeBefore, nodeAfter ); } // If position is next to text node - move position inside. return movePositionToTextNode( newPosition ); } /** * Unwraps nodes within provided range from attribute element. * * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions are not placed inside * same parent container. * * @param {module:engine/view/range~Range} range * @param {module:engine/view/attributeelement~AttributeElement} element */ export function unwrap( range, attribute ) { if ( !( attribute instanceof AttributeElement ) ) { /** * Attribute element need to be instance of attribute element. * * @error view-writer-unwrap-invalid-attribute */ throw new CKEditorError( 'view-writer-unwrap-invalid-attribute' ); } validateRangeContainer( range ); // If range is collapsed - nothing to unwrap. if ( range.isCollapsed ) { return range; } // Range around one element - check if AttributeElement can be unwrapped partially when it's not similar. // For example: // unwrap with:

result: if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) { const node = range.start.nodeAfter; // Unwrap single attribute element. if ( !attribute.isSimilar( node ) && node instanceof AttributeElement && unwrapAttributeElement( attribute, node ) ) { return range; } } // Break attributes at range start and end. const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true ); const parentContainer = breakStart.parent; // Unwrap children located between break points. const newRange = unwrapChildren( parentContainer, breakStart.offset, breakEnd.offset, attribute ); // Merge attributes at the both ends and return a new range. const start = mergeAttributes( newRange.start ); // If start position was merged - move end position back. let rangeEnd = newRange.end; if ( !start.isEqual( newRange.start ) ) { rangeEnd = rangeEnd.getShiftedBy( -1 ); } const end = mergeAttributes( rangeEnd ); return new Range( start, end ); } /** * Renames element by creating a copy of renamed element but with changed name and then moving contents of the * old element to the new one. Keep in mind that this will invalidate all {@link module:engine/view/position~Position positions} which * has renamed element as {@link module:engine/view/position~Position#parent a parent}. * * New element has to be created because `Element#tagName` property in DOM is readonly. * * Since this function creates a new element and removes the given one, the new element is returned to keep reference. * * @param {module:engine/view/containerelement~ContainerElement} viewElement Element to be renamed. * @param {String} newName New name for element. */ export function rename( viewElement, newName ) { const newElement = new ContainerElement( newName, viewElement.getAttributes() ); insert( Position.createAfter( viewElement ), newElement ); move( Range.createIn( viewElement ), Position.createAt( newElement ) ); remove( Range.createOn( viewElement ) ); return newElement; } /** * Attribute element need to be instance of attribute element. * * @error view-writer-wrap-invalid-attribute */ // Returns first parent container of specified {@link module:engine/view/position~Position Position}. // Position's parent node is checked as first, then next parents are checked. // Note that {@link module:engine/view/documentfragment~DocumentFragment DocumentFragment} is treated like a container. // // @param {module:engine/view/position~Position} position Position used as a start point to locate parent container. // @returns {module:engine/view/containerelement~ContainerElement|module:engine/view/documentfragment~DocumentFragment|undefined} // Parent container element or `undefined` if container is not found. function getParentContainer( position ) { let parent = position.parent; while ( !isContainerOrFragment( parent ) ) { if ( !parent ) { return undefined; } parent = parent.parent; } return parent; } // Function used by both public breakAttributes (without splitting text nodes) and by other methods (with // splitting text nodes). // // @param {module:engine/view/range~Range} range Range which `start` and `end` positions will be used to break attributes. // @param {Boolean} [forceSplitText = false] If set to `true`, will break text nodes even if they are directly in // container element. This behavior will result in incorrect view state, but is needed by other view writing methods // which then fixes view state. Defaults to `false`. // @returns {module:engine/view/range~Range} New range with located at break positions. function _breakAttributesRange( range, forceSplitText = false ) { const rangeStart = range.start; const rangeEnd = range.end; validateRangeContainer( range ); // Break at the collapsed position. Return new collapsed range. if ( range.isCollapsed ) { const position = _breakAttributes( range.start, forceSplitText ); return new Range( position, position ); } let breakEnd = _breakAttributes( rangeEnd, forceSplitText ); const count = breakEnd.parent.childCount; const breakStart = _breakAttributes( rangeStart, forceSplitText ); // Calculate new break end offset. breakEnd = breakEnd.getShiftedBy( breakEnd.parent.childCount - count ); return new Range( breakStart, breakEnd ); } // Function used by public breakAttributes (without splitting text nodes) and by other methods (with // splitting text nodes). // // Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` when break position // is placed inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}. // // Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-ui-element` when break position // is placed inside {@link module:engine/view/uielement~UIElement UIElement}. // // @param {module:engine/view/position~Position} position Position where to break attributes. // @param {Boolean} [forceSplitText = false] If set to `true`, will break text nodes even if they are directly in // container element. This behavior will result in incorrect view state, but is needed by other view writing methods // which then fixes view state. Defaults to `false`. // @returns {module:engine/view/position~Position} New position after breaking the attributes. function _breakAttributes( position, forceSplitText = false ) { const positionOffset = position.offset; const positionParent = position.parent; // If position is placed inside EmptyElement - throw an exception as we cannot break inside. if ( position.parent.is( 'emptyElement' ) ) { /** * Cannot break inside EmptyElement instance. * * @error view-writer-cannot-break-empty-element */ throw new CKEditorError( 'view-writer-cannot-break-empty-element' ); } // If position is placed inside UIElement - throw an exception as we cannot break inside. if ( position.parent.is( 'uiElement' ) ) { /** * Cannot break inside UIElement instance. * * @error view-writer-cannot-break-ui-element */ throw new CKEditorError( 'view-writer-cannot-break-ui-element' ); } // There are no attributes to break and text nodes breaking is not forced. if ( !forceSplitText && positionParent.is( 'text' ) && isContainerOrFragment( positionParent.parent ) ) { return position; } // Position's parent is container, so no attributes to break. if ( isContainerOrFragment( positionParent ) ) { return position; } // Break text and start again in new position. if ( positionParent.is( 'text' ) ) { return _breakAttributes( breakTextNode( position ), forceSplitText ); } const length = positionParent.childCount; //

foobar{}

//

foobar[]

//

foobar[]

if ( positionOffset == length ) { const newPosition = new Position( positionParent.parent, positionParent.index + 1 ); return _breakAttributes( newPosition, forceSplitText ); } else //

foo{}bar

//

foo[]bar

//

foo{}bar

if ( positionOffset === 0 ) { const newPosition = new Position( positionParent.parent, positionParent.index ); return _breakAttributes( newPosition, forceSplitText ); } //

foob{}ar

//

foob[]ar

//

foob[]ar

//

foob[]ar

else { const offsetAfter = positionParent.index + 1; // Break element. const clonedNode = positionParent.clone(); // Insert cloned node to position's parent node. positionParent.parent.insertChildren( offsetAfter, clonedNode ); // Get nodes to move. const count = positionParent.childCount - positionOffset; const nodesToMove = positionParent.removeChildren( positionOffset, count ); // Move nodes to cloned node. clonedNode.appendChildren( nodesToMove ); // Create new position to work on. const newPosition = new Position( positionParent.parent, offsetAfter ); return _breakAttributes( newPosition, forceSplitText ); } } // Unwraps children from provided `attribute`. Only children contained in `parent` element between // `startOffset` and `endOffset` will be unwrapped. // // @param {module:engine/view/element~Element} parent // @param {Number} startOffset // @param {Number} endOffset // @param {module:engine/view/element~Element} attribute function unwrapChildren( parent, startOffset, endOffset, attribute ) { let i = startOffset; const unwrapPositions = []; // Iterate over each element between provided offsets inside parent. while ( i < endOffset ) { const child = parent.getChild( i ); // If attributes are the similar, then unwrap. if ( child.isSimilar( attribute ) ) { const unwrapped = child.getChildren(); const count = child.childCount; // Replace wrapper element with its children child.remove(); parent.insertChildren( i, unwrapped ); // Save start and end position of moved items. unwrapPositions.push( new Position( parent, i ), new Position( parent, i + count ) ); // Skip elements that were unwrapped. Assuming that there won't be another element to unwrap in child // elements. i += count; endOffset += count - 1; } else { // If other nested attribute is found start unwrapping there. if ( child.is( 'attributeElement' ) ) { unwrapChildren( child, 0, child.childCount, attribute ); } i++; } } // Merge at each unwrap. let offsetChange = 0; for ( let position of unwrapPositions ) { position = position.getShiftedBy( -offsetChange ); // Do not merge with elements outside selected children. if ( position.offset == startOffset || position.offset == endOffset ) { continue; } const newPosition = mergeAttributes( position ); // If nodes were merged - other merge offsets will change. if ( !newPosition.isEqual( position ) ) { offsetChange++; endOffset--; } } return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset ); } // Wraps children with provided `attribute`. Only children contained in `parent` element between // `startOffset` and `endOffset` will be wrapped. // // @param {module:engine/view/element~Element} parent // @param {Number} startOffset // @param {Number} endOffset // @param {module:engine/view/element~Element} attribute function wrapChildren( parent, startOffset, endOffset, attribute ) { let i = startOffset; const wrapPositions = []; while ( i < endOffset ) { const child = parent.getChild( i ); const isText = child.is( 'text' ); const isAttribute = child.is( 'attributeElement' ); const isEmpty = child.is( 'emptyElement' ); const isUI = child.is( 'uiElement' ); // Wrap text, empty elements, ui elements or attributes with higher or equal priority. if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( attribute, child ) ) ) { // Clone attribute. const newAttribute = attribute.clone(); // Wrap current node with new attribute; child.remove(); newAttribute.appendChildren( child ); parent.insertChildren( i, newAttribute ); wrapPositions.push( new Position( parent, i ) ); } // If other nested attribute is found start wrapping there. else if ( isAttribute ) { wrapChildren( child, 0, child.childCount, attribute ); } i++; } // Merge at each wrap. let offsetChange = 0; for ( let position of wrapPositions ) { position = position.getShiftedBy( -offsetChange ); // Do not merge with elements outside selected children. if ( position.offset == startOffset ) { continue; } const newPosition = mergeAttributes( position ); // If nodes were merged - other merge offsets will change. if ( !newPosition.isEqual( position ) ) { offsetChange++; endOffset--; } } return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset ); } // Checks if first {@link module:engine/view/attributeelement~AttributeElement AttributeElement} provided to the function // can be wrapped otuside second element. It is done by comparing elements' // {@link module:engine/view/attributeelement~AttributeElement#priority priorities}, if both have same priority // {@link module:engine/view/element~Element#getIdentity identities} are compared. // // @param {module:engine/view/attributeelement~AttributeElement} a // @param {module:engine/view/attributeelement~AttributeElement} b // @returns {Boolean} function shouldABeOutsideB( a, b ) { if ( a.priority < b.priority ) { return true; } else if ( a.priority > b.priority ) { return false; } // When priorities are equal and names are different - use identities. return a.getIdentity() < b.getIdentity(); } // Returns new position that is moved to near text node. Returns same position if there is no text node before of after // specified position. // //

foo[]

->

foo{}

//

[]foo

->

{}foo

// // @param {module:engine/view/position~Position} position // @returns {module:engine/view/position~Position} Position located inside text node or same position if there is no text nodes // before or after position location. function movePositionToTextNode( position ) { const nodeBefore = position.nodeBefore; if ( nodeBefore && nodeBefore.is( 'text' ) ) { return new Position( nodeBefore, nodeBefore.data.length ); } const nodeAfter = position.nodeAfter; if ( nodeAfter && nodeAfter.is( 'text' ) ) { return new Position( nodeAfter, 0 ); } return position; } // Breaks text node into two text nodes when possible. // //

foo{}bar

->

foo[]bar

//

{}foobar

->

[]foobar

//

foobar{}

->

foobar[]

// // @param {module:engine/view/position~Position} position Position that need to be placed inside text node. // @returns {module:engine/view/position~Position} New position after breaking text node. function breakTextNode( position ) { if ( position.offset == position.parent.data.length ) { return new Position( position.parent.parent, position.parent.index + 1 ); } if ( position.offset === 0 ) { return new Position( position.parent.parent, position.parent.index ); } // Get part of the text that need to be moved. const textToMove = position.parent.data.slice( position.offset ); // Leave rest of the text in position's parent. position.parent.data = position.parent.data.slice( 0, position.offset ); // Insert new text node after position's parent text node. position.parent.parent.insertChildren( position.parent.index + 1, new Text( textToMove ) ); // Return new position between two newly created text nodes. return new Position( position.parent.parent, position.parent.index + 1 ); } // Merges two text nodes into first node. Removes second node and returns merge position. // // @param {module:engine/view/text~Text} t1 First text node to merge. Data from second text node will be moved at the end of // this text node. // @param {module:engine/view/text~Text} t2 Second text node to merge. This node will be removed after merging. // @returns {module:engine/view/position~Position} Position after merging text nodes. function mergeTextNodes( t1, t2 ) { // Merge text data into first text node and remove second one. const nodeBeforeLength = t1.data.length; t1.data += t2.data; t2.remove(); return new Position( t1, nodeBeforeLength ); } // Wraps one {@link module:engine/view/attributeelement~AttributeElement AttributeElement} into another by merging them if possible. // When merging is possible - all attributes, styles and classes are moved from wrapper element to element being // wrapped. // // @param {module:engine/view/attributeelement~AttributeElement} wrapper Wrapper AttributeElement. // @param {module:engine/view/attributeelement~AttributeElement} toWrap AttributeElement to wrap using wrapper element. // @returns {Boolean} Returns `true` if elements are merged. function wrapAttributeElement( wrapper, toWrap ) { // Can't merge if name or priority differs. if ( wrapper.name !== toWrap.name || wrapper.priority !== toWrap.priority ) { return false; } // Check if attributes can be merged. for ( const key of wrapper.getAttributeKeys() ) { // Classes and styles should be checked separately. if ( key === 'class' || key === 'style' ) { continue; } // If some attributes are different we cannot wrap. if ( toWrap.hasAttribute( key ) && toWrap.getAttribute( key ) !== wrapper.getAttribute( key ) ) { return false; } } // Check if styles can be merged. for ( const key of wrapper.getStyleNames() ) { if ( toWrap.hasStyle( key ) && toWrap.getStyle( key ) !== wrapper.getStyle( key ) ) { return false; } } // Move all attributes/classes/styles from wrapper to wrapped AttributeElement. for ( const key of wrapper.getAttributeKeys() ) { // Classes and styles should be checked separately. if ( key === 'class' || key === 'style' ) { continue; } // Move only these attributes that are not present - other are similar. if ( !toWrap.hasAttribute( key ) ) { toWrap.setAttribute( key, wrapper.getAttribute( key ) ); } } for ( const key of wrapper.getStyleNames() ) { if ( !toWrap.hasStyle( key ) ) { toWrap.setStyle( key, wrapper.getStyle( key ) ); } } for ( const key of wrapper.getClassNames() ) { if ( !toWrap.hasClass( key ) ) { toWrap.addClass( key ); } } return true; } // Unwraps {@link module:engine/view/attributeelement~AttributeElement AttributeElement} from another by removing corresponding attributes, // classes and styles. All attributes, classes and styles from wrapper should be present inside element being unwrapped. // // @param {module:engine/view/attributeelement~AttributeElement} wrapper Wrapper AttributeElement. // @param {module:engine/view/attributeelement~AttributeElement} toUnwrap AttributeElement to unwrap using wrapper element. // @returns {Boolean} Returns `true` if elements are unwrapped. function unwrapAttributeElement( wrapper, toUnwrap ) { // Can't unwrap if name or priority differs. if ( wrapper.name !== toUnwrap.name || wrapper.priority !== toUnwrap.priority ) { return false; } // Check if AttributeElement has all wrapper attributes. for ( const key of wrapper.getAttributeKeys() ) { // Classes and styles should be checked separately. if ( key === 'class' || key === 'style' ) { continue; } // If some attributes are missing or different we cannot unwrap. if ( !toUnwrap.hasAttribute( key ) || toUnwrap.getAttribute( key ) !== wrapper.getAttribute( key ) ) { return false; } } // Check if AttributeElement has all wrapper classes. if ( !toUnwrap.hasClass( ...wrapper.getClassNames() ) ) { return false; } // Check if AttributeElement has all wrapper styles. for ( const key of wrapper.getStyleNames() ) { // If some styles are missing or different we cannot unwrap. if ( !toUnwrap.hasStyle( key ) || toUnwrap.getStyle( key ) !== wrapper.getStyle( key ) ) { return false; } } // Remove all wrapper's attributes from unwrapped element. for ( const key of wrapper.getAttributeKeys() ) { // Classes and styles should be checked separately. if ( key === 'class' || key === 'style' ) { continue; } toUnwrap.removeAttribute( key ); } // Remove all wrapper's classes from unwrapped element. toUnwrap.removeClass( ...wrapper.getClassNames() ); // Remove all wrapper's styles from unwrapped element. toUnwrap.removeStyle( ...wrapper.getStyleNames() ); return true; } // Returns `true` if range is located in same {@link module:engine/view/attributeelement~AttributeElement AttributeElement} // (`start` and `end` positions are located inside same {@link module:engine/view/attributeelement~AttributeElement AttributeElement}), // starts on 0 offset and ends after last child node. // // @param {module:engine/view/range~Range} Range // @returns {Boolean} function rangeSpansOnAllChildren( range ) { return range.start.parent == range.end.parent && range.start.parent.is( 'attributeElement' ) && range.start.offset === 0 && range.end.offset === range.start.parent.childCount; } // Checks if provided nodes are valid to insert. Checks if each node is an instance of // {@link module:engine/view/text~Text Text} or {@link module:engine/view/attributeelement~AttributeElement AttributeElement}, // {@link module:engine/view/containerelement~ContainerElement ContainerElement}, // {@link module:engine/view/emptyelement~EmptyElement EmptyElement} or // {@link module:engine/view/uielement~UIElement UIElement}. // // Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert // contains instances that are not {@link module:engine/view/text~Text Texts}, // {@link module:engine/view/emptyelement~EmptyElement EmptyElements}, // {@link module:engine/view/uielement~UIElement UIElements}, // {@link module:engine/view/attributeelement~AttributeElement AttributeElements} or // {@link module:engine/view/containerelement~ContainerElement ContainerElements}. // // @param Iterable. nodes function validateNodesToInsert( nodes ) { for ( const node of nodes ) { if ( !validNodesToInsert.some( ( validNode => node instanceof validNode ) ) ) { // eslint-disable-line no-use-before-define /** * Inserted nodes should be valid to insert. of {@link module:engine/view/attributeelement~AttributeElement AttributeElement}, * {@link module:engine/view/containerelement~ContainerElement ContainerElement}, * {@link module:engine/view/emptyelement~EmptyElement EmptyElement}, * {@link module:engine/view/uielement~UIElement UIElement}, {@link module:engine/view/text~Text Text}. * * @error view-writer-insert-invalid-node */ throw new CKEditorError( 'view-writer-insert-invalid-node' ); } if ( !node.is( 'text' ) ) { validateNodesToInsert( node.getChildren() ); } } } const validNodesToInsert = [ Text, AttributeElement, ContainerElement, EmptyElement, UIElement ]; // Checks if node is ContainerElement or DocumentFragment, because in most cases they should be treated the same way. // // @param {module:engine/view/node~Node} node // @returns {Boolean} Returns `true` if node is instance of ContainerElement or DocumentFragment. function isContainerOrFragment( node ) { return node && ( node.is( 'containerElement' ) || node.is( 'documentFragment' ) ); } // Checks if {@link module:engine/view/range~Range#start range start} and {@link module:engine/view/range~Range#end range end} are placed // inside same {@link module:engine/view/containerelement~ContainerElement container element}. // Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when validation fails. // // @param {module:engine/view/range~Range} range function validateRangeContainer( range ) { const startContainer = getParentContainer( range.start ); const endContainer = getParentContainer( range.end ); if ( !startContainer || !endContainer || startContainer !== endContainer ) { /** * Range container is invalid. This can happen if {@link module:engine/view/range~Range#start range start} and * {@link module:engine/view/range~Range#end range end} positions are not placed inside same container or * parent container for these positions cannot be found. * * @error view-writer-invalid-range-container */ throw new CKEditorError( 'view-writer-invalid-range-container' ); } }