UNPKG

@ckeditor/ckeditor5-engine

Version:

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

182 lines (181 loc) 8.61 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/controller/editingcontroller */ import { CKEditorError, ObservableMixin } from '@ckeditor/ckeditor5-utils'; import { ViewRootEditableElement } from '../view/rooteditableelement.js'; import { EditingView } from '../view/view.js'; import { Mapper } from '../conversion/mapper.js'; import { DowncastDispatcher } from '../conversion/downcastdispatcher.js'; import { cleanSelection, convertCollapsedSelection, convertRangeSelection, insertAttributesAndChildren, insertText, remove } from '../conversion/downcasthelpers.js'; import { convertSelectionChange } from '../conversion/upcasthelpers.js'; // @if CK_DEBUG_ENGINE // const { dumpTrees, initDocumentDumping } = require( '../dev-utils/utils' ); /** * A controller for the editing pipeline. The editing pipeline controls the {@link ~EditingController#model model} rendering, * including selection handling. It also creates the {@link ~EditingController#view view} which builds a * browser-independent virtualization over the DOM elements. The editing controller also attaches default converters. */ export class EditingController extends /* #__PURE__ */ ObservableMixin() { /** * Editor model. */ model; /** * Editing view controller. */ view; /** * A mapper that describes the model-view binding. */ mapper; /** * Downcast dispatcher that converts changes from the model to the {@link #view editing view}. */ downcastDispatcher; /** * Creates an editing controller instance. * * @param model Editing model. * @param stylesProcessor The styles processor instance. */ constructor(model, stylesProcessor) { super(); this.model = model; this.view = new EditingView(stylesProcessor); this.mapper = new Mapper(); this.downcastDispatcher = new DowncastDispatcher({ mapper: this.mapper, schema: model.schema }); const doc = this.model.document; const selection = doc.selection; const markers = this.model.markers; // When plugins listen on model changes (on selection change, post fixers, etc.) and change the view as a result of // the model's change, they might trigger view rendering before the conversion is completed (e.g. before the selection // is converted). We disable rendering for the length of the outermost model change() block to prevent that. // // See https://github.com/ckeditor/ckeditor5-engine/issues/1528 this.listenTo(this.model, '_beforeChanges', () => { this.view._disableRendering(true); }, { priority: 'highest' }); this.listenTo(this.model, '_afterChanges', () => { this.view._disableRendering(false); }, { priority: 'lowest' }); // Whenever model document is changed, convert those changes to the view (using model.Document#differ). // Do it on 'low' priority, so changes are converted after other listeners did their job. // Also convert model selection. this.listenTo(doc, 'change', () => { this.view.change(writer => { this.downcastDispatcher.convertChanges(doc.differ, markers, writer); this.downcastDispatcher.convertSelection(selection, markers, writer); }); }, { priority: 'low' }); // Convert selection from the view to the model when it changes in the view. this.listenTo(this.view.document, 'selectionChange', convertSelectionChange(this.model, this.mapper)); // Attach default model converters. this.downcastDispatcher.on('insert:$text', insertText(), { priority: 'lowest' }); this.downcastDispatcher.on('insert', insertAttributesAndChildren(), { priority: 'lowest' }); this.downcastDispatcher.on('remove', remove(), { priority: 'low' }); // Attach default model selection converters. this.downcastDispatcher.on('cleanSelection', cleanSelection()); this.downcastDispatcher.on('selection', convertRangeSelection(), { priority: 'low' }); this.downcastDispatcher.on('selection', convertCollapsedSelection(), { priority: 'low' }); // Binds {@link module:engine/view/document~ViewDocument#roots view roots collection} to // {@link module:engine/model/document~ModelDocument#roots model roots collection} so creating // model root automatically creates corresponding view root. this.view.document.roots.bindTo(this.model.document.roots).using(root => { // $graveyard is a special root that has no reflection in the view. if (root.rootName == '$graveyard') { return null; } const viewRoot = new ViewRootEditableElement(this.view.document, root.name); viewRoot.rootName = root.rootName; this.mapper.bindElements(root, viewRoot); return viewRoot; }); // @if CK_DEBUG_ENGINE // initDocumentDumping( this.model.document ); // @if CK_DEBUG_ENGINE // initDocumentDumping( this.view.document ); // @if CK_DEBUG_ENGINE // dumpTrees( this.model.document, this.model.document.version ); // @if CK_DEBUG_ENGINE // dumpTrees( this.view.document, this.model.document.version ); // @if CK_DEBUG_ENGINE // this.model.document.on( 'change', () => { // @if CK_DEBUG_ENGINE // dumpTrees( this.view.document, this.model.document.version ); // @if CK_DEBUG_ENGINE // }, { priority: 'lowest' } ); } /** * Removes all event listeners attached to the `EditingController`. Destroys all objects created * by `EditingController` that need to be destroyed. */ destroy() { this.view.destroy(); this.stopListening(); } /** * Calling this method will refresh the marker by triggering the downcast conversion for it. * * Reconverting the marker is useful when you want to change its {@link module:engine/view/element~ViewElement view element} * without changing any marker data. For instance: * * ```ts * let isCommentActive = false; * * model.conversion.markerToHighlight( { * model: 'comment', * view: data => { * const classes = [ 'comment-marker' ]; * * if ( isCommentActive ) { * classes.push( 'comment-marker--active' ); * } * * return { classes }; * } * } ); * * // ... * * // Change the property that indicates if marker is displayed as active or not. * isCommentActive = true; * * // Reconverting will downcast and synchronize the marker with the new isCommentActive state value. * editor.editing.reconvertMarker( 'comment' ); * ``` * * **Note**: If you want to reconvert a model item, use {@link #reconvertItem} instead. * * @param markerOrName Name of a marker to update, or a marker instance. */ reconvertMarker(markerOrName) { const markerName = typeof markerOrName == 'string' ? markerOrName : markerOrName.name; const currentMarker = this.model.markers.get(markerName); if (!currentMarker) { /** * The marker with the provided name does not exist and cannot be reconverted. * * @error editingcontroller-reconvertmarker-marker-not-exist * @param {string} markerName The name of the reconverted marker. */ throw new CKEditorError('editingcontroller-reconvertmarker-marker-not-exist', this, { markerName }); } this.model.change(() => { this.model.markers._refresh(currentMarker); }); } /** * Calling this method will downcast a model item on demand (by requesting a refresh in the {@link module:engine/model/differ~Differ}). * * You can use it if you want the view representation of a specific item updated as a response to external modifications. For instance, * when the view structure depends not only on the associated model data but also on some external state. * * **Note**: If you want to reconvert a model marker, use {@link #reconvertMarker} instead. * * @param item Item to refresh. */ reconvertItem(item) { this.model.change(() => { this.model.document.differ._refreshItem(item); }); } }