/** * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module engine/model/model */ import Batch from './batch'; import Writer from './writer'; import Schema from './schema'; import Document from './document'; import MarkerCollection from './markercollection'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import ModelElement from './element'; import ModelRange from './range'; import insertContent from './utils/insertcontent'; import deleteContent from './utils/deletecontent'; import modifySelection from './utils/modifyselection'; import getSelectedContent from './utils/getselectedcontent'; import { injectSelectionPostFixer } from './utils/selection-post-fixer'; /** * Editor's data model. Read about the model in the * {@glink framework/guides/architecture/editing-engine engine architecture guide}. * * @mixes module:utils/observablemixin~ObservableMixin */ export default class Model { constructor() { /** * Model's marker collection. * * @readonly * @member {module:engine/model/markercollection~MarkerCollection} */ this.markers = new MarkerCollection(); /** * Model's document. * * @readonly * @member {module:engine/model/document~Document} */ this.document = new Document( this ); /** * Model's schema. * * @readonly * @member {module:engine/model/schema~Schema} */ this.schema = new Schema(); /** * All callbacks added by {@link module:engine/model/model~Model#change} or * {@link module:engine/model/model~Model#enqueueChange} methods waiting to be executed. * * @private * @type {Array.} */ this._pendingChanges = []; /** * The last created and currently used writer instance. * * @private * @member {module:engine/model/writer~Writer} */ this._currentWriter = null; [ 'insertContent', 'deleteContent', 'modifySelection', 'getSelectedContent', 'applyOperation' ] .forEach( methodName => this.decorate( methodName ) ); // Adding operation validation with `highest` priority, so it is called before any other feature would like // to do anything with the operation. If the operation has incorrect parameters it should throw on the earliest occasion. this.on( 'applyOperation', ( evt, args ) => { const operation = args[ 0 ]; operation._validate(); }, { priority: 'highest' } ); // Register some default abstract entities. this.schema.register( '$root', { isLimit: true } ); this.schema.register( '$block', { allowIn: '$root', isBlock: true } ); this.schema.register( '$text', { allowIn: '$block' } ); this.schema.register( '$clipboardHolder', { allowContentOf: '$root', isLimit: true } ); this.schema.extend( '$text', { allowIn: '$clipboardHolder' } ); // Element needed by `upcastElementToMarker` converter. // This element temporarily represents marker bound during conversion process and is removed // at the end of conversion. `UpcastDispatcher` or at least `Conversion` class looks like a better for this // registration but both know nothing about Schema. this.schema.register( '$marker', { allowIn: [ '$root', '$block' ] } ); injectSelectionPostFixer( this ); } /** * The `change()` method is the primary way of changing the model. You should use it to modify all document nodes * (including detached nodes – i.e. nodes not added to the {@link module:engine/model/model~Model#document model document}), * the {@link module:engine/model/document~Document#selection document's selection}, and * {@link module:engine/model/model~Model#markers model markers}. * * model.change( writer => { * writer.insertText( 'foo', paragraph, 'end' ); * } ); * * All changes inside the change block use the same {@link module:engine/model/batch~Batch} so they are combined * into a single undo step. * * model.change( writer => { * writer.insertText( 'foo', paragraph, 'end' ); // foo. * * model.change( writer => { * writer.insertText( 'bar', paragraph, 'end' ); // foobar. * } ); * * writer.insertText( 'bom', paragraph, 'end' ); // foobarbom. * } ); * * The callback of the `change()` block is executed synchronously. * * You can also return a value from the change block. * * const img = model.change( writer => { * return writer.createElement( 'img' ); * } ); * * @see #enqueueChange * @param {Function} callback Callback function which may modify the model. * @returns {*} Value returned by the callback. */ change( callback ) { if ( this._pendingChanges.length === 0 ) { // If this is the outermost block, create a new batch and start `_runPendingChanges` execution flow. this._pendingChanges.push( { batch: new Batch(), callback } ); return this._runPendingChanges()[ 0 ]; } else { // If this is not the outermost block, just execute the callback. return callback( this._currentWriter ); } } /** * The `enqueueChange()` method performs similar task as the {@link #change `change()` method}, with two major differences. * * First, the callback of `enqueueChange()` is executed when all other enqueued changes are done. It might be executed * immediately if it is not nested in any other change block, but if it is nested in another (enqueue)change block, * it will be delayed and executed after the outermost block. * * model.change( writer => { * console.log( 1 ); * * model.enqueueChange( writer => { * console.log( 2 ); * } ); * * console.log( 3 ); * } ); // Will log: 1, 3, 2. * * Second, it lets you define the {@link module:engine/model/batch~Batch} into which you want to add your changes. * By default, a new batch is created. In the sample above, `change` and `enqueueChange` blocks use a different * batch (and different {@link module:engine/model/writer~Writer} since each of them operates on the separate batch). * * When using the `enqueueChange()` block you can also add some changes to the batch you used before. * * model.enqueueChange( batch, writer => { * writer.insertText( 'foo', paragraph, 'end' ); * } ); * * The batch instance can be obtained from {@link module:engine/model/writer~Writer#batch the writer}. * * @param {module:engine/model/batch~Batch|'transparent'|'default'} batchOrType Batch or batch type should be used in the callback. * If not defined, a new batch will be created. * @param {Function} callback Callback function which may modify the model. */ enqueueChange( batchOrType, callback ) { if ( typeof batchOrType === 'string' ) { batchOrType = new Batch( batchOrType ); } else if ( typeof batchOrType == 'function' ) { callback = batchOrType; batchOrType = new Batch(); } this._pendingChanges.push( { batch: batchOrType, callback } ); if ( this._pendingChanges.length == 1 ) { this._runPendingChanges(); } } /** * {@link module:utils/observablemixin~ObservableMixin#decorate Decorated} function for applying * {@link module:engine/model/operation/operation~Operation operations} to the model. * * This is a low-level way of changing the model. It is exposed for very specific use cases (like the undo feature). * Normally, to modify the model, you will want to use {@link module:engine/model/writer~Writer `Writer`}. * See also {@glink framework/guides/architecture/editing-engine#changing-the-model Changing the model} section * of the {@glink framework/guides/architecture/editing-engine Editing architecture} guide. * * @param {module:engine/model/operation/operation~Operation} operation The operation to apply. */ applyOperation( operation ) { operation._execute(); } /** * Inserts content into the editor (specified selection) as one would expect the paste * functionality to work. * * This is a high-level method. It takes the {@link #schema schema} into consideration when inserting * the content, clears the given selection's content before inserting nodes and moves the selection * to its target position at the end of the process. * It can split elements, merge them, wrap bare text nodes in paragraphs, etc. – just like the * pasting feature should do. * * For lower-level methods see {@link module:engine/model/writer~Writer `Writer`}. * * This method, unlike {@link module:engine/model/writer~Writer `Writer`}'s methods, does not have to be used * inside a {@link #change `change()` block}. * * # Conversion and schema * * Inserting elements and text nodes into the model is not enough to make CKEditor 5 render that content * to the user. CKEditor 5 implements a model-view-controller architecture and what `model.insertContent()` does * is only adding nodes to the model. Additionally, you need to define * {@glink framework/guides/architecture/editing-engine#conversion converters} between the model and view * and define those nodes in the {@glink framework/guides/architecture/editing-engine#schema schema}. * * So, while this method may seem similar to CKEditor 4's `editor.insertHtml()` (in fact, both methods * are used for paste-like content insertion), CKEditor 5's method cannot be use to insert arbitrary HTML * unless converters are defined for all elements and attributes in that HTML. * * # Examples * * Using `insertContent()` with a manually created model structure: * * // Let's create a document fragment containing such a content: * // * // foo * //
* // bar * //
* const docFrag = editor.model.change( writer => { * const p1 = writer.createElement( 'paragraph' ); * const p2 = writer.createElement( 'paragraph' ); * const blockQuote = writer.createElement( 'blockQuote' ); * const docFrag = writer.createDocumentFragment(); * * writer.append( p1, docFrag ); * writer.append( blockQuote, docFrag ); * writer.append( p2, blockQuote ); * writer.insertText( 'foo', p1 ); * writer.insertText( 'bar', p2 ); * * return docFrag; * } ); * * // insertContent() doesn't have to be used in a change() block. It can, though, * // so this code could be moved to the callback defined above. * editor.model.insertContent( docFrag ); * * Using `insertContent()` with HTML string converted to a model document fragment (similar to the pasting mechanism): * * // You can create your own HtmlDataProcessor instance or use editor.data.processor * // if you haven't overridden the default one (which is HtmlDataProcessor instance). * const htmlDP = new HtmlDataProcessor(); * * // Convert an HTML string to a view document fragment. * const viewFragment = htmlDP.toView( htmlString ); * * // Convert a view document fragment to a model document fragment * // in the context of $root. This conversion takes schema into * // the account so if e.g. the view document fragment contained a bare text node * // then that text node cannot be a child of $root, so it will be automatically * // wrapped with a . You can define the context yourself (in the 2nd parameter), * // and e.g. convert the content like it would happen in a . * // Note: the clipboard feature uses a custom context called $clipboardHolder * // which has a loosened schema. * const modelFragment = editor.data.toModel( viewFragment ); * * editor.model.insertContent( modelFragment ); * * By default this method will use the document selection but it can also be used with a position, range or selection instance. * * // Insert text at the current document selection position. * editor.model.change( writer => { * editor.model.insertContent( writer.createText( 'x' ) ); * } ); * * // Insert text at given position - document selection will not be modified. * editor.model.change( writer => { * editor.model.insertContent( writer.createText( 'x' ), Position.createAt( doc.getRoot(), 2 ) ); * } ); * * If an instance of {@link module:engine/model/selection~Selection} is passed as `selectable` * it will be moved to the target position (where the document selection should be moved after the insertion). * * // Insert text replacing given selection instance. * const selection = new Selection( paragraph, 'in' ); * * editor.model.change( writer => { * editor.model.insertContent( writer.createText( 'x' ), selection ); * * // insertContent() modifies the passed selection instance so it can be used to set the document selection. * // Note: This is not necessary when you passed document selection to insertContent(). * writer.setSelection( selection ); * } ); * * @fires insertContent * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert. * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection| * module:engine/model/position~Position|module:engine/model/element~Element| * Iterable.|module:engine/model/range~Range|null} [selectable=model.document.selection] * Selection into which the content should be inserted. If not provided the current model document selection will be used. */ insertContent( content, selectable ) { insertContent( this, content, selectable ); } /** * Deletes content of the selection and merge siblings. The resulting selection is always collapsed. * * **Note:** For the sake of predictability, the resulting selection should always be collapsed. * In cases where a feature wants to modify deleting behavior so selection isn't collapsed * (e.g. a table feature may want to keep row selection after pressing Backspace), * then that behavior should be implemented in the view's listener. At the same time, the table feature * will need to modify this method's behavior too, e.g. to "delete contents and then collapse * the selection inside the last selected cell" or "delete the row and collapse selection somewhere near". * That needs to be done in order to ensure that other features which use `deleteContent()` will work well with tables. * * @fires deleteContent * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection * Selection of which the content should be deleted. * @param {Object} [options] * @param {Boolean} [options.leaveUnmerged=false] Whether to merge elements after removing the content of the selection. * * For example `x[xy]y` will become: * * * `x^y` with the option disabled (`leaveUnmerged == false`) * * `x^y` with enabled (`leaveUnmerged == true`). * * Note: {@link module:engine/model/schema~Schema#isObject object} and {@link module:engine/model/schema~Schema#isLimit limit} * elements will not be merged. * * @param {Boolean} [options.doNotResetEntireContent=false] Whether to skip replacing the entire content with a * paragraph when the entire content was selected. * * For example `[xy]` will become: * * * `^` with the option disabled (`doNotResetEntireContent == false`) * * `^` with enabled (`doNotResetEntireContent == true`) */ deleteContent( selection, options ) { deleteContent( this, selection, options ); } /** * Modifies the selection. Currently, the supported modifications are: * * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`. * Possible values for `unit` are: * * `'character'` (default) - moves selection by one user-perceived character. In most cases this means moving by one * character in `String` sense. However, unicode also defines "combing marks". These are special symbols, that combines * with a symbol before it ("base character") to create one user-perceived character. For example, `q̣̇` is a normal * letter `q` with two "combining marks": upper dot (`Ux0307`) and lower dot (`Ux0323`). For most actions, i.e. extending * selection by one position, it is correct to include both "base character" and all of it's "combining marks". That is * why `'character'` value is most natural and common method of modifying selection. * * `'codePoint'` - moves selection by one unicode code point. In contrary to, `'character'` unit, this will insert * selection between "base character" and "combining mark", because "combining marks" have their own unicode code points. * However, for technical reasons, unicode code points with values above `UxFFFF` are represented in native `String` by * two characters, called "surrogate pairs". Halves of "surrogate pairs" have a meaning only when placed next to each other. * For example `𨭎` is represented in `String` by `\uD862\uDF4E`. Both `\uD862` and `\uDF4E` do not have any meaning * outside the pair (are rendered as ? when alone). Position between them would be incorrect. In this case, selection * extension will include whole "surrogate pair". * * `'word'` - moves selection by a whole word. * * **Note:** if you extend a forward selection in a backward direction you will in fact shrink it. * * @fires modifySelection * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection * The selection to modify. * @param {Object} [options] * @param {'forward'|'backward'} [options.direction='forward'] The direction in which the selection should be modified. * @param {'character'|'codePoint'|'word'} [options.unit='character'] The unit by which selection should be modified. */ modifySelection( selection, options ) { modifySelection( this, selection, options ); } /** * Gets a clone of the selected content. * * For example, for the following selection: * * ```html * x *
* y * fir[st *
* se]cond * z * ``` * * It will return a document fragment with such a content: * * ```html *
* st *
* se * ``` * * @fires getSelectedContent * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection * The selection of which content will be returned. * @returns {module:engine/model/documentfragment~DocumentFragment} */ getSelectedContent( selection ) { return getSelectedContent( this, selection ); } /** * Checks whether given {@link module:engine/model/range~Range range} or {@link module:engine/model/element~Element element} * has any content. * * Content is any text node or element which is registered in {@link module:engine/model/schema~Schema schema}. * * @param {module:engine/model/range~Range|module:engine/model/element~Element} rangeOrElement Range or element to check. * @returns {Boolean} */ hasContent( rangeOrElement ) { if ( rangeOrElement instanceof ModelElement ) { rangeOrElement = ModelRange.createIn( rangeOrElement ); } if ( rangeOrElement.isCollapsed ) { return false; } for ( const item of rangeOrElement.getItems() ) { // Remember, `TreeWalker` returns always `textProxy` nodes. if ( item.is( 'textProxy' ) || this.schema.isObject( item ) ) { return true; } } return false; } /** * Removes all events listeners set by model instance and destroys {@link module:engine/model/document~Document}. */ destroy() { this.document.destroy(); this.stopListening(); } /** * Common part of {@link module:engine/model/model~Model#change} and {@link module:engine/model/model~Model#enqueueChange} * which calls callbacks and returns array of values returned by these callbacks. * * @private * @returns {Array.<*>} Array of values returned by callbacks. */ _runPendingChanges() { const ret = []; this.fire( '_beforeChanges' ); while ( this._pendingChanges.length ) { // Create a new writer using batch instance created for this chain of changes. const currentBatch = this._pendingChanges[ 0 ].batch; this._currentWriter = new Writer( this, currentBatch ); // Execute changes callback and gather the returned value. const callbackReturnValue = this._pendingChanges[ 0 ].callback( this._currentWriter ); ret.push( callbackReturnValue ); // Fire internal `_change` event. this.fire( '_change', this._currentWriter ); this._pendingChanges.shift(); this._currentWriter = null; } this.fire( '_afterChanges' ); return ret; } /** * Fired after leaving each {@link module:engine/model/model~Model#enqueueChange} block or outermost * {@link module:engine/model/model~Model#change} block. * * **Note:** This is an internal event! Use {@link module:engine/model/document~Document#event:change} instead. * * @protected * @event _change * @param {module:engine/model/writer~Writer} writer `Writer` instance that has been used in the change block. */ /** * Fired when entering the outermost {@link module:engine/model/model~Model#enqueueChange} or * {@link module:engine/model/model~Model#change} block. * * @protected * @event _beforeChanges */ /** * Fired when leaving the outermost {@link module:engine/model/model~Model#enqueueChange} or * {@link module:engine/model/model~Model#change} block. * * @protected * @event _afterChanges */ /** * Fired every time any {@link module:engine/model/operation/operation~Operation operation} is applied on the model * using {@link #applyOperation}. * * Note that this event is suitable only for very specific use-cases. Use it if you need to listen to every single operation * applied on the document. However, in most cases {@link module:engine/model/document~Document#event:change} should * be used. * * A few callbacks are already added to this event by engine internal classes: * * * with `highest` priority operation is validated, * * with `normal` priority operation is executed, * * with `low` priority the {@link module:engine/model/document~Document} updates its version, * * with `low` priority {@link module:engine/model/liveposition~LivePosition} and {@link module:engine/model/liverange~LiveRange} * update themselves. * * @event applyOperation * @param {Array} args Arguments of the `applyOperation` which is an array with a single element - applied * {@link module:engine/model/operation/operation~Operation operation}. */ /** * Event fired when {@link #insertContent} method is called. * * The {@link #insertContent default action of that method} is implemented as a * listener to this event so it can be fully customized by the features. * * **Note** The `selectable` parameter for the {@link #insertContent} is optional. When `undefined` value is passed the method uses * `model.document.selection`. * * @event insertContent * @param {Array} args The arguments passed to the original method. */ /** * Event fired when {@link #deleteContent} method is called. * * The {@link #deleteContent default action of that method} is implemented as a * listener to this event so it can be fully customized by the features. * * @event deleteContent * @param {Array} args The arguments passed to the original method. */ /** * Event fired when {@link #modifySelection} method is called. * * The {@link #modifySelection default action of that method} is implemented as a * listener to this event so it can be fully customized by the features. * * @event modifySelection * @param {Array} args The arguments passed to the original method. */ /** * Event fired when {@link #getSelectedContent} method is called. * * The {@link #getSelectedContent default action of that method} is implemented as a * listener to this event so it can be fully customized by the features. * * @event getSelectedContent * @param {Array} args The arguments passed to the original method. */ } mix( Model, ObservableMixin );