| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444 |
- /**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
- // Load all basic deltas and transformations, they register themselves, but they need to be imported somewhere.
- import deltas from './delta/basic-deltas.js'; // jshint ignore:line
- import transformations from './delta/basic-transformations.js'; // jshint ignore:line
- import Range from './range.js';
- import Position from './position.js';
- import RootElement from './rootelement.js';
- import Batch from './batch.js';
- import History from './history.js';
- import LiveSelection from './liveselection.js';
- import Schema from './schema.js';
- import TreeWalker from './treewalker.js';
- import clone from '../../utils/lib/lodash/clone.js';
- import EmitterMixin from '../../utils/emittermixin.js';
- import CKEditorError from '../../utils/ckeditorerror.js';
- import mix from '../../utils/mix.js';
- import { isInsideSurrogatePair, isInsideCombinedSymbol } from '../../utils/unicode.js';
- const graveyardName = '$graveyard';
- /**
- * Document tree model describes all editable data in the editor. It may contain multiple
- * {@link engine.model.Document#roots root elements}, for example if the editor have multiple editable areas, each area will be
- * represented by the separate root.
- *
- * All changes in the document are done by {@link engine.model.operation.Operation operations}. To create operations in
- * a simple way, use the {@link engine.model.Batch} API, for example:
- *
- * doc.batch().insert( position, nodes ).split( otherPosition );
- *
- * @see engine.model.Document#batch
- *
- * @memberOf engine.model
- */
- export default class Document {
- /**
- * Creates an empty document instance with no {@link engine.model.Document#roots} (other than
- * a {@link engine.model.Document#graveyard graveyard root}).
- */
- constructor() {
- /**
- * Document version. It starts from `0` and every operation increases the version number. It is used to ensure that
- * operations are applied on the proper document version. If the {@link engine.model.operation.Operation#baseVersion} will
- * not match document version the {@link document-applyOperation-wrong-version} error is thrown.
- *
- * @readonly
- * @member {Number} engine.model.Document#version
- */
- this.version = 0;
- /**
- * Selection done on this document.
- *
- * @readonly
- * @member {engine.model.LiveSelection} engine.model.Document#selection
- */
- this.selection = new LiveSelection( this );
- /**
- * Schema for this document.
- *
- * @member {engine.model.Schema} engine.model.Document#schema
- */
- this.schema = new Schema();
- /**
- * Document's history.
- *
- * **Note:** Be aware that deltas applied to the stored deltas might be removed or changed.
- *
- * @readonly
- * @member {engine.model.History} engine.model.Document#history
- */
- this.history = new History( this );
- /**
- * Array of pending changes. See: {@link engine.model.Document#enqueueChanges}.
- *
- * @private
- * @member {Array.<Function>} engine.model.Document#_pendingChanges
- */
- this._pendingChanges = [];
- /**
- * List of roots that are owned and managed by this document. Use {@link engine.model.document#createRoot} and
- * {@link engine.model.document#getRoot} to manipulate it.
- *
- * @readonly
- * @protected
- * @member {Map} engine.model.Document#roots
- */
- this._roots = new Map();
- // Add events that will ensure selection correctness.
- this.selection.on( 'change:range', () => {
- for ( let range of this.selection.getRanges() ) {
- if ( !this._validateSelectionRange( range ) ) {
- /**
- * Range from document selection starts or ends at incorrect position.
- *
- * @error document-selection-wrong-position
- * @param {engine.model.Range} range
- */
- throw new CKEditorError( 'document-selection-wrong-position: ' +
- 'Range from document selection starts or ends at incorrect position.', { range } );
- }
- }
- } );
- // Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
- this.createRoot( '$root', graveyardName );
- }
- /**
- * Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
- *
- * @readonly
- * @type {engine.model.RootElement}
- */
- get graveyard() {
- return this.getRoot( graveyardName );
- }
- /**
- * This is the entry point for all document changes. All changes on the document are done using
- * {@link engine.model.operation.Operation operations}. To create operations in the simple way use the
- * {@link engine.model.Batch} API available via {@link engine.model.Document#batch} method.
- *
- * @fires @link engine.model.Document#change
- * @param {engine.model.operation.Operation} operation Operation to be applied.
- */
- applyOperation( operation ) {
- if ( operation.baseVersion !== this.version ) {
- /**
- * Only operations with matching versions can be applied.
- *
- * @error document-applyOperation-wrong-version
- * @param {engine.model.operation.Operation} operation
- */
- throw new CKEditorError(
- 'model-document-applyOperation-wrong-version: Only operations with matching versions can be applied.',
- { operation: operation } );
- }
- let changes = operation._execute();
- this.version++;
- this.history.addDelta( operation.delta );
- const batch = operation.delta && operation.delta.batch;
- if ( changes ) {
- // `NoOperation` returns no changes, do not fire event for it.
- this.fire( 'change', operation.type, changes, batch );
- }
- }
- /**
- * Creates a {@link engine.model.Batch} instance which allows to change the document.
- *
- * @param {String} [type] Batch type. See {@link engine.model.Batch#type}.
- * @returns {engine.model.Batch} Batch instance.
- */
- batch( type ) {
- return new Batch( this, type );
- }
- /**
- * Creates a new top-level root.
- *
- * @param {String} [elementName='$root'] Element name. Defaults to `'$root'` which also have
- * some basic schema defined (`$block`s are allowed inside the `$root`). Make sure to define a proper
- * schema if you use a different name.
- * @param {String} [rootName='main'] Unique root name.
- * @returns {engine.model.RootElement} Created root.
- */
- createRoot( elementName = '$root', rootName = 'main' ) {
- if ( this._roots.has( rootName ) ) {
- /**
- * Root with specified name already exists.
- *
- * @error document-createRoot-name-exists
- * @param {engine.model.Document} doc
- * @param {String} name
- */
- throw new CKEditorError(
- 'model-document-createRoot-name-exists: Root with specified name already exists.',
- { name: rootName }
- );
- }
- const root = new RootElement( this, elementName, rootName );
- this._roots.set( rootName, root );
- return root;
- }
- /**
- * Removes all events listeners set by document instance.
- */
- destroy() {
- this.selection.destroy();
- this.stopListening();
- }
- /**
- * Enqueues document changes. Any changes to be done on document (mostly using {@link engine.model.Document#batch}
- * should be placed in the queued callback. If no other plugin is changing document at the moment, the callback will be
- * called immediately. Otherwise it will wait for all previously queued changes to finish happening. This way
- * queued callback will not interrupt other callbacks.
- *
- * When all queued changes are done {@link engine.model.Document#changesDone} event is fired.
- *
- * @fires @link engine.model.Document#changesDone
- * @param {Function} callback Callback to enqueue.
- */
- enqueueChanges( callback ) {
- this._pendingChanges.push( callback );
- if ( this._pendingChanges.length == 1 ) {
- while ( this._pendingChanges.length ) {
- this._pendingChanges[ 0 ]();
- this._pendingChanges.shift();
- }
- this.fire( 'changesDone' );
- }
- }
- /**
- * Returns top-level root by its name.
- *
- * @param {String} [name='main'] Unique root name.
- * @returns {engine.model.RootElement} Root registered under given name.
- */
- getRoot( name = 'main' ) {
- if ( !this._roots.has( name ) ) {
- /**
- * Root with specified name does not exist.
- *
- * @error document-getRoot-root-not-exist
- * @param {String} name
- */
- throw new CKEditorError(
- 'model-document-getRoot-root-not-exist: Root with specified name does not exist.',
- { name: name }
- );
- }
- return this._roots.get( name );
- }
- /**
- * Checks if root with given name is defined.
- *
- * @param {String} name Name of root to check.
- * @returns {Boolean}
- */
- hasRoot( name ) {
- return this._roots.has( name );
- }
- /**
- * Returns array with names of all roots (without the {@link engine.model.Document#graveyard}) added to the document.
- *
- * @returns {Array.<String>} Roots names.
- */
- getRootNames() {
- return Array.from( this._roots.keys() ).filter( ( name ) => name != graveyardName );
- }
- /**
- * Basing on given `position`, finds and returns a {@link engine.model.Position Position} instance that is nearest
- * to that `position` and is a correct position for selection. A position is correct for selection if
- * text node can be placed at that position.
- *
- * If no correct position is found, the first position in given `position` root is returned. This can happen if no node
- * has been added to the root or it may mean incorrect model document state.
- *
- * @param {engine.model.Position} position Reference position where selection position should be looked for.
- * @returns {engine.model.Position|null} Nearest selection position.
- */
- getNearestSelectionPosition( position ) {
- if ( this.schema.check( { name: '$text', inside: position } ) ) {
- return Position.createFromPosition( position );
- }
- const backwardWalker = new TreeWalker( { startPosition: position, direction: 'backward' } );
- const forwardWalker = new TreeWalker( { startPosition: position } );
- let done = false;
- while ( !done ) {
- done = true;
- let step = backwardWalker.next();
- if ( !step.done ) {
- if ( this.schema.check( { name: '$text', inside: step.value.nextPosition } ) ) {
- return step.value.nextPosition;
- }
- done = false;
- }
- step = forwardWalker.next();
- if ( !step.done ) {
- if ( this.schema.check( { name: '$text', inside: step.value.nextPosition } ) ) {
- return step.value.nextPosition;
- }
- done = false;
- }
- }
- return new Position( position.root, [ 0 ] );
- }
- /**
- * Custom toJSON method to solve child-parent circular dependencies.
- *
- * @returns {Object} Clone of this object with the document property changed to string.
- */
- toJSON() {
- const json = clone( this );
- // Due to circular references we need to remove parent reference.
- json.selection = '[engine.model.LiveSelection]';
- return json;
- }
- /**
- * Returns default root for this document which is either the first root that was added to the the document using
- * {@link engine.model.Document#createRoot} or the {@link engine.model.Document#graveyard graveyard root} if
- * no other roots were created.
- *
- * @protected
- * @returns {engine.model.RootElement} The default root for this document.
- */
- _getDefaultRoot() {
- for ( let root of this._roots.values() ) {
- if ( root !== this.graveyard ) {
- return root;
- }
- }
- return this.graveyard;
- }
- /**
- * Returns a default range for this selection. The default range is a collapsed range that starts and ends
- * at the beginning of this selection's document's {@link engine.model.Document#_getDefaultRoot default root}.
- *
- * @protected
- * @returns {engine.model.Range}
- */
- _getDefaultRange() {
- const defaultRoot = this._getDefaultRoot();
- // Find the first position where the selection can be put.
- const position = new Position( defaultRoot, [ 0 ] );
- const selectionPosition = this.getNearestSelectionPosition( position );
- return new Range( selectionPosition );
- }
- /**
- * Checks whether given {@link engine.model.Range range} is a valid range for
- * {@link engine.model.Document#selection document's selection}.
- *
- * @private
- * @param {engine.model.Range} range Range to check.
- * @returns {Boolean} `true` if `range` is valid, `false` otherwise.
- */
- _validateSelectionRange( range ) {
- return validateTextNodePosition( range.start ) && validateTextNodePosition( range.end );
- }
- /**
- * Fired when document changes by applying an operation.
- *
- * There are 5 types of change:
- *
- * * 'insert' when nodes are inserted,
- * * 'remove' when nodes are removed,
- * * 'reinsert' when remove is undone,
- * * 'move' when nodes are moved,
- * * 'addAttribute' when attributes are added,
- * * 'removeAttribute' when attributes are removed,
- * * 'changeAttribute' when attributes change,
- * * 'addRootAttribute' when attribute for root is added,
- * * 'removeRootAttribute' when attribute for root is removed,
- * * 'changeRootAttribute' when attribute for root changes.
- *
- * @event engine.model.Document#change
- * @param {String} type Change type, possible option: 'insert', 'remove', 'reinsert', 'move', 'attribute'.
- * @param {Object} data Additional information about the change.
- * @param {engine.model.Range} data.range Range in model containing changed nodes. Note that the range state is
- * after changes has been done, i.e. for 'remove' the range will be in the {@link engine.model.Document#graveyard graveyard root}.
- * This is `undefined` for "...root..." types.
- * @param {engine.model.Position} [data.sourcePosition] Change source position. Exists for 'remove', 'reinsert' and 'move'.
- * Note that this position state is before changes has been done, i.e. for 'reinsert' the source position will be in the
- * {@link engine.model.Document#graveyard graveyard root}.
- * @param {String} [data.key] Only for attribute types. Key of changed / inserted / removed attribute.
- * @param {*} [data.oldValue] Only for 'removeAttribute', 'removeRootAttribute', 'changeAttribute' or
- * 'changeRootAttribute' type.
- * @param {*} [data.newValue] Only for 'addAttribute', 'addRootAttribute', 'changeAttribute' or
- * 'changeRootAttribute' type.
- * @param {engine.model.RootElement} [changeInfo.root] Root element which attributes got changed. This is defined
- * only for root types.
- * @param {engine.model.Batch} batch A {@link engine.model.Batch batch} of changes which this change is a part of.
- */
- /**
- * Fired when all queued document changes are done. See {@link engine.model.Document#enqueueChanges}.
- *
- * @event engine.model.Document#changesDone
- */
- }
- mix( Document, EmitterMixin );
- // Checks whether given range boundary position is valid for document selection, meaning that is not between
- // unicode surrogate pairs or base character and combining marks.
- function validateTextNodePosition( rangeBoundary ) {
- const textNode = rangeBoundary.textNode;
- if ( textNode ) {
- const data = textNode.data;
- const offset = rangeBoundary.offset - textNode.startOffset;
- return !isInsideSurrogatePair( data, offset ) && !isInsideCombinedSymbol( data, offset );
- }
- return true;
- }
|