/** * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ 'use strict'; import Position from './position.js'; import Element from './element.js'; import Text from './text.js'; import Range from './range.js'; import CKEditorError from '../../utils/ckeditorerror.js'; import DocumentFragment from './documentfragment.js'; /** * Tree View Writer class. * Writer defines a high-level API for TreeView manipulations. * * @memberOf engine.treeView */ export default class Writer { constructor() { /** * Priorities map. Maps node to priority. * Nodes with priorities are considered as attributes. * * @member {WeakMap} engine.treeView.Writer#_priorities * @protected */ this._priorities = new WeakMap(); } /** * Returns `true` if provided node is a `container` node. * * `Container` nodes are mostly elements like `

` or `

`. Break and merge operations are performed only in a * bounds of a container nodes. Containers will not be broken or merged by * {@link engine.treeView.Writer#breakAttributes breakAttributes} and * {@link engine.treeView.Writer#mergeAttributes mergeAttributes}. * * `Attribute` nodes are mostly elements like `` or ``. Attributes can be broken and merged. Merging requires * that attribute nodes are {@link engine.treeView.Element#isSimilar similar} and have same priority. Setting different * priorities on similar nodes may prevent merging, eg. two `` nodes next ot each other shouldn't be merged. * There might be a need to mark `` element as a container node, for example in situation when it will be a * container of an inline widget: * * foobar // attribute * foobar // container * * @param {engine.treeView.Element} node Node to check. * @returns {Boolean} `True` if provided node is a container. */ isContainer( node ) { const isElement = node instanceof Element; return isElement && !this._priorities.has( node ); } /** * Returns `true` if provided node is an `attribute` node. * For more information about attribute and container nodes see {@link engine.treeView.Writer#isContainer isContainer} * method description. * * @see engine.treeView.Writer#isContainer * @param {engine.treeView.Element} node Node to check. * @returns {Boolean} `True` if provided node is an attribute. */ isAttribute( node ) { const isElement = node instanceof Element; return isElement && this._priorities.has( node ); } /** * Sets node priority. When priority is defined, node is considered as `attribute`. * * @see engine.treeView.Writer#isContainer * @param {engine.treeView.Node} node * @param {Number} priority */ setPriority( node, priority ) { this._priorities.set( node, priority ); } /** * Returns node's priority. * * @param {engine.treeView.Node} node Node to check its priority. * @returns {Number|undefined} Priority or `undefined` if there is no priority defined. */ getPriority( node ) { return this._priorities.get( node ); } /** * 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 ( !this.isContainer( parent ) ) { 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 ( this.isContainer( positionParent ) ) { return Position.createFromPosition( position ); } const parentIsText = positionParent instanceof Text; const length = parentIsText ? positionParent.data.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; if ( parentIsText ) { // Break text. // Get part of the text that need to be moved. const textToMove = positionParent.data.slice( positionOffset ); // Leave rest of the text in position's parent. positionParent.data = positionParent.data.slice( 0, positionOffset ); // Insert new text node after position's parent text node. positionParent.parent.insertChildren( offsetAfter, new Text( textToMove ) ); // Create new position to work on. const newPosition = new Position( positionParent.parent, offsetAfter ); return this.breakAttributes( newPosition ); } else { // Break element. const clonedNode = positionParent.clone(); // Clone priority. this.setPriority( clonedNode, this.getPriority( positionParent ) ); // 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.Element#isSimilar similar} `attribute` nodes, with same priority 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 ( this.isContainer( nodeBefore ) || this.isContainer( nodeAfter ) ) { return position; } if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) { // When selection is between two text nodes. // Merge text data into first text node and remove second one. const nodeBeforeLength = nodeBefore.data.length; nodeBefore.data += nodeAfter.data; positionParent.removeChildren( positionOffset ); return new Position( nodeBefore, nodeBeforeLength ); } else if ( nodeBefore.isSimilar( nodeAfter ) ) { // When selection is between same nodes. const nodeBeforePriority = this._priorities.get( nodeBefore ); const nodeAfterPriority = this._priorities.get( nodeAfter ); // Do not merge same nodes with different priorities. if ( nodeBeforePriority === undefined || nodeBeforePriority !== nodeAfterPriority ) { return Position.createFromPosition( position ); } // 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. * * @param {engine.treeView.Position} position Insertion position. * @param {engine.treeView.Node|Iterable.} nodes Node or nodes to insert. * @returns {engine.treeView.Range} Range around inserted nodes. */ insert( position, nodes ) { const container = this.getParentContainer( position ); const insertionPosition = this.breakAttributes( position ); const length = container.insertChildren( insertionPosition.offset, nodes ); const endPosition = insertionPosition.getShiftedBy( length ); const start = this.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.offset--; } const end = this.mergeAttributes( endPosition ); return new Range( start, end ); } } /** * Removes provided range from the container. * * 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. * * @param {engine.treeView.Range} range Range to remove from container. After removing, it will be updated * to a collapsed range showing the new position. * @returns {engine.treeView.DocumentFragment} Document fragment containing removed nodes. */ remove( range ) { // Range should be placed inside one container. if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) { /** * Range is not placed inside same container. * * @error treeview-writer-invalid-range-container */ throw new CKEditorError( 'treeview-writer-invalid-range-container' ); } // 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 } = this.breakRange( range ); const parentContainer = breakStart.parent; const count = breakEnd.offset - breakStart.offset; // Remove nodes in range. const removed = parentContainer.removeChildren( breakStart.offset, count ); // Merge after removing. const mergePosition = this.mergeAttributes( breakStart ); range.start = mergePosition; range.end = Position.createFromPosition( mergePosition ); // Return removed nodes. return new DocumentFragment( removed ); } /** * Moves nodes from provided range to target position. * * 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. * * @param {engine.treeView.Range} sourceRange Range containing nodes to move. * @param {engine.treeView.Position} targetPosition Position to insert. * @returns {engine.treeView.Range} Range in target container. Inserted nodes are placed between * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions. */ move( sourceRange, targetPosition ) { const nodes = this.remove( sourceRange ); return this.insert( targetPosition, nodes ); } /** * Wraps elements within range with provided attribute element. * * Throws {@link utils.CKEditorError} `treeview-writer-invalid-range-container` when {@link engine.treeView.Range#start} * and {@link engine.treeView.Range#end} positions are not placed inside same parent container. * * @param {engine.treeView.Range} range Range to wrap. * @param {engine.treeView.Element} attribute Attribute element to use as wrapper. * @param {Number} priority Priority to set. */ wrap( range, attribute, priority ) { // Range should be placed inside one container. if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) { /** * Range is not placed inside same container. * * @error treeview-writer-invalid-range-container */ throw new CKEditorError( 'treeview-writer-invalid-range-container' ); } // If range is collapsed - nothing to wrap. if ( range.isCollapsed ) { return range; } // Sets wrapper element priority. this.setPriority( attribute, priority ); // Break attributes at range start and end. const { start: breakStart, end: breakEnd } = this.breakRange( range ); const parentContainer = breakStart.parent; // Unwrap children located between break points. const unwrappedRange = unwrapChildren( this, parentContainer, breakStart.offset, breakEnd.offset, attribute ); // Wrap all children with attribute. const newRange = wrapChildren( this, parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute ); // Merge attributes at the both ends and return a new range. const start = this.mergeAttributes( newRange.start ); // If start position was merged - move end position back. if ( !start.isEqual( newRange.start ) ) { newRange.end.offset--; } const end = this.mergeAttributes( newRange.end ); return new Range( start, end ); } /** * Unwraps nodes within provided range from attribute element. * * 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. * * @param {engine.treeView.Range} range * @param {engine.treeView.Element} element */ unwrap( range, attribute ) { // Range should be placed inside one container. if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) { /** * Range is not placed inside same container. * * @error treeview-writer-invalid-range-container */ throw new CKEditorError( 'treeview-writer-invalid-range-container' ); } // If range is collapsed - nothing to unwrap. if ( range.isCollapsed ) { return range; } // Break attributes at range start and end. const { start: breakStart, end: breakEnd } = this.breakRange( range ); const parentContainer = breakStart.parent; // Unwrap children located between break points. const newRange = unwrapChildren( this, parentContainer, breakStart.offset, breakEnd.offset, attribute ); // Merge attributes at the both ends and return a new range. const start = this.mergeAttributes( newRange.start ); // If start position was merged - move end position back. if ( !start.isEqual( newRange.start ) ) { newRange.end.offset--; } const end = this.mergeAttributes( newRange.end ); return new Range( start, end ); } } // Unwraps children from provided `attribute`. Only children contained in `parent` element between // `startOffset` and `endOffset` will be unwrapped. // // @private // @param {engine.treeView.Writer} writer // @param {engine.treeView.Element} parent // @param {Number} startOffset // @param {Number} endOffset // @param {engine.treeView.Element} attribute function unwrapChildren( writer, 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 and have same priority, then unwrap. if ( child.isSimilar( attribute ) && writer.getPriority( child ) == writer.getPriority( attribute ) ) { const unwrapped = child.getChildren(); const count = child.getChildCount(); // 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 ( writer.isAttribute( child ) ) { unwrapChildren( writer, child, 0, child.getChildCount(), 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 = writer.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. // @private // @param {engine.treeView.Writer} writer // @param {engine.treeView.Element} parent // @param {Number} startOffset // @param {Number} endOffset // @param {engine.treeView.Element} attribute function wrapChildren( writer, parent, startOffset, endOffset, attribute ) { let i = startOffset; const wrapPositions = []; while ( i < endOffset ) { const child = parent.getChild( i ); const isText = child instanceof Text; const isAttribute = writer.isAttribute( child ); const priority = writer.getPriority( attribute ); // Wrap text or attributes with higher or equal priority. if ( isText || ( isAttribute && priority <= writer.getPriority( child ) ) ) { // Clone attribute. const newAttribute = attribute.clone(); writer.setPriority( newAttribute, priority ); // 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 ( writer.isAttribute( child ) ) { wrapChildren( writer, child, 0, child.getChildCount(), 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 = writer.mergeAttributes( position ); // If nodes were merged - other merge offsets will change. if ( !newPosition.isEqual( position ) ) { offsetChange++; endOffset--; } } return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset ); }