UNPKG

@ckeditor/ckeditor5-engine

Version:

The editing engine of CKEditor 5 – the best browser-based rich text editor.

1,131 lines (1,130 loc) 59.1 kB
/** * @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/model/writer */ import { AttributeOperation } from './operation/attributeoperation.js'; import { DetachOperation } from './operation/detachoperation.js'; import { InsertOperation } from './operation/insertoperation.js'; import { MarkerOperation } from './operation/markeroperation.js'; import { MergeOperation } from './operation/mergeoperation.js'; import { MoveOperation } from './operation/moveoperation.js'; import { RenameOperation } from './operation/renameoperation.js'; import { RootAttributeOperation } from './operation/rootattributeoperation.js'; import { RootOperation } from './operation/rootoperation.js'; import { SplitOperation } from './operation/splitoperation.js'; import { ModelDocumentFragment } from './documentfragment.js'; import { ModelDocumentSelection } from './documentselection.js'; import { ModelElement } from './element.js'; import { ModelPosition } from './position.js'; import { ModelRange } from './range.js'; import { ModelRootElement } from './rootelement.js'; import { ModelText } from './text.js'; import { CKEditorError, logWarning, toMap } from '@ckeditor/ckeditor5-utils'; /** * The model can only be modified by using the writer. It should be used whenever you want to create a node, modify * child nodes, attributes or text, set the selection's position and its attributes. * * The instance of the writer is only available in the {@link module:engine/model/model~Model#change `change()`} or * {@link module:engine/model/model~Model#enqueueChange `enqueueChange()`}. * * ```ts * model.change( writer => { * writer.insertText( 'foo', paragraph, 'end' ); * } ); * ``` * * Note that the writer should never be stored and used outside of the `change()` and * `enqueueChange()` blocks. * * Note that writer's methods do not check the {@link module:engine/model/schema~ModelSchema}. It is possible * to create incorrect model structures by using the writer. Read more about in * {@glink framework/deep-dive/schema#who-checks-the-schema "Who checks the schema?"}. * * @see module:engine/model/model~Model#change * @see module:engine/model/model~Model#enqueueChange */ export class ModelWriter { /** * Instance of the model on which this writer operates. */ model; /** * The batch to which this writer will add changes. */ batch; /** * Creates a writer instance. * * **Note:** It is not recommended to use it directly. Use {@link module:engine/model/model~Model#change `Model#change()`} or * {@link module:engine/model/model~Model#enqueueChange `Model#enqueueChange()`} instead. * * @internal */ constructor(model, batch) { this.model = model; this.batch = batch; } /** * Creates a new {@link module:engine/model/text~ModelText text node}. * * ```ts * writer.createText( 'foo' ); * writer.createText( 'foo', { bold: true } ); * ``` * * @param data Text data. * @param attributes Text attributes. * @returns {module:engine/model/text~ModelText} Created text node. */ createText(data, attributes) { return new ModelText(data, attributes); } /** * Creates a new {@link module:engine/model/element~ModelElement element}. * * ```ts * writer.createElement( 'paragraph' ); * writer.createElement( 'paragraph', { alignment: 'center' } ); * ``` * * @param name Name of the element. * @param attributes Elements attributes. * @returns Created element. */ createElement(name, attributes) { return new ModelElement(name, attributes); } /** * Creates a new {@link module:engine/model/documentfragment~ModelDocumentFragment document fragment}. * * @returns Created document fragment. */ createDocumentFragment() { return new ModelDocumentFragment(); } /** * Creates a copy of the element and returns it. Created element has the same name and attributes as the original element. * If clone is deep, the original element's children are also cloned. If not, then empty element is returned. * * @param element The element to clone. * @param deep If set to `true` clones element and all its children recursively. When set to `false`, * element will be cloned without any child. */ cloneElement(element, deep = true) { return element._clone(deep); } /** * Inserts item on given position. * * ```ts * const paragraph = writer.createElement( 'paragraph' ); * writer.insert( paragraph, position ); * ``` * * Instead of using position you can use parent and offset: * * ```ts * const text = writer.createText( 'foo' ); * writer.insert( text, paragraph, 5 ); * ``` * * You can also use `end` instead of the offset to insert at the end: * * ```ts * const text = writer.createText( 'foo' ); * writer.insert( text, paragraph, 'end' ); * ``` * * Or insert before or after another element: * * ```ts * const paragraph = writer.createElement( 'paragraph' ); * writer.insert( paragraph, anotherParagraph, 'after' ); * ``` * * These parameters works the same way as {@link #createPositionAt `writer.createPositionAt()`}. * * Note that if the item already has parent it will be removed from the previous parent. * * Note that you cannot re-insert a node from a document to a different document or a document fragment. In this case, * `model-writer-insert-forbidden-move` is thrown. * * If you want to move {@link module:engine/model/range~ModelRange range} instead of an * {@link module:engine/model/item~ModelItem item} use {@link module:engine/model/writer~ModelWriter#move `Writer#move()`}. * * **Note:** For a paste-like content insertion mechanism see * {@link module:engine/model/model~Model#insertContent `model.insertContent()`}. * * @param item Item or document fragment to insert. * @param offset Offset or one of the flags. Used only when second parameter is a {@link module:engine/model/item~ModelItem model item}. */ insert(item, itemOrPosition, offset = 0) { this._assertWriterUsedCorrectly(); if (item instanceof ModelText && item.data == '') { return; } const position = ModelPosition._createAt(itemOrPosition, offset); // If item has a parent already. if (item.parent) { // We need to check if item is going to be inserted within the same document. if (isSameTree(item.root, position.root)) { // If it's we just need to move it. this.move(ModelRange._createOn(item), position); return; } // If it isn't the same root. else { if (item.root.document) { /** * Cannot move a node from a document to a different tree. * It is forbidden to move a node that was already in a document outside of it. * * @error model-writer-insert-forbidden-move */ throw new CKEditorError('model-writer-insert-forbidden-move', this); } else { // Move between two different document fragments or from document fragment to a document is possible. // In that case, remove the item from it's original parent. this.remove(item); } } } const version = position.root.document ? position.root.document.version : null; const children = item instanceof ModelDocumentFragment ? item._removeChildren(0, item.childCount) : item; const insert = new InsertOperation(position, children, version); if (item instanceof ModelText) { insert.shouldReceiveAttributes = true; } this.batch.addOperation(insert); this.model.applyOperation(insert); // When element is a ModelDocumentFragment we need to move its markers to Document#markers. if (item instanceof ModelDocumentFragment) { for (const [markerName, markerRange] of item.markers) { // We need to migrate marker range from ModelDocumentFragment to Document. const rangeRootPosition = ModelPosition._createAt(markerRange.root, 0); const range = new ModelRange(markerRange.start._getCombined(rangeRootPosition, position), markerRange.end._getCombined(rangeRootPosition, position)); const options = { range, usingOperation: true, affectsData: true }; if (this.model.markers.has(markerName)) { this.updateMarker(markerName, options); } else { this.addMarker(markerName, options); } } } } insertText(text, attributes, // Too complicated when not using `any`. itemOrPosition, // Too complicated when not using `any`. offset // Too complicated when not using `any`. ) { if (attributes instanceof ModelDocumentFragment || attributes instanceof ModelElement || attributes instanceof ModelPosition) { this.insert(this.createText(text), attributes, itemOrPosition); } else { this.insert(this.createText(text, attributes), itemOrPosition, offset); } } insertElement(name, attributes, // Too complicated when not using `any`. itemOrPositionOrOffset, // Too complicated when not using `any`. offset // Too complicated when not using `any`. ) { if (attributes instanceof ModelDocumentFragment || attributes instanceof ModelElement || attributes instanceof ModelPosition) { this.insert(this.createElement(name), attributes, itemOrPositionOrOffset); } else { this.insert(this.createElement(name, attributes), itemOrPositionOrOffset, offset); } } /** * Inserts item at the end of the given parent. * * ```ts * const paragraph = writer.createElement( 'paragraph' ); * writer.append( paragraph, root ); * ``` * * Note that if the item already has parent it will be removed from the previous parent. * * If you want to move {@link module:engine/model/range~ModelRange range} instead of an * {@link module:engine/model/item~ModelItem item} use {@link module:engine/model/writer~ModelWriter#move `Writer#move()`}. * * @param item Item or document fragment to insert. */ append(item, parent) { this.insert(item, parent, 'end'); } appendText(text, attributes, parent) { if (attributes instanceof ModelDocumentFragment || attributes instanceof ModelElement) { this.insert(this.createText(text), attributes, 'end'); } else { this.insert(this.createText(text, attributes), parent, 'end'); } } appendElement(name, attributes, parent) { if (attributes instanceof ModelDocumentFragment || attributes instanceof ModelElement) { this.insert(this.createElement(name), attributes, 'end'); } else { this.insert(this.createElement(name, attributes), parent, 'end'); } } /** * Sets value of the attribute with given key on a {@link module:engine/model/item~ModelItem model item} * or on a {@link module:engine/model/range~ModelRange range}. * * @param key Attribute key. * @param value Attribute new value. * @param itemOrRange Model item or range on which the attribute will be set. */ setAttribute(key, value, itemOrRange) { this._assertWriterUsedCorrectly(); if (itemOrRange instanceof ModelRange) { const ranges = itemOrRange.getMinimalFlatRanges(); for (const range of ranges) { setAttributeOnRange(this, key, value, range); } } else { setAttributeOnItem(this, key, value, itemOrRange); } } /** * Sets values of attributes on a {@link module:engine/model/item~ModelItem model item} * or on a {@link module:engine/model/range~ModelRange range}. * * ```ts * writer.setAttributes( { * bold: true, * italic: true * }, range ); * ``` * * @param attributes Attributes keys and values. * @param itemOrRange Model item or range on which the attributes will be set. */ setAttributes(attributes, itemOrRange) { for (const [key, val] of toMap(attributes)) { this.setAttribute(key, val, itemOrRange); } } /** * Removes an attribute with given key from a {@link module:engine/model/item~ModelItem model item} * or from a {@link module:engine/model/range~ModelRange range}. * * @param key Attribute key. * @param itemOrRange Model item or range from which the attribute will be removed. */ removeAttribute(key, itemOrRange) { this._assertWriterUsedCorrectly(); if (itemOrRange instanceof ModelRange) { const ranges = itemOrRange.getMinimalFlatRanges(); for (const range of ranges) { setAttributeOnRange(this, key, null, range); } } else { setAttributeOnItem(this, key, null, itemOrRange); } } /** * Removes all attributes from all elements in the range or from the given item. * * @param itemOrRange Model item or range from which all attributes will be removed. */ clearAttributes(itemOrRange) { this._assertWriterUsedCorrectly(); const removeAttributesFromItem = (item) => { for (const attribute of item.getAttributeKeys()) { this.removeAttribute(attribute, item); } }; if (!(itemOrRange instanceof ModelRange)) { removeAttributesFromItem(itemOrRange); } else { for (const item of itemOrRange.getItems()) { removeAttributesFromItem(item); } } } /** * Moves all items in the source range to the target position. * * ```ts * writer.move( sourceRange, targetPosition ); * ``` * * Instead of the target position you can use parent and offset or define that range should be moved to the end * or before or after chosen item: * * ```ts * // Moves all items in the range to the paragraph at offset 5: * writer.move( sourceRange, paragraph, 5 ); * // Moves all items in the range to the end of a blockquote: * writer.move( sourceRange, blockquote, 'end' ); * // Moves all items in the range to a position after an image: * writer.move( sourceRange, image, 'after' ); * ``` * * These parameters work the same way as {@link #createPositionAt `writer.createPositionAt()`}. * * Note that items can be moved only within the same tree. It means that you can move items within the same root * (element or document fragment) or between {@link module:engine/model/document~ModelDocument#roots documents roots}, * but you cannot move items from document fragment to the document or from one detached element to another. Use * {@link module:engine/model/writer~ModelWriter#insert} in such cases. * * @param range Source range. * @param offset Offset or one of the flags. Used only when second parameter is a {@link module:engine/model/item~ModelItem model item}. */ move(range, itemOrPosition, offset) { this._assertWriterUsedCorrectly(); if (!(range instanceof ModelRange)) { /** * Invalid range to move. * * @error writer-move-invalid-range */ throw new CKEditorError('writer-move-invalid-range', this); } if (!range.isFlat) { /** * Range to move is not flat. * * @error writer-move-range-not-flat */ throw new CKEditorError('writer-move-range-not-flat', this); } const position = ModelPosition._createAt(itemOrPosition, offset); // Do not move anything if the move target is same as moved range start. if (position.isEqual(range.start)) { return; } // If part of the marker is removed, create additional marker operation for undo purposes. this._addOperationForAffectedMarkers('move', range); if (!isSameTree(range.root, position.root)) { /** * Range is going to be moved within not the same document. Please use * {@link module:engine/model/writer~ModelWriter#insert insert} instead. * * @error writer-move-different-document */ throw new CKEditorError('writer-move-different-document', this); } const version = range.root.document ? range.root.document.version : null; const operation = new MoveOperation(range.start, range.end.offset - range.start.offset, position, version); this.batch.addOperation(operation); this.model.applyOperation(operation); } /** * Removes given model {@link module:engine/model/item~ModelItem item} or {@link module:engine/model/range~ModelRange range}. * * @param itemOrRange Model item or range to remove. */ remove(itemOrRange) { this._assertWriterUsedCorrectly(); const rangeToRemove = itemOrRange instanceof ModelRange ? itemOrRange : ModelRange._createOn(itemOrRange); const ranges = rangeToRemove.getMinimalFlatRanges().reverse(); for (const flat of ranges) { // If part of the marker is removed, create additional marker operation for undo purposes. this._addOperationForAffectedMarkers('move', flat); applyRemoveOperation(flat.start, flat.end.offset - flat.start.offset, this.batch, this.model); } } /** * Merges two siblings at the given position. * * Node before and after the position have to be an element. Otherwise `writer-merge-no-element-before` or * `writer-merge-no-element-after` error will be thrown. * * @param position Position between merged elements. */ merge(position) { this._assertWriterUsedCorrectly(); const nodeBefore = position.nodeBefore; const nodeAfter = position.nodeAfter; // If part of the marker is removed, create additional marker operation for undo purposes. this._addOperationForAffectedMarkers('merge', position); if (!(nodeBefore instanceof ModelElement)) { /** * Node before merge position must be an element. * * @error writer-merge-no-element-before */ throw new CKEditorError('writer-merge-no-element-before', this); } if (!(nodeAfter instanceof ModelElement)) { /** * Node after merge position must be an element. * * @error writer-merge-no-element-after */ throw new CKEditorError('writer-merge-no-element-after', this); } if (!position.root.document) { this._mergeDetached(position); } else { this._merge(position); } } /** * Shortcut for {@link module:engine/model/model~Model#createPositionFromPath `Model#createPositionFromPath()`}. * * @param root Root of the position. * @param path Position path. See {@link module:engine/model/position~ModelPosition#path}. * @param stickiness Position stickiness. See {@link module:engine/model/position~ModelPositionStickiness}. */ createPositionFromPath(root, path, stickiness) { return this.model.createPositionFromPath(root, path, stickiness); } /** * Shortcut for {@link module:engine/model/model~Model#createPositionAt `Model#createPositionAt()`}. * * @param offset Offset or one of the flags. Used only when first parameter is a {@link module:engine/model/item~ModelItem model item}. */ createPositionAt(itemOrPosition, offset) { return this.model.createPositionAt(itemOrPosition, offset); } /** * Shortcut for {@link module:engine/model/model~Model#createPositionAfter `Model#createPositionAfter()`}. * * @param item Item after which the position should be placed. */ createPositionAfter(item) { return this.model.createPositionAfter(item); } /** * Shortcut for {@link module:engine/model/model~Model#createPositionBefore `Model#createPositionBefore()`}. * * @param item Item after which the position should be placed. */ createPositionBefore(item) { return this.model.createPositionBefore(item); } /** * Shortcut for {@link module:engine/model/model~Model#createRange `Model#createRange()`}. * * @param start Start position. * @param end End position. If not set, range will be collapsed at `start` position. */ createRange(start, end) { return this.model.createRange(start, end); } /** * Shortcut for {@link module:engine/model/model~Model#createRangeIn `Model#createRangeIn()`}. * * @param element Element which is a parent for the range. */ createRangeIn(element) { return this.model.createRangeIn(element); } /** * Shortcut for {@link module:engine/model/model~Model#createRangeOn `Model#createRangeOn()`}. * * @param element Element which is a parent for the range. */ createRangeOn(element) { return this.model.createRangeOn(element); } createSelection(...args) { return this.model.createSelection(...args); } /** * Performs merge action in a detached tree. * * @param position Position between merged elements. */ _mergeDetached(position) { const nodeBefore = position.nodeBefore; const nodeAfter = position.nodeAfter; this.move(ModelRange._createIn(nodeAfter), ModelPosition._createAt(nodeBefore, 'end')); this.remove(nodeAfter); } /** * Performs merge action in a non-detached tree. * * @param position Position between merged elements. */ _merge(position) { const targetPosition = ModelPosition._createAt(position.nodeBefore, 'end'); const sourcePosition = ModelPosition._createAt(position.nodeAfter, 0); const graveyard = position.root.document.graveyard; const graveyardPosition = new ModelPosition(graveyard, [0]); const version = position.root.document.version; const merge = new MergeOperation(sourcePosition, position.nodeAfter.maxOffset, targetPosition, graveyardPosition, version); this.batch.addOperation(merge); this.model.applyOperation(merge); } /** * Renames the given element. * * @param element The element to rename. * @param newName New element name. */ rename(element, newName) { this._assertWriterUsedCorrectly(); if (!(element instanceof ModelElement)) { /** * Trying to rename an object which is not an instance of Element. * * @error writer-rename-not-element-instance */ throw new CKEditorError('writer-rename-not-element-instance', this); } const version = element.root.document ? element.root.document.version : null; const renameOperation = new RenameOperation(ModelPosition._createBefore(element), element.name, newName, version); this.batch.addOperation(renameOperation); this.model.applyOperation(renameOperation); } /** * Splits elements starting from the given position and going to the top of the model tree as long as given * `limitElement` is reached. When `limitElement` is not defined then only the parent of the given position will be split. * * The element needs to have a parent. It cannot be a root element nor a document fragment. * The `writer-split-element-no-parent` error will be thrown if you try to split an element with no parent. * * @param position Position of split. * @param limitElement Stop splitting when this element will be reached. * @returns Split result with properties: * * `position` - Position between split elements. * * `range` - Range that stars from the end of the first split element and ends at the beginning of the first copy element. */ split(position, limitElement) { this._assertWriterUsedCorrectly(); let splitElement = position.parent; if (!splitElement.parent) { /** * Element with no parent cannot be split. * * @error writer-split-element-no-parent */ throw new CKEditorError('writer-split-element-no-parent', this); } // When limit element is not defined lets set splitElement parent as limit. if (!limitElement) { limitElement = splitElement.parent; } if (!position.parent.getAncestors({ includeSelf: true }).includes(limitElement)) { /** * Limit element is not a position ancestor. * * @error writer-split-invalid-limit-element */ throw new CKEditorError('writer-split-invalid-limit-element', this); } // We need to cache elements that will be created as a result of the first split because // we need to create a range from the end of the first split element to the beginning of the // first copy element. This should be handled by ModelLiveRange but it doesn't work on detached nodes. let firstSplitElement; let firstCopyElement; do { const version = splitElement.root.document ? splitElement.root.document.version : null; const howMany = splitElement.maxOffset - position.offset; const insertionPosition = SplitOperation.getInsertionPosition(position); const split = new SplitOperation(position, howMany, insertionPosition, null, version); this.batch.addOperation(split); this.model.applyOperation(split); // Cache result of the first split. if (!firstSplitElement && !firstCopyElement) { firstSplitElement = splitElement; firstCopyElement = position.parent.nextSibling; } position = this.createPositionAfter(position.parent); splitElement = position.parent; } while (splitElement !== limitElement); return { position, range: new ModelRange(ModelPosition._createAt(firstSplitElement, 'end'), ModelPosition._createAt(firstCopyElement, 0)) }; } /** * Wraps the given range with the given element or with a new element (if a string was passed). * * **Note:** range to wrap should be a "flat range" (see {@link module:engine/model/range~ModelRange#isFlat `Range#isFlat`}). * If not, an error will be thrown. * * @param range Range to wrap. * @param elementOrString Element or name of element to wrap the range with. */ wrap(range, elementOrString) { this._assertWriterUsedCorrectly(); if (!range.isFlat) { /** * Range to wrap is not flat. * * @error writer-wrap-range-not-flat */ throw new CKEditorError('writer-wrap-range-not-flat', this); } const element = elementOrString instanceof ModelElement ? elementOrString : new ModelElement(elementOrString); if (element.childCount > 0) { /** * Element to wrap with is not empty. * * @error writer-wrap-element-not-empty */ throw new CKEditorError('writer-wrap-element-not-empty', this); } if (element.parent !== null) { /** * Element to wrap with is already attached to a tree model. * * @error writer-wrap-element-attached */ throw new CKEditorError('writer-wrap-element-attached', this); } this.insert(element, range.start); // Shift the range-to-wrap because we just inserted an element before that range. const shiftedRange = new ModelRange(range.start.getShiftedBy(1), range.end.getShiftedBy(1)); this.move(shiftedRange, ModelPosition._createAt(element, 0)); } /** * Unwraps children of the given element – all its children are moved before it and then the element is removed. * Throws error if you try to unwrap an element which does not have a parent. * * @param element Element to unwrap. */ unwrap(element) { this._assertWriterUsedCorrectly(); if (element.parent === null) { /** * Trying to unwrap an element which has no parent. * * @error writer-unwrap-element-no-parent */ throw new CKEditorError('writer-unwrap-element-no-parent', this); } this.move(ModelRange._createIn(element), this.createPositionAfter(element)); this.remove(element); } /** * Adds a {@link module:engine/model/markercollection~Marker marker}. Marker is a named range, which tracks * changes in the document and updates its range automatically, when model tree changes. * * As the first parameter you can set marker name. * * The required `options.usingOperation` parameter lets you decide if the marker should be managed by operations or not. See * {@link module:engine/model/markercollection~Marker marker class description} to learn about the difference between * markers managed by operations and not-managed by operations. * * The `options.affectsData` parameter, which defaults to `false`, allows you to define if a marker affects the data. It should be * `true` when the marker change changes the data returned by the * {@link module:core/editor/editor~Editor#getData `editor.getData()`} method. * When set to `true` it fires the {@link module:engine/model/document~ModelDocument#event:change:data `change:data`} event. * When set to `false` it fires the {@link module:engine/model/document~ModelDocument#event:change `change`} event. * * Create marker directly base on marker's name: * * ```ts * addMarker( markerName, { range, usingOperation: false } ); * ``` * * Create marker using operation: * * ```ts * addMarker( markerName, { range, usingOperation: true } ); * ``` * * Create marker that affects the editor data: * * ```ts * addMarker( markerName, { range, usingOperation: false, affectsData: true } ); * ``` * * Note: For efficiency reasons, it's best to create and keep as little markers as possible. * * @see module:engine/model/markercollection~Marker * @param name Name of a marker to create - must be unique. * @param options.usingOperation Flag indicating that the marker should be added by MarkerOperation. * See {@link module:engine/model/markercollection~Marker#managedUsingOperations}. * @param options.range Marker range. * @param options.affectsData Flag indicating that the marker changes the editor data. * @returns Marker that was set. */ addMarker(name, options) { this._assertWriterUsedCorrectly(); if (!options || typeof options.usingOperation != 'boolean') { /** * The `options.usingOperation` parameter is required when adding a new marker. * * @error writer-addmarker-no-usingoperation */ throw new CKEditorError('writer-addmarker-no-usingoperation', this); } const usingOperation = options.usingOperation; const range = options.range; const affectsData = options.affectsData === undefined ? false : options.affectsData; if (this.model.markers.has(name)) { /** * Marker with provided name already exists. * * @error writer-addmarker-marker-exists */ throw new CKEditorError('writer-addmarker-marker-exists', this); } if (!range) { /** * Range parameter is required when adding a new marker. * * @error writer-addmarker-no-range */ throw new CKEditorError('writer-addmarker-no-range', this); } if (!usingOperation) { return this.model.markers._set(name, range, usingOperation, affectsData); } applyMarkerOperation(this, name, null, range, affectsData); return this.model.markers.get(name); } /** * Adds, updates or refreshes a {@link module:engine/model/markercollection~Marker marker}. Marker is a named range, which tracks * changes in the document and updates its range automatically, when model tree changes. Still, it is possible to change the * marker's range directly using this method. * * As the first parameter you can set marker name or instance. If none of them is provided, new marker, with a unique * name is created and returned. * * **Note**: If you want to change the {@link module:engine/view/element~ViewElement view element} * of the marker while its data in the model * remains the same, use the dedicated {@link module:engine/controller/editingcontroller~EditingController#reconvertMarker} method. * * The `options.usingOperation` parameter lets you change if the marker should be managed by operations or not. See * {@link module:engine/model/markercollection~Marker marker class description} to learn about the difference between * markers managed by operations and not-managed by operations. It is possible to change this option for an existing marker. * * The `options.affectsData` parameter, which defaults to `false`, allows you to define if a marker affects the data. It should be * `true` when the marker change changes the data returned by * the {@link module:core/editor/editor~Editor#getData `editor.getData()`} method. * When set to `true` it fires the {@link module:engine/model/document~ModelDocument#event:change:data `change:data`} event. * When set to `false` it fires the {@link module:engine/model/document~ModelDocument#event:change `change`} event. * * Update marker directly base on marker's name: * * ```ts * updateMarker( markerName, { range } ); * ``` * * Update marker using operation: * * ```ts * updateMarker( marker, { range, usingOperation: true } ); * updateMarker( markerName, { range, usingOperation: true } ); * ``` * * Change marker's option (start using operations to manage it): * * ```ts * updateMarker( marker, { usingOperation: true } ); * ``` * * Change marker's option (inform the engine, that the marker does not affect the data anymore): * * ```ts * updateMarker( markerName, { affectsData: false } ); * ``` * * @see module:engine/model/markercollection~Marker * @param markerOrName Name of a marker to update, or a marker instance. * @param options If options object is not defined then marker will be refreshed by triggering * downcast conversion for this marker with the same data. * @param options.range Marker range to update. * @param options.usingOperation Flag indicated whether the marker should be added by MarkerOperation. * See {@link module:engine/model/markercollection~Marker#managedUsingOperations}. * @param options.affectsData Flag indicating that the marker changes the editor data. */ updateMarker(markerOrName, options) { this._assertWriterUsedCorrectly(); const markerName = typeof markerOrName == 'string' ? markerOrName : markerOrName.name; const currentMarker = this.model.markers.get(markerName); if (!currentMarker) { /** * Marker with provided name does not exist and will not be updated. * * @error writer-updatemarker-marker-not-exists */ throw new CKEditorError('writer-updatemarker-marker-not-exists', this); } if (!options) { /** * The usage of `writer.updateMarker()` only to reconvert (refresh) a * {@link module:engine/model/markercollection~Marker model marker} was deprecated and may not work in the future. * Please update your code to use * {@link module:engine/controller/editingcontroller~EditingController#reconvertMarker `editor.editing.reconvertMarker()`} * instead. * * @error writer-updatemarker-reconvert-using-editingcontroller * @param markerName The name of the updated marker. */ logWarning('writer-updatemarker-reconvert-using-editingcontroller', { markerName }); this.model.markers._refresh(currentMarker); return; } const hasUsingOperationDefined = typeof options.usingOperation == 'boolean'; const affectsDataDefined = typeof options.affectsData == 'boolean'; // Use previously defined marker's affectsData if the property is not provided. const affectsData = affectsDataDefined ? options.affectsData : currentMarker.affectsData; if (!hasUsingOperationDefined && !options.range && !affectsDataDefined) { /** * One of the options is required - provide range, usingOperations or affectsData. * * @error writer-updatemarker-wrong-options */ throw new CKEditorError('writer-updatemarker-wrong-options', this); } const currentRange = currentMarker.getRange(); const updatedRange = options.range ? options.range : currentRange; if (hasUsingOperationDefined && options.usingOperation !== currentMarker.managedUsingOperations) { // The marker type is changed so it's necessary to create proper operations. if (options.usingOperation) { // If marker changes to a managed one treat this as synchronizing existing marker. // Create `MarkerOperation` with `oldRange` set to `null`, so reverse operation will remove the marker. applyMarkerOperation(this, markerName, null, updatedRange, affectsData); } else { // If marker changes to a marker that do not use operations then we need to create additional operation // that removes that marker first. applyMarkerOperation(this, markerName, currentRange, null, affectsData); // Although not managed the marker itself should stay in model and its range should be preserver or changed to passed range. this.model.markers._set(markerName, updatedRange, undefined, affectsData); } return; } // Marker's type doesn't change so update it accordingly. if (currentMarker.managedUsingOperations) { applyMarkerOperation(this, markerName, currentRange, updatedRange, affectsData); } else { this.model.markers._set(markerName, updatedRange, undefined, affectsData); } } /** * Removes given {@link module:engine/model/markercollection~Marker marker} or marker with given name. * The marker is removed accordingly to how it has been created, so if the marker was created using operation, * it will be destroyed using operation. * * @param markerOrName Marker or marker name to remove. */ removeMarker(markerOrName) { this._assertWriterUsedCorrectly(); const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name; if (!this.model.markers.has(name)) { /** * Trying to remove marker which does not exist. * * @error writer-removemarker-no-marker */ throw new CKEditorError('writer-removemarker-no-marker', this); } const marker = this.model.markers.get(name); if (!marker.managedUsingOperations) { this.model.markers._remove(name); return; } const oldRange = marker.getRange(); applyMarkerOperation(this, name, oldRange, null, marker.affectsData); } /** * Adds a new root to the document (or re-attaches a {@link #detachRoot detached root}). * * Throws an error, if trying to add a root that is already added and attached. * * @param rootName Name of the added root. * @param elementName The element name. Defaults to `'$root'` which also has some basic schema defined * (e.g. `$block` elements are allowed inside the `$root`). Make sure to define a proper schema if you use a different name. * @returns The added root element. */ addRoot(rootName, elementName = '$root') { this._assertWriterUsedCorrectly(); const root = this.model.document.getRoot(rootName); if (root && root.isAttached()) { /** * Root with provided name already exists and is attached. * * @error writer-addroot-root-exists */ throw new CKEditorError('writer-addroot-root-exists', this); } const document = this.model.document; const operation = new RootOperation(rootName, elementName, true, document, document.version); this.batch.addOperation(operation); this.model.applyOperation(operation); return this.model.document.getRoot(rootName); } /** * Detaches the root from the document. * * All content and markers are removed from the root upon detaching. New content and new markers cannot be added to the root, as long * as it is detached. * * A root cannot be fully removed from the document, it can be only detached. A root is permanently removed only after you * re-initialize the editor and do not specify the root in the initial data. * * A detached root can be re-attached using {@link #addRoot}. * * Throws an error if the root does not exist or the root is already detached. * * @param rootOrName Name of the detached root. */ detachRoot(rootOrName) { this._assertWriterUsedCorrectly(); const root = typeof rootOrName == 'string' ? this.model.document.getRoot(rootOrName) : rootOrName; if (!root || !root.isAttached()) { /** * Root with provided name does not exist or is already detached. * * @error writer-detachroot-no-root */ throw new CKEditorError('writer-detachroot-no-root', this); } // First, remove all markers from the root. It is better to do it before removing stuff for undo purposes. // However, looking through all the markers may not be the best performance wise. But there's no better solution for now. for (const marker of this.model.markers) { if (marker.getRange().root === root) { this.removeMarker(marker); } } // Remove all attributes from the root. for (const key of root.getAttributeKeys()) { this.removeAttribute(key, root); } // Remove all contents of the root. this.remove(this.createRangeIn(root)); // Finally, detach the root. const document = this.model.document; const operation = new RootOperation(root.rootName, root.name, false, document, document.version); this.batch.addOperation(operation); this.model.applyOperation(operation); } setSelection(...args) { this._assertWriterUsedCorrectly(); this.model.document.selection._setTo(...args); } /** * Moves {@link module:engine/model/documentselection~ModelDocumentSelection#focus} to the specified location. * * The location can be specified in the same form as * {@link #createPositionAt `writer.createPositionAt()`} parameters. * * @param itemOrPosition * @param offset Offset or one of the flags. Used only when first parameter is a {@link module:engine/model/item~ModelItem model item}. */ setSelectionFocus(itemOrPosition, offset) { this._assertWriterUsedCorrectly(); this.model.document.selection._setFocus(itemOrPosition, offset); } setSelectionAttribute(keyOrObjectOrIterable, value) { this._assertWriterUsedCorrectly(); if (typeof keyOrObjectOrIterable === 'string') { this._setSelectionAttribute(keyOrObjectOrIterable, value); } else { for (const [key, value] of toMap(keyOrObjectOrIterable)) { this._setSelectionAttribute(key, value); } } } /** * Removes attribute(s) with given key(s) from the selection. * * Remove one attribute: * * ```ts * writer.removeSelectionAttribute( 'italic' ); * ``` * * Remove multiple attributes: * * ```ts * writer.removeSelectionAttribute( [ 'italic', 'bold' ] ); * ``` * * @param keyOrIterableOfKeys Key of the attribute to remove or an iterable of attribute keys to remove. */ removeSelectionAttribute(keyOrIterableOfKeys) { this._assertWriterUsedCorrectly(); if (typeof keyOrIterableOfKeys === 'string') { this._removeSelectionAttribute(keyOrIterableOfKeys); } else { for (const key of keyOrIterableOfKeys) { this._removeSelectionAttribute(key); } } } /** * Temporarily changes the {@link module:engine/model/documentselection~ModelDocumentSelection#isGravityOverridden gravity} * of the selection from left to right. * * The gravity defines from which direction the selection inherits its attributes. If it's the default left gravity, * then the selection (after being moved by the user) inherits attributes from its left-hand side. * This method allows to temporarily override this behavior by forcing the gravity to the right. * * For the following model fragment: * * ```xml * <$text bold="true" linkHref="url">bar[]</$text><$text bold="true">biz</$text> * ``` * * * Default gravity: selection will have the `bold` and `linkHref` attributes. * * Overridden gravity: selection will have `bold` attribute. * * **Note**: It returns an unique identifier which is required to restore the gravity. It guarantees the symmetry * of the process. * * @returns The unique id which allows restoring the gravity. */ overrideSelectionGravity() { return this.model.document.selection._overrideGravity(); } /** * Restores {@link ~ModelWriter#overrideSelectionGravity} gravity to default. * * Restoring the gravity is only possible using the unique identifier returned by * {@link ~ModelWriter#overrideSelectionGravity}. Note that the gravity remains overridden as long as won't be restored * the same number of times it was overridden. * * @param uid The unique id returned by {@link ~ModelWriter#overrideSelectionGravity}. */ restoreSelectionGravity(uid) { this.model.document.selection._restoreGravity(uid); } /** * @param key Key of the attribute to remove. * @param value Attribute value. */ _setSelectionAttribute(key, value) { const selection = this.model.document.selection; // Store attribute in parent element if the selection is collapsed in an empty node. if (selection.isCollapsed && selection.anchor.parent.isEmpty) { const storeKey = ModelDocumentSelection._getStoreAttributeKey(key); this.setAttribute(storeKey, value, selection.anchor.parent); } selection._setAttribute(key, value); } /** * @param key Key of the attribute to remove. */ _removeSelectionAttribute(key) { const selection = this.model.document.selection; // Remove stored attribute from parent element if the selection is collapsed in an empty node. if (selection.isCollapsed && selection.anchor.parent.isEmpty) { const storeKey = ModelDocumentSelection._getStoreAttributeKey(key); this.removeAttribute(storeKey, selection.anchor.parent); } selection._removeAttribute(key); } /** * Throws `writer-detached-writer-tries-to-modify-model` error when the writer is used outside of the `change()` block. */ _assertWriterUsedCorrectly() { /** * Trying to use a writer outside a {@link module:engine/model/model~Model#change `change()`} or * {@link module:engine/model/model~Model#enqueueChange `enqueueChange()`} blocks. *