| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635 |
- /**
- * @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';
- /**
- * Tree View Writer class.
- * Writer defines a high-level API for TreeView manipulations.
- *
- * @memberOf core.treeView
- */
- export default class Writer {
- constructor() {
- /**
- * Priorities map. Maps node to priority.
- * Nodes with priorities are considered as attributes.
- *
- * @member {WeakMap} core.treeView.Writer#_priorities
- * @protected
- */
- this._priorities = new WeakMap();
- }
- /**
- * Returns `true` if provided node is a `container` node.
- *
- * `Container` nodes are mostly elements like `<p>` or `<div>`. Break and merge operations are performed only in a
- * bounds of a container nodes. Containers will not be broken or merged by
- * {@link core.treeView.Writer#breakAttributes breakAttributes} and
- * {@link core.treeView.Writer#mergeAttributes mergeAttributes}.
- *
- * `Attribute` nodes are mostly elements like `<b>` or `<span>`. Attributes can be broken and merged. Merging requires
- * that attribute nodes are {@link core.treeView.Element#isSimilar similar} and have same priority. Setting different
- * priorities on similar nodes may prevent merging, eg. two `<abbr>` nodes next ot each other shouldn't be merged.
- * There might be a need to mark `<span>` element as a container node, for example in situation when it will be a
- * container of an inline widget:
- *
- * <span color="red">foobar</span> // attribute
- * <span data-widget>foobar</span> // container
- *
- * @param {core.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 core.treeView.Writer#isContainer isContainer}
- * method description.
- *
- * @see core.treeView.Writer#isContainer
- * @param {core.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 core.treeView.Writer#isContainer
- * @param {core.treeView.Node} node
- * @param {Number} priority
- */
- setPriority( node, priority ) {
- this._priorities.set( node, priority );
- }
- /**
- * Returns node's priority.
- *
- * @param {core.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 core.treeView.Position Position}.
- * Position's parent node is checked as first, then next parents are checked.
- *
- * @param {core.treeView.Position} position Position used as a start point to locate parent container.
- * @returns {core.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 `<p>` is a container, `<b>` and `<u>` are attribute nodes:
- *
- * <p>{foo}<b><u>{bar}|</u></b></p> -> <p>{foo}<b><u>{bar}</u></b>|</p>
- * <p>{foo}<b><u>|{bar}</u></b></p> -> <p>{foo}|<b><u>{bar}</u></b></p>
- * <p>{foo}<b><u>{b|ar}</u></b></p> -> <p>{foo}<b><u>{b}</u></b>|<b><u>{ar}</u></b></p>
- *
- * @see core.treeView.Writer#isContainer
- * @see core.treeView.Writer#isAttribute
- *
- * @param {core.treeView.Position} position Position where to break attributes.
- * @returns {core.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();
- // <p>foo<b><u>bar|</u></b></p>
- // <p>foo<b><u>bar</u>|</b></p>
- // <p>foo<b><u>bar</u></b>|</p>
- if ( positionOffset == length ) {
- const newPosition = new Position( positionParent.parent, positionParent.getIndex() + 1 );
- return this.breakAttributes( newPosition );
- } else
- // <p>foo<b><u>|bar</u></b></p>
- // <p>foo<b>|<u>bar</u></b></p>
- // <p>foo|<b><u>bar</u></b></p>
- if ( positionOffset === 0 ) {
- const newPosition = new Position( positionParent.parent, positionParent.getIndex() );
- return this.breakAttributes( newPosition );
- }
- // <p>foo<b><u>"b|ar"</u></b></p>
- // <p>foo<b><u>"b"|"ar"</u></b></p>
- // <p>foo<b><u>b</u>|<u>ar</u></b></p>
- // <p>foo<b><u>b</u></b>|<b><u>ar</u></b></p>
- 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 core.treeView.Writer#breakAttributes breakAttribute} method to break attributes on
- * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions of
- * provided {@link core.treeView.Range Range}.
- *
- * Throws {@link core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
- * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
- * same parent container.
- *
- * @see core.treeView.Writer#breakAttribute
- * @param {core.treeView.Range} range Range which `start` and `end` positions will be used to break attributes.
- * @returns {core.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 core.treeView.Element#isSimilar similar} `attribute` nodes, with same priority can be merged.
- *
- * In following examples `<p>` is a container and `<b>` is an attribute node:
- *
- * <p>{foo}|{bar}</p> -> <p>{foo|bar}</p>
- * <p><b>{foo}</b>|<b>{bar}</b> -> <p><b>{foo|bar}</b></b>
- * <p><b foo="bar">{a}</b>|<b foo="baz">{b}</b> -> <p><b foo="bar">{a}</b>|<b foo="baz">{b}</b>
- *
- * @see core.treeView.Writer#isContainer
- * @see core.treeView.Writer#isAttribute
- * @param {core.treeView.Position} position Merge position.
- * @returns {core.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 {core.treeView.Position} position Insertion position.
- * @param {core.treeView.Node|Iterable.<core.treeView.Node>} nodes Node or nodes to insert.
- * @returns {core.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 core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
- * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
- * same parent container.
- *
- * @param {core.treeView.Range} range Range to remove from container. After removing, it will be updated
- * to a collapsed range showing the new position.
- * @returns {Array.<core.treeView.Node>} The array of 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 [];
- }
- // 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 removed;
- }
- /**
- * Moves nodes from provided range to target position.
- *
- * Throws {@link core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
- * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
- * same parent container.
- *
- * @param {core.treeView.Range} sourceRange Range containing nodes to move.
- * @param {core.treeView.Position} targetPosition Position to insert.
- * @returns {core.treeView.Range} Range in target container. Inserted nodes are placed between
- * {@link core.treeView.Range#start start} and {@link core.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 core.CKEditorError} `treeview-writer-invalid-range-container` when {@link core.treeView.Range#start}
- * and {@link core.treeView.Range#end} positions are not placed inside same parent container.
- *
- * @param {core.treeView.Range} range Range to wrap.
- * @param {core.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 core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
- * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
- * same parent container.
- *
- * @param {core.treeView.Range} range
- * @param {core.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 {core.treeView.Writer} writer
- // @param {core.treeView.Element} parent
- // @param {Number} startOffset
- // @param {Number} endOffset
- // @param {core.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 {core.treeView.Writer} writer
- // @param {core.treeView.Element} parent
- // @param {Number} startOffset
- // @param {Number} endOffset
- // @param {core.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 );
- }
|