UNPKG

@ckeditor/ckeditor5-engine

Version:

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

437 lines (436 loc) • 18.9 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/markercollection */ import { ModelTypeCheckable } from './typecheckable.js'; import { ModelLiveRange } from './liverange.js'; import { CKEditorError, EmitterMixin } from '@ckeditor/ckeditor5-utils'; /** * The collection of all {@link module:engine/model/markercollection~Marker markers} attached to the document. * It lets you {@link module:engine/model/markercollection~MarkerCollection#get get} markers or track them using * {@link module:engine/model/markercollection~MarkerCollection#event:update} event. * * To create, change or remove makers use {@link module:engine/model/writer~ModelWriter model writers'} methods: * {@link module:engine/model/writer~ModelWriter#addMarker} or {@link module:engine/model/writer~ModelWriter#removeMarker}. Since * the writer is the only proper way to change the data model it is not possible to change markers directly using this * collection. All markers created by the writer will be automatically added to this collection. * * By default there is one marker collection available as {@link module:engine/model/model~Model#markers model property}. * * @see module:engine/model/markercollection~Marker */ export class MarkerCollection extends /* #__PURE__ */ EmitterMixin() { /** * Stores {@link ~Marker markers} added to the collection. */ _markers = new Map(); /** * Iterable interface. * * Iterates over all {@link ~Marker markers} added to the collection. */ [Symbol.iterator]() { return this._markers.values(); } /** * Checks if given {@link ~Marker marker} or marker name is in the collection. * * @param markerOrName Name of marker or marker instance to check. * @returns `true` if marker is in the collection, `false` otherwise. */ has(markerOrName) { const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName; return this._markers.has(markerName); } /** * Returns {@link ~Marker marker} with given `markerName`. * * @param markerName Name of marker to get. * @returns Marker with given name or `null` if such marker was * not added to the collection. */ get(markerName) { return this._markers.get(markerName) || null; } /** * Creates and adds a {@link ~Marker marker} to the `MarkerCollection` with given name on given * {@link module:engine/model/range~ModelRange range}. * * If `MarkerCollection` already had a marker with given name (or {@link ~Marker marker} was passed), the marker in * collection is updated and {@link module:engine/model/markercollection~MarkerCollection#event:update} event is fired * but only if there was a change (marker range or {@link module:engine/model/markercollection~Marker#managedUsingOperations} * flag has changed. * * @internal * @fires update * @param markerOrName Name of marker to set or marker instance to update. * @param range Marker range. * @param managedUsingOperations Specifies whether the marker is managed using operations. * @param affectsData Specifies whether the marker affects the data produced by the data pipeline * (is persisted in the editor's data). * @returns `Marker` instance which was added or updated. */ _set(markerOrName, range, managedUsingOperations = false, affectsData = false) { const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName; if (markerName.includes(',')) { /** * Marker name cannot contain the "," character. * * @error markercollection-incorrect-marker-name */ throw new CKEditorError('markercollection-incorrect-marker-name', this); } const oldMarker = this._markers.get(markerName); if (oldMarker) { const oldMarkerData = oldMarker.getData(); const oldRange = oldMarker.getRange(); let hasChanged = false; if (!oldRange.isEqual(range)) { oldMarker._attachLiveRange(ModelLiveRange.fromRange(range)); hasChanged = true; } if (managedUsingOperations != oldMarker.managedUsingOperations) { oldMarker._managedUsingOperations = managedUsingOperations; hasChanged = true; } if (typeof affectsData === 'boolean' && affectsData != oldMarker.affectsData) { oldMarker._affectsData = affectsData; hasChanged = true; } if (hasChanged) { this.fire(`update:${markerName}`, oldMarker, oldRange, range, oldMarkerData); } return oldMarker; } const liveRange = ModelLiveRange.fromRange(range); const marker = new Marker(markerName, liveRange, managedUsingOperations, affectsData); this._markers.set(markerName, marker); this.fire(`update:${markerName}`, marker, null, range, { ...marker.getData(), range: null }); return marker; } /** * Removes given {@link ~Marker marker} or a marker with given name from the `MarkerCollection`. * * @internal * @fires update * @param markerOrName Marker or name of a marker to remove. * @returns `true` if marker was found and removed, `false` otherwise. */ _remove(markerOrName) { const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName; const oldMarker = this._markers.get(markerName); if (oldMarker) { this._markers.delete(markerName); this.fire(`update:${markerName}`, oldMarker, oldMarker.getRange(), null, oldMarker.getData()); this._destroyMarker(oldMarker); return true; } return false; } /** * Fires an {@link module:engine/model/markercollection~MarkerCollection#event:update} event for the given {@link ~Marker marker} * but does not change the marker. Useful to force {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast * conversion} for the marker. * * @internal * @fires update * @param markerOrName Marker or name of a marker to refresh. */ _refresh(markerOrName) { const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName; const marker = this._markers.get(markerName); if (!marker) { /** * Marker with provided name does not exists. * * @error markercollection-refresh-marker-not-exists */ throw new CKEditorError('markercollection-refresh-marker-not-exists', this); } const range = marker.getRange(); this.fire(`update:${markerName}`, marker, range, range, marker.getData()); } /** * Returns iterator that iterates over all markers, which ranges * contain given {@link module:engine/model/position~ModelPosition position}. */ *getMarkersAtPosition(position) { for (const marker of this) { if (marker.getRange().containsPosition(position)) { yield marker; } } } /** * Returns iterator that iterates over all markers, which intersects with given {@link module:engine/model/range~ModelRange range}. */ *getMarkersIntersectingRange(range) { for (const marker of this) { if (marker.getRange().getIntersection(range) !== null) { yield marker; } } } /** * Destroys marker collection and all markers inside it. */ destroy() { for (const marker of this._markers.values()) { this._destroyMarker(marker); } this._markers = null; this.stopListening(); } /** * Iterates over all markers that starts with given `prefix`. * * ```ts * const markerFooA = markersCollection._set( 'foo:a', rangeFooA ); * const markerFooB = markersCollection._set( 'foo:b', rangeFooB ); * const markerBarA = markersCollection._set( 'bar:a', rangeBarA ); * const markerFooBarA = markersCollection._set( 'foobar:a', rangeFooBarA ); * Array.from( markersCollection.getMarkersGroup( 'foo' ) ); // [ markerFooA, markerFooB ] * Array.from( markersCollection.getMarkersGroup( 'a' ) ); // [] * ``` */ *getMarkersGroup(prefix) { for (const marker of this._markers.values()) { if (marker.name.startsWith(prefix + ':')) { yield marker; } } } /** * Destroys the marker. */ _destroyMarker(marker) { marker.stopListening(); marker._detachLiveRange(); } } /** * `Marker` is a continuous part of the model (like a range), is named and represents some kind of information about the * marked part of the model document. In contrary to {@link module:engine/model/node~ModelNode nodes}, which are building blocks of * the model document tree, markers are not stored directly in the document tree but in the * {@link module:engine/model/model~Model#markers model markers' collection}. Still, they are document data, by giving * additional meaning to the part of a model document between marker start and marker end. * * In this sense, markers are similar to adding and converting attributes on nodes. The difference is that attribute is * connected with a given node (e.g. a character is bold no matter if it gets moved or content around it changes). * Markers on the other hand are continuous ranges and are characterized by their start and end position. This means that * any character in the marker is marked by the marker. For example, if a character is moved outside of marker it stops being * "special" and the marker is shrunk. Similarly, when a character is moved into the marker from other place in document * model, it starts being "special" and the marker is enlarged. * * Another upside of markers is that finding marked part of document is fast and easy. Using attributes to mark some nodes * and then trying to find that part of document would require traversing whole document tree. Marker gives instant access * to the range which it is marking at the moment. * * Markers are built from a name and a range. * * Range of the marker is updated automatically when document changes, using * {@link module:engine/model/liverange~ModelLiveRange live range} mechanism. * * Name is used to group and identify markers. Names have to be unique, but markers can be grouped by * using common prefixes, separated with `:`, for example: `user:john` or `search:3`. That's useful in term of creating * namespaces for custom elements (e.g. comments, highlights). You can use this prefixes in * {@link module:engine/model/markercollection~MarkerCollection#event:update} listeners to listen on changes in a group of markers. * For instance: `model.markers.on( 'update:user', callback );` will be called whenever any `user:*` markers changes. * * There are two types of markers. * * 1. Markers managed directly, without using operations. They are added directly by {@link module:engine/model/writer~ModelWriter} * to the {@link module:engine/model/markercollection~MarkerCollection} without any additional mechanism. They can be used * as bookmarks or visual markers. They are great for showing results of the find, or select link when the focus is in the input. * * 1. Markers managed using operations. These markers are also stored in {@link module:engine/model/markercollection~MarkerCollection} * but changes in these markers is managed the same way all other changes in the model structure - using operations. * Therefore, they are handled in the undo stack and synchronized between clients if the collaboration plugin is enabled. * This type of markers is useful for solutions like spell checking or comments. * * Both type of them should be added / updated by {@link module:engine/model/writer~ModelWriter#addMarker} * and removed by {@link module:engine/model/writer~ModelWriter#removeMarker} methods. * * ```ts * model.change( ( writer ) => { * const marker = writer.addMarker( name, { range, usingOperation: true } ); * * // ... * * writer.removeMarker( marker ); * } ); * ``` * * See {@link module:engine/model/writer~ModelWriter} to find more examples. * * Since markers need to track change in the document, for efficiency reasons, it is best to create and keep as little * markers as possible and remove them as soon as they are not needed anymore. * * Markers can be downcasted and upcasted. * * Markers downcast happens on {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} and * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker} events. * Use {@link module:engine/conversion/downcasthelpers downcast converters} or attach a custom converter to mentioned events. * For {@link module:engine/controller/datacontroller~DataController data pipeline}, marker should be downcasted to an element. * Then, it can be upcasted back to a marker. Again, use {@link module:engine/conversion/upcasthelpers upcast converters} or * attach a custom converter to {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element}. * * `Marker` instances are created and destroyed only by {@link ~MarkerCollection MarkerCollection}. */ class Marker extends /* #__PURE__ */ EmitterMixin(ModelTypeCheckable) { /** * Marker's name. */ name; /** * Flag indicates if the marker is managed using operations or not. * * @internal */ _managedUsingOperations; /** * Specifies whether the marker affects the data produced by the data pipeline * (is persisted in the editor's data). * * @internal */ _affectsData; /** * Range marked by the marker. */ _liveRange; /** * Creates a marker instance. * * @param name Marker name. * @param liveRange Range marked by the marker. * @param managedUsingOperations Specifies whether the marker is managed using operations. * @param affectsData Specifies whether the marker affects the data produced by the data pipeline (is persisted in the editor's data). */ constructor(name, liveRange, managedUsingOperations, affectsData) { super(); this.name = name; this._liveRange = this._attachLiveRange(liveRange); this._managedUsingOperations = managedUsingOperations; this._affectsData = affectsData; } /** * A value indicating if the marker is managed using operations. * See {@link ~Marker marker class description} to learn more about marker types. * See {@link module:engine/model/writer~ModelWriter#addMarker}. */ get managedUsingOperations() { if (!this._liveRange) { throw new CKEditorError('marker-destroyed', this); } return this._managedUsingOperations; } /** * A value indicating if the marker changes the data. */ get affectsData() { if (!this._liveRange) { throw new CKEditorError('marker-destroyed', this); } return this._affectsData; } /** * Returns the marker data (properties defining the marker). */ getData() { return { range: this.getRange(), affectsData: this.affectsData, managedUsingOperations: this.managedUsingOperations }; } /** * Returns current marker start position. */ getStart() { if (!this._liveRange) { throw new CKEditorError('marker-destroyed', this); } return this._liveRange.start.clone(); } /** * Returns current marker end position. */ getEnd() { if (!this._liveRange) { throw new CKEditorError('marker-destroyed', this); } return this._liveRange.end.clone(); } /** * Returns a range that represents the current state of the marker. * * Keep in mind that returned value is a {@link module:engine/model/range~ModelRange Range}, not a * {@link module:engine/model/liverange~ModelLiveRange ModelLiveRange}. This means that it is up-to-date and relevant only * until next model document change. Do not store values returned by this method. Instead, store {@link ~Marker#name} * and get `Marker` instance from {@link module:engine/model/markercollection~MarkerCollection MarkerCollection} every * time there is a need to read marker properties. This will guarantee that the marker has not been removed and * that it's data is up-to-date. */ getRange() { if (!this._liveRange) { throw new CKEditorError('marker-destroyed', this); } return this._liveRange.toRange(); } /** * Converts `Marker` to plain object and returns it. * * @returns `Marker` converted to plain object. */ toJSON() { return { name: this.name, range: this._liveRange?.toJSON(), usingOperations: this._managedUsingOperations, affectsData: this._affectsData }; } /** * Binds new live range to the marker and detach the old one if is attached. * * @internal * @param liveRange Live range to attach * @returns Attached live range. */ _attachLiveRange(liveRange) { if (this._liveRange) { this._detachLiveRange(); } // Delegating does not work with namespaces. Alternatively, we could delegate all events (using `*`). liveRange.delegate('change:range').to(this); liveRange.delegate('change:content').to(this); this._liveRange = liveRange; return liveRange; } /** * Unbinds and destroys currently attached live range. * * @internal */ _detachLiveRange() { this._liveRange.stopDelegating('change:range', this); this._liveRange.stopDelegating('change:content', this); this._liveRange.detach(); this._liveRange = null; } } // The magic of type inference using `is` method is centralized in `TypeCheckable` class. // Proper overload would interfere with that. Marker.prototype.is = function (type) { return type === 'marker' || type === 'model:marker'; }; export { Marker }; /** * Cannot use a {@link module:engine/model/markercollection~MarkerCollection#destroy destroyed marker} instance. * * @error marker-destroyed */