/** * @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: * *
{foo}{bar}|
->{foo}{bar}|
*{foo}|{bar}
->{foo}|{bar}
*{foo}{b|ar}
->{foo}{b}|{ar}
* * @see engine.treeView.Writer#isContainer * @see engine.treeView.Writer#isAttribute * * @param {engine.treeView.Position} position Position where to break attributes. * @returns {engine.treeView.Position} New position after breaking the attributes. */ breakAttributes( position ) { const positionOffset = position.offset; const positionParent = position.parent; // 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 ) ); } const length = positionParent.getChildCount(); //foobar|
//foobar|
//foobar|
if ( positionOffset == length ) { const newPosition = new Position( positionParent.parent, positionParent.getIndex() + 1 ); return this.breakAttributes( newPosition ); } else //foo|bar
//foo|bar
//foo|bar
if ( positionOffset === 0 ) { const newPosition = new Position( positionParent.parent, positionParent.getIndex() ); return this.breakAttributes( newPosition ); } //foo"b|ar"
//foo"b"|"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 ); } } /** * Uses {@link engine.treeView.Writer#breakAttributes breakAttribute} 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 ) { 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 ); return new Range( position, position ); } const breakEnd = this.breakAttributes( rangeEnd ); const count = breakEnd.parent.getChildCount(); const breakStart = this.breakAttributes( rangeStart ); // 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}
*
* @see engine.treeView.Writer#isContainer
* @see engine.treeView.Writer#isAttribute
* @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;
}
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.