@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
1,068 lines • 76 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module engine/view/downcastwriter
*/
import { ViewPosition } from './position.js';
import { ViewRange } from './range.js';
import { ViewSelection } from './selection.js';
import { ViewContainerElement } from './containerelement.js';
import { ViewAttributeElement } from './attributeelement.js';
import { ViewEmptyElement } from './emptyelement.js';
import { ViewUIElement } from './uielement.js';
import { ViewRawElement } from './rawelement.js';
import { CKEditorError, isIterable } from '@ckeditor/ckeditor5-utils';
import { ViewDocumentFragment } from './documentfragment.js';
import { ViewText } from './text.js';
import { ViewEditableElement } from './editableelement.js';
import { isPlainObject } from 'es-toolkit/compat';
/**
* View downcast writer.
*
* It provides a set of methods used to manipulate view nodes.
*
* Do not create an instance of this writer manually. To modify a view structure, use
* the {@link module:engine/view/view~EditingView#change `View#change()`} block.
*
* The `ViewDowncastWriter` is designed to work with semantic views which are the views that were/are being downcasted from the model.
* To work with ordinary views (e.g. parsed from a pasted content) use the
* {@link module:engine/view/upcastwriter~ViewUpcastWriter upcast writer}.
*
* Read more about changing the view in the {@glink framework/architecture/editing-engine#changing-the-view Changing the view}
* section of the {@glink framework/architecture/editing-engine Editing engine architecture} guide.
*/
export class ViewDowncastWriter {
/**
* The view document instance in which this writer operates.
*/
document;
/**
* Holds references to the attribute groups that share the same {@link module:engine/view/attributeelement~ViewAttributeElement#id id}.
* The keys are `id`s, the values are `Set`s holding {@link module:engine/view/attributeelement~ViewAttributeElement}s.
*/
_cloneGroups = new Map();
/**
* The slot factory used by the `elementToStructure` downcast helper.
*/
_slotFactory = null;
/**
* @param document The view document instance.
*/
constructor(document) {
this.document = document;
}
setSelection(...args) {
this.document.selection._setTo(...args);
}
/**
* Moves {@link module:engine/view/documentselection~ViewDocumentSelection#focus selection's focus} to the specified location.
*
* The location can be specified in the same form as
* {@link module:engine/view/view~EditingView#createPositionAt view.createPositionAt()}
* parameters.
*
* @param itemOrPosition
* @param offset Offset or one of the flags. Used only when the first parameter is a {@link module:engine/view/item~ViewItem view item}.
*/
setSelectionFocus(itemOrPosition, offset) {
this.document.selection._setFocus(itemOrPosition, offset);
}
/**
* Creates a new {@link module:engine/view/documentfragment~ViewDocumentFragment} instance.
*
* @param children A list of nodes to be inserted into the created document fragment.
* @returns The created document fragment.
*/
createDocumentFragment(children) {
return new ViewDocumentFragment(this.document, children);
}
/**
* Creates a new {@link module:engine/view/text~ViewText text node}.
*
* ```ts
* writer.createText( 'foo' );
* ```
*
* @param data The text's data.
* @returns The created text node.
*/
createText(data) {
return new ViewText(this.document, data);
}
/**
* Creates a new {@link module:engine/view/attributeelement~ViewAttributeElement}.
*
* ```ts
* writer.createAttributeElement( 'strong' );
* writer.createAttributeElement( 'a', { href: 'foo.bar' } );
*
* // Make `<a>` element contain other attributes element so the `<a>` element is not broken.
* writer.createAttributeElement( 'a', { href: 'foo.bar' }, { priority: 5 } );
*
* // Set `id` of a marker element so it is not joined or merged with "normal" elements.
* writer.createAttributeElement( 'span', { class: 'my-marker' }, { id: 'marker:my' } );
* ```
*
* @param name Name of the element.
* @param attributes Element's attributes.
* @param options Element's options.
* @param options.priority Element's {@link module:engine/view/attributeelement~ViewAttributeElement#priority priority}.
* @param options.id Element's {@link module:engine/view/attributeelement~ViewAttributeElement#id id}.
* @param options.renderUnsafeAttributes A list of attribute names that should be rendered in the editing
* pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
* @returns Created element.
*/
createAttributeElement(name, attributes, options = {}) {
const attributeElement = new ViewAttributeElement(this.document, name, attributes);
if (typeof options.priority === 'number') {
attributeElement._priority = options.priority;
}
if (options.id) {
attributeElement._id = options.id;
}
if (options.renderUnsafeAttributes) {
attributeElement._unsafeAttributesToRender.push(...options.renderUnsafeAttributes);
}
return attributeElement;
}
createContainerElement(name, attributes, childrenOrOptions = {}, options = {}) {
let children = undefined;
if (isContainerOptions(childrenOrOptions)) {
options = childrenOrOptions;
}
else {
children = childrenOrOptions;
}
const containerElement = new ViewContainerElement(this.document, name, attributes, children);
if (options.renderUnsafeAttributes) {
containerElement._unsafeAttributesToRender.push(...options.renderUnsafeAttributes);
}
return containerElement;
}
/**
* Creates a new {@link module:engine/view/editableelement~ViewEditableElement}.
*
* ```ts
* writer.createEditableElement( 'div' );
* writer.createEditableElement( 'div', { id: 'foo-1234' } );
* ```
*
* Note: The editable element is to be used in the editing pipeline. Usually, together with
* {@link module:widget/utils~toWidgetEditable `toWidgetEditable()`}.
*
* @param name Name of the element.
* @param attributes Elements attributes.
* @param options Element's options.
* @param options.renderUnsafeAttributes A list of attribute names that should be rendered in the editing
* pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
* @returns Created element.
*/
createEditableElement(name, attributes, options = {}) {
const editableElement = new ViewEditableElement(this.document, name, attributes);
if (options.renderUnsafeAttributes) {
editableElement._unsafeAttributesToRender.push(...options.renderUnsafeAttributes);
}
return editableElement;
}
/**
* Creates a new {@link module:engine/view/emptyelement~ViewEmptyElement}.
*
* ```ts
* writer.createEmptyElement( 'img' );
* writer.createEmptyElement( 'img', { id: 'foo-1234' } );
* ```
*
* @param name Name of the element.
* @param attributes Elements attributes.
* @param options Element's options.
* @param options.renderUnsafeAttributes A list of attribute names that should be rendered in the editing
* pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
* @returns Created element.
*/
createEmptyElement(name, attributes, options = {}) {
const emptyElement = new ViewEmptyElement(this.document, name, attributes);
if (options.renderUnsafeAttributes) {
emptyElement._unsafeAttributesToRender.push(...options.renderUnsafeAttributes);
}
return emptyElement;
}
/**
* Creates a new {@link module:engine/view/uielement~ViewUIElement}.
*
* ```ts
* writer.createUIElement( 'span' );
* writer.createUIElement( 'span', { id: 'foo-1234' } );
* ```
*
* A custom render function can be provided as the third parameter:
*
* ```ts
* writer.createUIElement( 'span', null, function( domDocument ) {
* const domElement = this.toDomElement( domDocument );
* domElement.innerHTML = '<b>this is ui element</b>';
*
* return domElement;
* } );
* ```
*
* Unlike {@link #createRawElement raw elements}, UI elements are by no means editor content, for instance,
* they are ignored by the editor selection system.
*
* You should not use UI elements as data containers. Check out {@link #createRawElement} instead.
*
* @param name The name of the element.
* @param attributes Element attributes.
* @param renderFunction A custom render function.
* @returns The created element.
*/
createUIElement(name, attributes, renderFunction) {
const uiElement = new ViewUIElement(this.document, name, attributes);
if (renderFunction) {
uiElement.render = renderFunction;
}
return uiElement;
}
/**
* Creates a new {@link module:engine/view/rawelement~ViewRawElement}.
*
* ```ts
* writer.createRawElement( 'span', { id: 'foo-1234' }, function( domElement ) {
* domElement.innerHTML = '<b>This is the raw content of the raw element.</b>';
* } );
* ```
*
* Raw elements work as data containers ("wrappers", "sandboxes") but their children are not managed or
* even recognized by the editor. This encapsulation allows integrations to maintain custom DOM structures
* in the editor content without, for instance, worrying about compatibility with other editor features.
* Raw elements are a perfect tool for integration with external frameworks and data sources.
*
* Unlike {@link #createUIElement UI elements}, raw elements act like "real" editor content (similar to
* {@link module:engine/view/containerelement~ViewContainerElement} or {@link module:engine/view/emptyelement~ViewEmptyElement}),
* and they are considered by the editor selection.
*
* You should not use raw elements to render the UI in the editor content. Check out {@link #createUIElement `#createUIElement()`}
* instead.
*
* @param name The name of the element.
* @param attributes Element attributes.
* @param renderFunction A custom render function.
* @param options Element's options.
* @param options.renderUnsafeAttributes A list of attribute names that should be rendered in the editing
* pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms.
* @returns The created element.
*/
createRawElement(name, attributes, renderFunction, options = {}) {
const rawElement = new ViewRawElement(this.document, name, attributes);
if (renderFunction) {
rawElement.render = renderFunction;
}
if (options.renderUnsafeAttributes) {
rawElement._unsafeAttributesToRender.push(...options.renderUnsafeAttributes);
}
return rawElement;
}
setAttribute(key, value, elementOrOverwrite, element) {
if (element !== undefined) {
element._setAttribute(key, value, elementOrOverwrite);
}
else {
elementOrOverwrite._setAttribute(key, value);
}
}
removeAttribute(key, elementOrTokens, element) {
if (element !== undefined) {
element._removeAttribute(key, elementOrTokens);
}
else {
elementOrTokens._removeAttribute(key);
}
}
/**
* Adds specified class to the element.
*
* ```ts
* writer.addClass( 'foo', linkElement );
* writer.addClass( [ 'foo', 'bar' ], linkElement );
* ```
*/
addClass(className, element) {
element._addClass(className);
}
/**
* Removes specified class from the element.
*
* ```ts
* writer.removeClass( 'foo', linkElement );
* writer.removeClass( [ 'foo', 'bar' ], linkElement );
* ```
*/
removeClass(className, element) {
element._removeClass(className);
}
setStyle(property, value, element) {
if (isPlainObject(property) && element === undefined) {
value._setStyle(property);
}
else {
element._setStyle(property, value);
}
}
/**
* Removes specified style from the element.
*
* ```ts
* writer.removeStyle( 'color', element ); // Removes 'color' style.
* writer.removeStyle( [ 'color', 'border-top' ], element ); // Removes both 'color' and 'border-top' styles.
* ```
*
* **Note**: This method can work with normalized style names if
* {@link module:engine/controller/datacontroller~DataController#addStyleProcessorRules a particular style processor rule is enabled}.
* See {@link module:engine/view/stylesmap~StylesMap#remove `StylesMap#remove()`} for details.
*/
removeStyle(property, element) {
element._removeStyle(property);
}
/**
* Sets a custom property on element. Unlike attributes, custom properties are not rendered to the DOM,
* so they can be used to add special data to elements.
*/
setCustomProperty(key, value, element) {
element._setCustomProperty(key, value);
}
/**
* Removes a custom property stored under the given key.
*
* @returns Returns true if property was removed.
*/
removeCustomProperty(key, element) {
return element._removeCustomProperty(key);
}
/**
* Breaks attribute elements at the provided position or at the boundaries of a provided range. It breaks attribute elements
* up to their first ancestor that is a container element.
*
* In following examples `<p>` is a container, `<b>` and `<u>` are attribute elements:
*
* ```html
* <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>
* <p><b>fo{o</b><u>ba}r</u></p> -> <p><b>fo</b><b>o</b><u>ba</u><u>r</u></b></p>
* ```
*
* **Note:** {@link module:engine/view/documentfragment~ViewDocumentFragment DocumentFragment} is treated like a container.
*
* **Note:** The difference between {@link module:engine/view/downcastwriter~ViewDowncastWriter#breakAttributes breakAttributes()} and
* {@link module:engine/view/downcastwriter~ViewDowncastWriter#breakContainer breakContainer()} is that `breakAttributes()` breaks all
* {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements} that are ancestors of a given `position`,
* up to the first encountered {@link module:engine/view/containerelement~ViewContainerElement container element}.
* `breakContainer()` assumes that a given `position` is directly in the container element and breaks that container element.
*
* Throws the `view-writer-invalid-range-container` {@link module:utils/ckeditorerror~CKEditorError CKEditorError}
* when the {@link module:engine/view/range~ViewRange#start start}
* and {@link module:engine/view/range~ViewRange#end end} positions of a passed range are not placed inside same parent container.
*
* Throws the `view-writer-cannot-break-empty-element` {@link module:utils/ckeditorerror~CKEditorError CKEditorError}
* when trying to break attributes inside an {@link module:engine/view/emptyelement~ViewEmptyElement ViewEmptyElement}.
*
* Throws the `view-writer-cannot-break-ui-element` {@link module:utils/ckeditorerror~CKEditorError CKEditorError}
* when trying to break attributes inside a {@link module:engine/view/uielement~ViewUIElement UIElement}.
*
* @see module:engine/view/attributeelement~ViewAttributeElement
* @see module:engine/view/containerelement~ViewContainerElement
* @see module:engine/view/downcastwriter~ViewDowncastWriter#breakContainer
* @param positionOrRange The position where to break attribute elements.
* @returns The new position or range, after breaking the attribute elements.
*/
breakAttributes(positionOrRange) {
if (positionOrRange instanceof ViewPosition) {
return this._breakAttributes(positionOrRange);
}
else {
return this._breakAttributesRange(positionOrRange);
}
}
/**
* Breaks a {@link module:engine/view/containerelement~ViewContainerElement container view element} into two, at the given position.
* The position has to be directly inside the container element and cannot be in the root. It does not break the conrainer view element
* if the position is at the beginning or at the end of its parent element.
*
* ```html
* <p>foo^bar</p> -> <p>foo</p><p>bar</p>
* <div><p>foo</p>^<p>bar</p></div> -> <div><p>foo</p></div><div><p>bar</p></div>
* <p>^foobar</p> -> ^<p>foobar</p>
* <p>foobar^</p> -> <p>foobar</p>^
* ```
*
* **Note:** The difference between {@link module:engine/view/downcastwriter~ViewDowncastWriter#breakAttributes breakAttributes()} and
* {@link module:engine/view/downcastwriter~ViewDowncastWriter#breakContainer breakContainer()} is that `breakAttributes()` breaks all
* {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements} that are ancestors of a given `position`,
* up to the first encountered {@link module:engine/view/containerelement~ViewContainerElement container element}.
* `breakContainer()` assumes that the given `position` is directly in the container element and breaks that container element.
*
* @see module:engine/view/attributeelement~ViewAttributeElement
* @see module:engine/view/containerelement~ViewContainerElement
* @see module:engine/view/downcastwriter~ViewDowncastWriter#breakAttributes
* @param position The position where to break the element.
* @returns The position between broken elements. If an element has not been broken,
* the returned position is placed either before or after it.
*/
breakContainer(position) {
const element = position.parent;
if (!(element.is('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', this.document);
}
if (!element.parent) {
/**
* Trying to break root element.
*
* @error view-writer-break-root
*/
throw new CKEditorError('view-writer-break-root', this.document);
}
if (position.isAtStart) {
return ViewPosition._createBefore(element);
}
else if (!position.isAtEnd) {
const newElement = element._clone(false);
this.insert(ViewPosition._createAfter(element), newElement);
const sourceRange = new ViewRange(position, ViewPosition._createAt(element, 'end'));
const targetPosition = new ViewPosition(newElement, 0);
this.move(sourceRange, targetPosition);
}
return ViewPosition._createAfter(element);
}
/**
* Merges {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements}. It also merges text nodes if needed.
* Only {@link module:engine/view/attributeelement~ViewAttributeElement#isSimilar similar} attribute elements can be merged.
*
* In following examples `<p>` is a container and `<b>` is an attribute element:
*
* ```html
* <p>foo[]bar</p> -> <p>foo{}bar</p>
* <p><b>foo</b>[]<b>bar</b></p> -> <p><b>foo{}bar</b></p>
* <p><b foo="bar">a</b>[]<b foo="baz">b</b></p> -> <p><b foo="bar">a</b>[]<b foo="baz">b</b></p>
* ```
*
* It will also take care about empty attributes when merging:
*
* ```html
* <p><b>[]</b></p> -> <p>[]</p>
* <p><b>foo</b><i>[]</i><b>bar</b></p> -> <p><b>foo{}bar</b></p>
* ```
*
* **Note:** Difference between {@link module:engine/view/downcastwriter~ViewDowncastWriter#mergeAttributes mergeAttributes} and
* {@link module:engine/view/downcastwriter~ViewDowncastWriter#mergeContainers mergeContainers} is that `mergeAttributes` merges two
* {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements} or
* {@link module:engine/view/text~ViewText text nodes} while `mergeContainer` merges two
* {@link module:engine/view/containerelement~ViewContainerElement container elements}.
*
* @see module:engine/view/attributeelement~ViewAttributeElement
* @see module:engine/view/containerelement~ViewContainerElement
* @see module:engine/view/downcastwriter~ViewDowncastWriter#mergeContainers
* @param position Merge position.
* @returns Position after merge.
*/
mergeAttributes(position) {
const positionOffset = position.offset;
const positionParent = position.parent;
// When inside text node - nothing to merge.
if (positionParent.is('$text')) {
return position;
}
// When inside empty attribute - remove it.
if (positionParent.is('attributeElement') && positionParent.childCount === 0) {
const parent = positionParent.parent;
const offset = positionParent.index;
positionParent._remove();
this._removeFromClonedElementsGroup(positionParent);
return this.mergeAttributes(new ViewPosition(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 position is between two text nodes.
if (nodeBefore.is('$text') && nodeAfter.is('$text')) {
return mergeTextNodes(nodeBefore, nodeAfter);
}
// When position is between two same attribute elements.
else if (nodeBefore.is('attributeElement') && nodeAfter.is('attributeElement') && nodeBefore.isSimilar(nodeAfter)) {
// Move all children nodes from node placed after selection and remove that node.
const count = nodeBefore.childCount;
nodeBefore._appendChild(nodeAfter.getChildren());
nodeAfter._remove();
this._removeFromClonedElementsGroup(nodeAfter);
// New position is located inside the first node, before new nodes.
// Call this method recursively to merge again if needed.
return this.mergeAttributes(new ViewPosition(nodeBefore, count));
}
return position;
}
/**
* Merges two {@link module:engine/view/containerelement~ViewContainerElement 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.
*
* ```html
* <p>foo</p>^<p>bar</p> -> <p>foo^bar</p>
* <div>foo</div>^<p>bar</p> -> <div>foo^bar</div>
* ```
*
* **Note:** Difference between {@link module:engine/view/downcastwriter~ViewDowncastWriter#mergeAttributes mergeAttributes} and
* {@link module:engine/view/downcastwriter~ViewDowncastWriter#mergeContainers mergeContainers} is that `mergeAttributes` merges two
* {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements} or
* {@link module:engine/view/text~ViewText text nodes} while `mergeContainer` merges two
* {@link module:engine/view/containerelement~ViewContainerElement container elements}.
*
* @see module:engine/view/attributeelement~ViewAttributeElement
* @see module:engine/view/containerelement~ViewContainerElement
* @see module:engine/view/downcastwriter~ViewDowncastWriter#mergeAttributes
* @param position Merge position.
* @returns Position after merge.
*/
mergeContainers(position) {
const prev = position.nodeBefore;
const next = position.nodeAfter;
if (!prev || !next || !prev.is('containerElement') || !next.is('containerElement')) {
/**
* Element before and after given position cannot be merged.
*
* @error view-writer-merge-containers-invalid-position
*/
throw new CKEditorError('view-writer-merge-containers-invalid-position', this.document);
}
const lastChild = prev.getChild(prev.childCount - 1);
const newPosition = lastChild instanceof ViewText ?
ViewPosition._createAt(lastChild, 'end') :
ViewPosition._createAt(prev, 'end');
this.move(ViewRange._createIn(next), ViewPosition._createAt(prev, 'end'));
this.remove(ViewRange._createOn(next));
return newPosition;
}
/**
* Inserts a node or nodes at specified position. Takes care about breaking attributes before insertion
* and merging them afterwards.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-insert-invalid-node` when nodes to insert
* contains instances that are not {@link module:engine/view/text~ViewText Texts},
* {@link module:engine/view/attributeelement~ViewAttributeElement ViewAttributeElements},
* {@link module:engine/view/containerelement~ViewContainerElement ViewContainerElements},
* {@link module:engine/view/emptyelement~ViewEmptyElement ViewEmptyElements},
* {@link module:engine/view/rawelement~ViewRawElement RawElements} or
* {@link module:engine/view/uielement~ViewUIElement UIElements}.
*
* @param position Insertion position.
* @param nodes Node or nodes to insert.
* @returns Range around inserted nodes.
*/
insert(position, nodes) {
nodes = isIterable(nodes) ? [...nodes] : [nodes];
// Check if nodes to insert are instances of ViewAttributeElements, ViewContainerElements, ViewEmptyElements, UIElements or Text.
validateNodesToInsert(nodes, this.document);
// Group nodes in batches of nodes that require or do not require breaking an ViewAttributeElements.
const nodeGroups = nodes.reduce((groups, node) => {
const lastGroup = groups[groups.length - 1];
// Break attributes on nodes that do exist in the model tree so they can have attributes, other elements
// can't have an attribute in model and won't get wrapped with an ViewAttributeElement while down-casted.
const breakAttributes = !node.is('uiElement');
if (!lastGroup || lastGroup.breakAttributes != breakAttributes) {
groups.push({
breakAttributes,
nodes: [node]
});
}
else {
lastGroup.nodes.push(node);
}
return groups;
}, []);
// Insert nodes in batches.
let start = null;
let end = position;
for (const { nodes, breakAttributes } of nodeGroups) {
const range = this._insertNodes(end, nodes, breakAttributes);
if (!start) {
start = range.start;
}
end = range.end;
}
// When no nodes were inserted - return collapsed range.
if (!start) {
return new ViewRange(position);
}
return new ViewRange(start, end);
}
/**
* Removes provided range from the container.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when
* {@link module:engine/view/range~ViewRange#start start} and {@link module:engine/view/range~ViewRange#end end}
* positions are not placed inside same parent container.
*
* @param rangeOrItem Range to remove from container
* or an {@link module:engine/view/item~ViewItem item} to remove. If range is provided, after removing, it will be updated
* to a collapsed range showing the new position.
* @returns Document fragment containing removed nodes.
*/
remove(rangeOrItem) {
const range = rangeOrItem instanceof ViewRange ? rangeOrItem : ViewRange._createOn(rangeOrItem);
validateRangeContainer(range, this.document);
// If range is collapsed - nothing to remove.
if (range.isCollapsed) {
return new ViewDocumentFragment(this.document);
}
// Break attributes at range start and end.
const { start: breakStart, end: breakEnd } = this._breakAttributesRange(range, true);
const parentContainer = breakStart.parent;
const count = breakEnd.offset - breakStart.offset;
// Remove nodes in range.
const removed = parentContainer._removeChildren(breakStart.offset, count);
for (const node of removed) {
this._removeFromClonedElementsGroup(node);
}
// Merge after removing.
const mergePosition = this.mergeAttributes(breakStart);
range.start = mergePosition;
range.end = mergePosition.clone();
// Return removed nodes.
return new ViewDocumentFragment(this.document, removed);
}
/**
* Removes matching elements from given range.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when
* {@link module:engine/view/range~ViewRange#start start} and {@link module:engine/view/range~ViewRange#end end}
* positions are not placed inside same parent container.
*
* @param range Range to clear.
* @param element Element to remove.
*/
clear(range, element) {
validateRangeContainer(range, this.document);
// Create walker on given range.
// We walk backward because when we remove element during walk it modifies range end position.
const walker = range.getWalker({
direction: 'backward',
ignoreElementEnd: true
});
// Let's walk.
for (const current of walker) {
const item = current.item;
let rangeToRemove;
// When current item matches to the given element.
if (item.is('element') && element.isSimilar(item)) {
// Create range on this element.
rangeToRemove = ViewRange._createOn(item);
// When range starts inside Text or TextProxy element.
}
else if (!current.nextPosition.isAfter(range.start) && item.is('$textProxy')) {
// We need to check if parent of this text matches to given element.
const parentElement = item.getAncestors().find(ancestor => {
return ancestor.is('element') && element.isSimilar(ancestor);
});
// If it is then create range inside this element.
if (parentElement) {
rangeToRemove = ViewRange._createIn(parentElement);
}
}
// If we have found element to remove.
if (rangeToRemove) {
// We need to check if element range stick out of the given range and truncate if it is.
if (rangeToRemove.end.isAfter(range.end)) {
rangeToRemove.end = range.end;
}
if (rangeToRemove.start.isBefore(range.start)) {
rangeToRemove.start = range.start;
}
// At the end we remove range with found element.
this.remove(rangeToRemove);
}
}
}
/**
* Moves nodes from provided range to target position.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when
* {@link module:engine/view/range~ViewRange#start start} and {@link module:engine/view/range~ViewRange#end end}
* positions are not placed inside same parent container.
*
* @param sourceRange Range containing nodes to move.
* @param targetPosition Position to insert.
* @returns Range in target container. Inserted nodes are placed between
* {@link module:engine/view/range~ViewRange#start start} and {@link module:engine/view/range~ViewRange#end end} positions.
*/
move(sourceRange, targetPosition) {
let nodes;
if (targetPosition.isAfter(sourceRange.end)) {
targetPosition = this._breakAttributes(targetPosition, true);
const parent = targetPosition.parent;
const countBefore = parent.childCount;
sourceRange = this._breakAttributesRange(sourceRange, true);
nodes = this.remove(sourceRange);
targetPosition.offset += (parent.childCount - countBefore);
}
else {
nodes = this.remove(sourceRange);
}
return this.insert(targetPosition, nodes);
}
/**
* Wraps elements within range with provided {@link module:engine/view/attributeelement~ViewAttributeElement ViewAttributeElement}.
* If a collapsed range is provided, it will be wrapped only if it is equal to view selection.
*
* If a collapsed range was passed and is same as selection, the selection
* will be moved to the inside of the wrapped attribute element.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-invalid-range-container`
* when {@link module:engine/view/range~ViewRange#start}
* and {@link module:engine/view/range~ViewRange#end} positions are not placed inside same parent container.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
* an instance of {@link module:engine/view/attributeelement~ViewAttributeElement ViewAttributeElement}.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-nonselection-collapsed-range` when passed range
* is collapsed and different than view selection.
*
* @param range Range to wrap.
* @param attribute Attribute element to use as wrapper.
* @returns range Range after wrapping, spanning over wrapping attribute element.
*/
wrap(range, attribute) {
if (!(attribute instanceof ViewAttributeElement)) {
throw new CKEditorError('view-writer-wrap-invalid-attribute', this.document);
}
validateRangeContainer(range, this.document);
if (!range.isCollapsed) {
// Non-collapsed range. Wrap it with the attribute element.
return this._wrapRange(range, attribute);
}
else {
// Collapsed range. Wrap position.
let position = range.start;
if (position.parent.is('element') && !_hasNonUiChildren(position.parent)) {
position = position.getLastMatchingPosition(value => value.item.is('uiElement'));
}
position = this._wrapPosition(position, attribute);
const viewSelection = this.document.selection;
// If wrapping position is equal to view selection, move view selection inside wrapping attribute element.
if (viewSelection.isCollapsed && viewSelection.getFirstPosition().isEqual(range.start)) {
this.setSelection(position);
}
return new ViewRange(position);
}
}
/**
* Unwraps nodes within provided range from attribute element.
*
* Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when
* {@link module:engine/view/range~ViewRange#start start} and {@link module:engine/view/range~ViewRange#end end}
* positions are not placed inside same parent container.
*/
unwrap(range, attribute) {
if (!(attribute instanceof ViewAttributeElement)) {
/**
* The `attribute` passed to {@link module:engine/view/downcastwriter~ViewDowncastWriter#unwrap `ViewDowncastWriter#unwrap()`}
* must be an instance of {@link module:engine/view/attributeelement~ViewAttributeElement `AttributeElement`}.
*
* @error view-writer-unwrap-invalid-attribute
*/
throw new CKEditorError('view-writer-unwrap-invalid-attribute', this.document);
}
validateRangeContainer(range, this.document);
// 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._breakAttributesRange(range, true);
const parentContainer = breakStart.parent;
// Unwrap children located between break points.
const newRange = this._unwrapChildren(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 ViewRange(start, end);
}
/**
* Renames element by creating a copy of renamed element but with changed name and then moving contents of the
* old element to the new one. Keep in mind that this will invalidate all {@link module:engine/view/position~ViewPosition positions}
* which has renamed element as {@link module:engine/view/position~ViewPosition#parent a parent}.
*
* New element has to be created because `Element#tagName` property in DOM is readonly.
*
* Since this function creates a new element and removes the given one, the new element is returned to keep reference.
*
* @param newName New name for element.
* @param viewElement Element to be renamed.
* @returns Element created due to rename.
*/
rename(newName, viewElement) {
const newElement = new ViewContainerElement(this.document, newName, viewElement.getAttributes());
this.insert(ViewPosition._createAfter(viewElement), newElement);
this.move(ViewRange._createIn(viewElement), ViewPosition._createAt(newElement, 0));
this.remove(ViewRange._createOn(viewElement));
return newElement;
}
/**
* Cleans up memory by removing obsolete cloned elements group from the writer.
*
* Should be used whenever all {@link module:engine/view/attributeelement~ViewAttributeElement attribute elements}
* with the same {@link module:engine/view/attributeelement~ViewAttributeElement#id id} are going to be removed from the view and
* the group will no longer be needed.
*
* Cloned elements group are not removed automatically in case if the group is still needed after all its elements
* were removed from the view.
*
* Keep in mind that group names are equal to the `id` property of the attribute element.
*
* @param groupName Name of the group to clear.
*/
clearClonedElementsGroup(groupName) {
this._cloneGroups.delete(groupName);
}
/**
* Creates position at the given location. The location can be specified as:
*
* * a {@link module:engine/view/position~ViewPosition position},
* * parent element and offset (offset defaults to `0`),
* * parent element and `'end'` (sets position at the end of that element),
* * {@link module:engine/view/item~ViewItem view item} and `'before'` or `'after'` (sets position before or after given view item).
*
* This method is a shortcut to other constructors such as:
*
* * {@link #createPositionBefore},
* * {@link #createPositionAfter},
*
* @param offset Offset or one of the flags. Used only when the first parameter is a {@link module:engine/view/item~ViewItem view item}.
*/
createPositionAt(itemOrPosition, offset) {
return ViewPosition._createAt(itemOrPosition, offset);
}
/**
* Creates a new position after given view item.
*
* @param item View item after which the position should be located.
*/
createPositionAfter(item) {
return ViewPosition._createAfter(item);
}
/**
* Creates a new position before given view item.
*
* @param item View item before which the position should be located.
*/
createPositionBefore(item) {
return ViewPosition._createBefore(item);
}
/**
* Creates a range spanning from `start` position to `end` position.
*
* **Note:** This factory method creates its own {@link module:engine/view/position~ViewPosition} instances basing on passed values.
*
* @param start Start position.
* @param end End position. If not set, range will be collapsed at `start` position.
*/
createRange(start, end) {
return new ViewRange(start, end);
}
/**
* Creates a range that starts before given {@link module:engine/view/item~ViewItem view item} and ends after it.
*/
createRangeOn(item) {
return ViewRange._createOn(item);
}
/**
* Creates a range inside an {@link module:engine/view/element~ViewElement element} which starts before the first child of
* that element and ends after the last child of that element.
*
* @param element Element which is a parent for the range.
*/
createRangeIn(element) {
return ViewRange._createIn(element);
}
createSelection(...args) {
return new ViewSelection(...args);
}
/**
* Creates placeholders for child elements of the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure
* `elementToStructure()`} conversion helper.
*
* ```ts
* const viewSlot = conversionApi.writer.createSlot();
* const viewPosition = conversionApi.writer.createPositionAt( viewElement, 0 );
*
* conversionApi.writer.insert( viewPosition, viewSlot );
* ```
*
* It could be filtered down to a specific subset of children (only `<foo>` model elements in this case):
*
* ```ts
* const viewSlot = conversionApi.writer.createSlot( node => node.is( 'element', 'foo' ) );
* const viewPosition = conversionApi.writer.createPositionAt( viewElement, 0 );
*
* conversionApi.writer.insert( viewPosition, viewSlot );
* ```
*
* While providing a filtered slot, make sure to provide slots for all child nodes. A single node cannot be downcasted into
* multiple slots.
*
* **Note**: You should not change the order of nodes. View elements should be in the same order as model nodes.
*
* @param modeOrFilter The filter for child nodes.
* @returns The slot element to be placed in to the view structure while processing
* {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure `elementToStructure()`}.
*/
createSlot(modeOrFilter = 'children') {
if (!this._slotFactory) {
/**
* The `createSlot()` method is only allowed inside the `elementToStructure` downcast helper callback.
*
* @error view-writer-invalid-create-slot-context
*/
throw new CKEditorError('view-writer-invalid-create-slot-context', this.document);
}
return this._slotFactory(this, modeOrFilter);
}
/**
* Registers a slot factory.
*
* @internal
* @param slotFactory The slot factory.
*/
_registerSlotFactory(slotFactory) {
this._slotFactory = slotFactory;
}
/**
* Clears the registered slot factory.
*
* @internal
*/
_clearSlotFactory() {
this._slotFactory = null;
}
/**
* Inserts a node or nodes at the specified position. Takes care of breaking attributes before insertion
* and merging them afterwards if requested by the breakAttributes param.
*
* @param position Insertion position.
* @param nodes Node or nodes to insert.
* @param breakAttributes Whether attributes should be broken.
* @returns Range around inserted nodes.
*/
_insertNodes(position, nodes, breakAttributes) {
let parentElement;
// Break attributes on nodes that do exist in the model tree so they can have attributes, other elements
// can't have an attribute in model and won't get wrapped with an ViewAttributeElement while down-casted.
if (breakAttributes) {
parentElement = getParentContainer(position);
}
else {
parentElement = position.parent.is('$text') ? position.parent.parent : position.parent;
}
if (!parentElement) {
/**
* Position's parent container cannot be found.
*
* @error view-writer-invalid-position-container
*/
throw new CKEditorError('view-writer-invalid-position-container', this.document);
}
let insertionPosition;
if (breakAttributes) {
insertionPosition = this._breakAttributes(position, true);
}
else {
insertionPosition = position.parent.is('$text') ? breakTextNode(position) : position;
}
const length = parentElement._insertChild(insertionPosition.offset, nodes);
for (const node of nodes) {
this._addToClonedElementsGroup(node);
}
const endPosition = insertionPosition.getShiftedBy(length);
const start = this.mergeAttributes(insertionPosition);
// If start position was merged - move end position.
if (!start.isEqual(insertionPosition)) {
endPosition.offset--;
}
const end = this.mergeAttributes(endPosition);
return new ViewRange(start, end);
}
/**
* Wraps children with provided `wrapElement`. Only children contained in `parent` element between
* `startOffset` and `endOffset` will be wrapped.
*/
_wrapChildren(parent, startOffset, endOffset, wrapElement) {
let i = startOffset;
const wrapPositions = [];
while (i < endOffset) {
const child = parent.getChild(i);
const isText = child.is('$text');
const isAttribute = child.is('attributeElement');
//
// (In all examples, assume that `wrapElement` is `<span class="foo">` element.)
//
// Check if `wrapElement` can be joined with the wrapped element. One of requirements is having same name.
// If possible, join elements.
//
// <p><span class="bar">abc</span></p> --> <p><span class="foo bar">abc</span></p>
//
if (isAttribute && child._canMergeAttributesFrom(wrapElement)) {
child._mergeAttributesFrom(wrapElement);
wrapPositions.push(new ViewPosition(parent, i));
}
//
// Wrap the child if it is not an attribute element or if it is an attribute element that should be inside
// `wrapElement` (due to priority).
//
// <p>abc</p> --> <p><span class="foo">abc</span></p>
// <p><strong>abc</strong></p> --> <p><span class="foo"><strong>abc</strong></span></p>
else if (isText || !isAttribute || shouldABeOutsideB(wrapElement, child)) {
// Clone attribute.
const newAttribute = wrapElement._clone();
// Wrap current node with new attribute.
child._remove();
newAttribute._appendChild(child);
parent._insertChild(i, newAttribute);
this._addToClonedElementsGroup(newAttribute);
wrapPositions.push(new ViewPosition(parent, i));
}
//
// If other nested attribute is found and it wasn't wrapped (see above), continue wrapping inside it.
//
// <p><a href="foo.html">abc</a></p> --> <p><a href="foo.html"><span class="foo">abc</span></a></p>
//
else /* if ( isAttribute ) */ {
this._wrapChildren(child, 0, child.childCount, wrapElement);
}
i++;
}
// Merge at each wrap.
let offsetChange = 0;
for (const position of wrapPositions) {
position.offset -= offsetChange;
// Do not merge with elements outside selected children.
if (position.offset == startOffset) {
continue;
}
const newPosition = this.mergeAttributes(position);
// If nodes were merged - other merge offsets will change.
if (!newPosition.isEqual(position)) {
offsetChange++;
endOffset--;
}
}
return ViewRange._createFromParentsAn