/** * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ import Position from './position.js'; import ContainerElement from './containerelement.js'; import AttributeElement from './attributeelement.js'; import Text from './text.js'; import Range from './range.js'; import CKEditorError from '../../utils/ckeditorerror.js'; import DocumentFragment from './documentfragment.js'; import isIterable from '../../utils/isiterable.js'; /** * Contains functions used for composing view tree. * * @namespace engine.view.writer */ export default { breakAttributes, breakContainer, mergeAttributes, mergeContainers, insert, remove, move, wrap, wrapPosition, unwrap, rename }; /** * 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 engine.view.DocumentFragment DocumentFragment} is treated like a container. * * **Note:** Difference between {@link engine.view.writer.breakAttributes breakAttributes} and * {@link engine.view.writer.breakContainer breakContainer} is that `breakAttributes` breaks all * {@link engine.view.AttributeElement attribute elements} that are ancestors of given `position`, up to the first * encountered {@link engine.view.ContainerElement container element}. `breakContainer` assumes that given `position` * is directly in container element and breaks that container element. * * Throws {@link utils.CKEditorError CKEditorError} `view-writer-invalid-range-container` when {@link engine.view.Range#start start} * and {@link engine.view.Range#end end} positions of a passed range are not placed inside same parent container. * * @see engine.view.AttributeElement * @see engine.view.ContainerElement * @see engine.view.writer.breakContainer * @function engine.view.writer.breakAttributes * @param {engine.view.Position|engine.view.Range} positionOrRange Position where to break attribute elements. * @returns {engine.view.Position|engine.view.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 engine.view.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 engine.view.writer.breakAttributes breakAttributes} and * {@link engine.view.writer.breakContainer breakContainer} is that `breakAttributes` breaks all * {@link engine.view.AttributeElement attribute elements} that are ancestors of given `position`, up to the first * encountered {@link engine.view.ContainerElement container element}. `breakContainer` assumes that given `position` * is directly in container element and breaks that container element. * * @see engine.view.AttributeElement * @see engine.view.ContainerElement * @see engine.view.writer.breakAttributes * @function engine.view.writer.breakContainer * @param {engine.view.Position} position Position where to break element. * @returns {engine.view.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 instanceof 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 engine.view.AttributeElement attribute elements}. It also merges text nodes if needed. * Only {@link engine.view.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 engine.view.writer.mergeAttributes mergeAttributes} and * {@link engine.view.writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two * {@link engine.view.AttributeElement attribute elements} or {@link engine.view.Text text nodes} * while `mergeContainer` merges two {@link engine.view.ContainerElement container elements}. * * @see engine.view.AttributeElement * @see engine.view.ContainerElement * @see engine.view.writer.mergeContainers * @function engine.view.writer.mergeAttributes * @param {engine.view.Position} position Merge position. * @returns {engine.view.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 instanceof Text ) { return position; } // When inside empty attribute - remove it. if ( positionParent instanceof 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 one or both nodes are containers - no attributes to merge. if ( ( nodeBefore instanceof ContainerElement ) || ( nodeAfter instanceof ContainerElement ) ) { return position; } // When position is between two text nodes. if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) { return mergeTextNodes( nodeBefore, nodeAfter ); } // When selection is between same nodes. else if ( 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 engine.view.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
*bar
->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 {engine.view.Element} parent // @param {Number} startOffset // @param {Number} endOffset // @param {engine.view.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 instanceof AttributeElement ) { unwrapChildren( child, 0, child.childCount, attribute ); } i++; } } // Merge at each unwrap. let offsetChange = 0; for ( let position of unwrapPositions ) { position.offset -= 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 {engine.view.Element} parent // @param {Number} startOffset // @param {Number} endOffset // @param {engine.view.Element} attribute function wrapChildren( parent, startOffset, endOffset, attribute ) { let i = startOffset; const wrapPositions = []; while ( i < endOffset ) { const child = parent.getChild( i ); const isText = child instanceof Text; const isAttribute = child instanceof AttributeElement; // Wrap text or attributes with higher or equal priority. if ( isText || ( isAttribute && attribute.priority <= child.priority ) ) { // 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 ) ); } else { // If other nested attribute is found start wrapping there. if ( child instanceof AttributeElement ) { wrapChildren( child, 0, child.childCount, attribute ); } } i++; } // Merge at each wrap. let offsetChange = 0; for ( let position of wrapPositions ) { // 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 ); } // 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 {engine.view.Position} position // @returns {engine.view.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 instanceof Text ) { return new Position( nodeBefore, nodeBefore.data.length ); } const nodeAfter = position.nodeAfter; if ( nodeAfter && nodeAfter instanceof 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 {engine.view.Position} position Position that need to be placed inside text node. // @returns {engine.view.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 {engine.view.Text} t1 First text node to merge. Data from second text node will be moved at the end of // this text node. // @param {engine.view.Text} t2 Second text node to merge. This node will be removed after merging. // @returns {engine.view.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 engine.view.AttributeElement AttributeElement} into another by merging them if possible. // Two AttributeElements can be merged when there is no attribute or style conflicts between them. // When merging is possible - all attributes, styles and classes are moved from wrapper element to element being // wrapped. // // @param {engine.view.AttributeElement} wrapper Wrapper AttributeElement. // @param {engine.view.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 ( let 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 ( let 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 ( let 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 ( let key of wrapper.getStyleNames() ) { if ( !toWrap.hasStyle( key ) ) { toWrap.setStyle( key, wrapper.getStyle( key ) ); } } for ( let key of wrapper.getClassNames() ) { if ( !toWrap.hasClass( key ) ) { toWrap.addClass( key ); } } return true; } // Unwraps {@link engine.view.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 {engine.view.AttributeElement} wrapper Wrapper AttributeElement. // @param {engine.view.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 ( let 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 ( let 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 ( let 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 engine.view.AttributeElement AttributeElement} // (`start` and `end` positions are located inside same {@link engine.view.AttributeElement AttributeElement}), // starts on 0 offset and ends after last child node. // // @param {engine.view.Range} Range // @returns {Boolean} function rangeSpansOnAllChildren( range ) { return range.start.parent == range.end.parent && range.start.parent instanceof 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 engine.view.Text Text} or {@link engine.view.AttributeElement AttributeElement} or // {@link engine.view.ContainerElement ContainerElement}. // // Throws {@link utils.CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert // contains instances that are not {@link engine.view.Text Texts}, // {@link engine.view.AttributeElement AttributeElements} or // {@link engine.view.ContainerElement ContainerElements}. // // @param Iterable.