/** * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ 'use strict'; 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'; /** * Tree View Writer class. * Writer defines a high-level API for TreeView manipulations. * * @memberOf engine.treeView */ export default class Writer { /** * Returns first parent container of specified {@link engine.treeView.Position Position}. * Position's parent node is checked as first, then next parents are checked. * * @param {engine.treeView.Position} position Position used as a start point to locate parent container. * @returns {engine.treeView.Element|undefined} Parent container element or `undefined` if container is not found. */ getParentContainer( position ) { let parent = position.parent; while ( !( parent instanceof ContainerElement ) ) { if ( !parent ) { return undefined; } parent = parent.parent; } return parent; } /** * Breaks attribute nodes at provided position. It breaks `attribute` nodes inside `container` node. * * In following examples `
` is a container, `` and `` are attribute nodes: * *
foobar{}
->foobar[]
*foo{}bar
->foo{}bar
*foob{}ar
->foob[]ar
* * @see engine.treeView.AttributeElement * @see engine.treeView.ContainerElement * @param {engine.treeView.Position} position Position where to break attributes. * @returns {engine.treeView.Position} New position after breaking the attributes. */ breakAttributes( position ) { return this._breakAttributes( position, false ); } /** * Private method used by both public breakAttributes (without splitting text nodes) and by other methods (with * splitting text nodes). * * @private * @param {engine.treeView.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 `Writer` methods * which then fixes view state. Defaults to `false`. * @returns {engine.treeView.Position} New position after breaking the attributes. */ _breakAttributes( position, forceSplitText = false ) { const positionOffset = position.offset; const positionParent = position.parent; // There are no attributes to break and text nodes breaking is not forced. if ( !forceSplitText && positionParent instanceof Text && positionParent.parent instanceof ContainerElement ) { return Position.createFromPosition( position ); } // Position's parent is container, so no attributes to break. if ( positionParent instanceof ContainerElement ) { return Position.createFromPosition( position ); } // Break text and start again in new position. if ( positionParent instanceof Text ) { return this._breakAttributes( breakTextNode( position ), forceSplitText ); } const length = positionParent.getChildCount(); //foobar{}
//foobar[]
//foobar[]
if ( positionOffset == length ) { const newPosition = new Position( positionParent.parent, positionParent.getIndex() + 1 ); return this._breakAttributes( newPosition, forceSplitText ); } else //foo{}bar
//foo[]bar
//foo{}bar
if ( positionOffset === 0 ) { const newPosition = new Position( positionParent.parent, positionParent.getIndex() ); return this._breakAttributes( newPosition, forceSplitText ); } //foob{}ar
//foob[]ar
//foob[]ar
//foob[]ar
else { const offsetAfter = positionParent.getIndex() + 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.getChildCount() - 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 this._breakAttributes( newPosition, forceSplitText ); } } /** * Uses {@link engine.treeView.Writer#breakAttributes breakAttributes} method to break attributes on * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions of * provided {@link engine.treeView.Range Range}. * * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions are not placed inside * same parent container. * * @see engine.treeView.Writer#breakAttribute * @param {engine.treeView.Range} range Range which `start` and `end` positions will be used to break attributes. * @returns {engine.treeView.Range} New range with located at break positions. */ breakRange( range ) { return this._breakRange( range ); } /** * Private method used by both public breakRange (without splitting text nodes) and by other methods (with * splitting text nodes). * * @private * @see engine.treeView.Writer#_breakAttribute * @param {engine.treeView.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 `Writer` methods * which then fixes view state. Defaults to `false`. * @returns {engine.treeView.Range} New range with located at break positions. */ _breakRange( range, forceSplitText = false ) { const rangeStart = range.start; const rangeEnd = range.end; // Range should be placed inside one container. if ( this.getParentContainer( rangeStart ) !== this.getParentContainer( rangeEnd ) ) { /** * Range is not placed inside same container. * * @error treeview-writer-invalid-range-container */ throw new CKEditorError( 'treeview-writer-invalid-range-container' ); } // Break at the collapsed position. Return new collapsed range. if ( range.isCollapsed ) { const position = this._breakAttributes( range.start, forceSplitText ); return new Range( position, position ); } const breakEnd = this._breakAttributes( rangeEnd, forceSplitText ); const count = breakEnd.parent.getChildCount(); const breakStart = this._breakAttributes( rangeStart, forceSplitText ); // Calculate new break end offset. breakEnd.offset += breakEnd.parent.getChildCount() - count; return new Range( breakStart, breakEnd ); } /** * Merges attribute nodes. It also merges text nodes if needed. * Only {@link engine.treeView.AttributeElement#isSimilar similar} `attribute` nodes can be merged. * * In following examples `` is a container and `` is an attribute node: * *
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
* * @see engine.treeView.AttributeElement * @see engine.treeView.ContainerElement * @param {engine.treeView.Position} position Merge position. * @returns {engine.treeView.Position} Position after merge. */ 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.getChildCount() === 0 ) { const parent = positionParent.parent; const offset = positionParent.getIndex(); positionParent.remove(); return this.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.getChildCount(); 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 this.mergeAttributes( new Position( nodeBefore, count ) ); } return position; } /** * Insert node or nodes at specified position. Takes care about breaking attributes before insertion * and merging them afterwards. * * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-insert-invalid-node` when nodes to insert * contains instances that are not {@link engine.treeView.Text Texts}, * {@link engine.treeView.AttributeElement AttributeElements} or * {@link engine.treeView.ContainerElement ContainerElements}. * * @param {engine.treeView.Position} position Insertion position. * @param {engine.treeView.Text|engine.treeView.AttributeElement|engine.treeView.ContainerElement * |Iterable.foo[]
->foo{}
//[]foo
->{}foo
// // @private // @param {engine.treeView.Position} position // @returns {engine.treeView.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[]
// // @private // @param {engine.treeView.Position} position Position that need to be placed inside text node. // @returns {engine.treeView.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.getIndex() + 1 ); } if ( position.offset === 0 ) { return new Position( position.parent.parent, position.parent.getIndex() ); } // 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.getIndex() + 1, new Text( textToMove ) ); // Return new position between two newly created text nodes. return new Position( position.parent.parent, position.parent.getIndex() + 1 ); } // Merges two text nodes into first node. Removes second node and returns merge position. // // @private // @param {engine.treeView.Text} t1 First text node to merge. Data from second text node will be moved at the end of // this text node. // @param {engine.treeView.Text} t2 Second text node to merge. This node will be removed after merging. // @returns {engine.treeView.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.treeView.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. // // @private // @param {engine.treeView.AttributeElement} wrapper Wrapper AttributeElement. // @param {engine.treeView.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.treeView.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. // // @private // @param {engine.treeView.AttributeElement} wrapper Wrapper AttributeElement. // @param {engine.treeView.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.treeView.AttributeElement AttributeElement} // (`start` and `end` positions are located inside same {@link engine.treeView.AttributeElement AttributeElement}), // starts on 0 offset and ends after last child node. // // @private // @param {engine.treeView.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.getChildCount(); } // Checks if provided nodes are valid to insert by writer. Checks if each node is an instance of // {@link engine.treeView.Text Text} or {@link engine.treeView.AttributeElement AttributeElement} or // {@link engine.treeView.ContainerElement ContainerElement}. // // Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-insert-invalid-node` when nodes to insert // contains instances that are not {@link engine.treeView.Text Texts}, // {@link engine.treeView.AttributeElement AttributeElements} or // {@link engine.treeView.ContainerElement ContainerElements}. // // @private // @param Iterable.