UNPKG

@ckeditor/ckeditor5-engine

Version:

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

944 lines 57.6 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/view/renderer */ import { ViewText } from './text.js'; import { ViewPosition } from './position.js'; import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller } from './filler.js'; import { CKEditorError, ObservableMixin, diff, env, fastDiff, insertAt, isComment, isNode, isText, remove, indexOf } from '@ckeditor/ckeditor5-utils'; // @if CK_DEBUG_TYPING // const { _buildLogMessage } = require( '../dev-utils/utils.js' ); import '../../theme/renderer.css'; /** * Renderer is responsible for updating the DOM structure and the DOM selection based on * the {@link module:engine/view/renderer~ViewRenderer#markToSync information about updated view nodes}. * In other words, it renders the view to the DOM. * * Its main responsibility is to make only the necessary, minimal changes to the DOM. However, unlike in many * virtual DOM implementations, the primary reason for doing minimal changes is not the performance but ensuring * that native editing features such as text composition, autocompletion, spell checking, selection's x-index are * affected as little as possible. * * Renderer uses {@link module:engine/view/domconverter~ViewDomConverter} to transform view nodes and positions * to and from the DOM. */ export class ViewRenderer extends /* #__PURE__ */ ObservableMixin() { /** * Set of DOM Documents instances. */ domDocuments = new Set(); /** * Converter instance. */ domConverter; /** * Set of nodes which attributes changed and may need to be rendered. */ markedAttributes = new Set(); /** * Set of elements which child lists changed and may need to be rendered. */ markedChildren = new Set(); /** * Set of text nodes which text data changed and may need to be rendered. */ markedTexts = new Set(); /** * View selection. Renderer updates DOM selection based on the view selection. */ selection; /** * The text node in which the inline filler was rendered. */ _inlineFiller = null; /** * DOM element containing fake selection. */ _fakeSelectionContainer = null; /** * Creates a renderer instance. * * @param domConverter Converter instance. * @param selection View selection. */ constructor(domConverter, selection) { super(); this.domConverter = domConverter; this.selection = selection; this.set('isFocused', false); this.set('isSelecting', false); this.set('isComposing', false); // Rendering the selection and inline filler manipulation should be postponed in (non-Android) Blink until the user finishes // creating the selection in DOM to avoid accidental selection collapsing // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723). // When the user stops selecting, all pending changes should be rendered ASAP, though. if (env.isBlink && !env.isAndroid) { this.on('change:isSelecting', () => { if (!this.isSelecting) { this.render(); } }); } } /** * Marks a view node to be updated in the DOM by {@link #render `render()`}. * * Note that only view nodes whose parents have corresponding DOM elements need to be marked to be synchronized. * * @see #markedAttributes * @see #markedChildren * @see #markedTexts * * @param type Type of the change. * @param node ViewNode to be marked. */ markToSync(type, node) { if (type === 'text') { if (this.domConverter.mapViewToDom(node.parent)) { this.markedTexts.add(node); } } else { // If the node has no DOM element it is not rendered yet, // its children/attributes do not need to be marked to be sync. if (!this.domConverter.mapViewToDom(node)) { return; } if (type === 'attributes') { this.markedAttributes.add(node); } else if (type === 'children') { this.markedChildren.add(node); } else { // eslint-disable-next-line @typescript-eslint/no-unused-vars const unreachable = type; /** * Unknown type passed to Renderer.markToSync. * * @error view-renderer-unknown-type */ throw new CKEditorError('view-renderer-unknown-type', this); } } } /** * Renders all buffered changes ({@link #markedAttributes}, {@link #markedChildren} and {@link #markedTexts}) and * the current view selection (if needed) to the DOM by applying a minimal set of changes to it. * * Renderer tries not to break the text composition (e.g. IME) and x-index of the selection, * so it does as little as it is needed to update the DOM. * * Renderer also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed * at the selection position and adds or removes it. To prevent breaking text composition inline filler will not be * removed as long as the selection is in the text node which needed it at first. */ render() { // Ignore rendering while in the composition mode. Composition events are not cancellable and browser will modify the DOM tree. // All marked elements, attributes, etc. will wait until next render after the composition ends. // On Android composition events are immediately applied to the model, so we don't need to skip rendering, // and we should not do it because the difference between view and DOM could lead to position mapping problems. if (this.isComposing && !env.isAndroid) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cRendering aborted while isComposing.', // @if CK_DEBUG_TYPING // 'font-style: italic' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } return; } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cRendering', // @if CK_DEBUG_TYPING // 'font-weight: bold' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } let inlineFillerPosition = null; const isInlineFillerRenderingPossible = env.isBlink && !env.isAndroid ? !this.isSelecting : true; // Refresh mappings. for (const element of this.markedChildren) { this._updateChildrenMappings(element); } // Don't manipulate inline fillers while the selection is being made in (non-Android) Blink to prevent accidental // DOM selection collapsing // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723). if (isInlineFillerRenderingPossible) { // There was inline filler rendered in the DOM but it's not // at the selection position any more, so we can remove it // (cause even if it's needed, it must be placed in another location). if (this._inlineFiller && !this._isSelectionInInlineFiller()) { this._removeInlineFiller(); } // If we've got the filler, let's try to guess its position in the view. if (this._inlineFiller) { inlineFillerPosition = this._getInlineFillerPosition(); } // Otherwise, if it's needed, create it at the selection position. else if (this._needsInlineFillerAtSelection()) { inlineFillerPosition = this.selection.getFirstPosition(); // Do not use `markToSync` so it will be added even if the parent is already added. this.markedChildren.add(inlineFillerPosition.parent); } } // Make sure the inline filler has any parent, so it can be mapped to view position by ViewDomConverter. else if (this._inlineFiller && this._inlineFiller.parentNode) { // While the user is making selection, preserve the inline filler at its original position. inlineFillerPosition = this.domConverter.domPositionToView(this._inlineFiller); // While down-casting the document selection attributes, all existing empty // attribute elements (for selection position) are removed from the view and DOM, // so make sure that we were able to map filler position. // https://github.com/ckeditor/ckeditor5/issues/12026 if (inlineFillerPosition && inlineFillerPosition.parent.is('$text')) { // The inline filler position is expected to be before the text node. inlineFillerPosition = ViewPosition._createBefore(inlineFillerPosition.parent); } } for (const element of this.markedAttributes) { this._updateAttrs(element); } for (const element of this.markedChildren) { this._updateChildren(element, { inlineFillerPosition }); } for (const node of this.markedTexts) { if (!this.markedChildren.has(node.parent) && this.domConverter.mapViewToDom(node.parent)) { this._updateText(node, { inlineFillerPosition }); } } // * Check whether the inline filler is required and where it really is in the DOM. // At this point in most cases it will be in the DOM, but there are exceptions. // For example, if the inline filler was deep in the created DOM structure, it will not be created. // Similarly, if it was removed at the beginning of this function and then neither text nor children were updated, // it will not be present. Fix those and similar scenarios. // * Don't manipulate inline fillers while the selection is being made in (non-Android) Blink to prevent accidental // DOM selection collapsing // (https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723). if (isInlineFillerRenderingPossible) { if (inlineFillerPosition) { const fillerDomPosition = this.domConverter.viewPositionToDom(inlineFillerPosition); const domDocument = fillerDomPosition.parent.ownerDocument; if (!startsWithFiller(fillerDomPosition.parent)) { // Filler has not been created at filler position. Create it now. this._inlineFiller = addInlineFiller(domDocument, fillerDomPosition.parent, fillerDomPosition.offset); } else { // Filler has been found, save it. this._inlineFiller = fillerDomPosition.parent; } } else { // There is no filler needed. this._inlineFiller = null; } } // First focus the new editing host, then update the selection. // Otherwise, FF may throw an error (https://github.com/ckeditor/ckeditor5/issues/721). this._updateFocus(); this._updateSelection(); this.domConverter._clearTemporaryCustomProperties(); this.markedTexts.clear(); this.markedAttributes.clear(); this.markedChildren.clear(); // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.groupEnd(); // @if CK_DEBUG_TYPING // } } /** * Updates mappings of view element's children. * * Children that were replaced in the view structure by similar elements (same tag name) are treated as 'replaced'. * This means that their mappings can be updated so the new view elements are mapped to the existing DOM elements. * Thanks to that these elements do not need to be re-rendered completely. * * @param viewElement The view element whose children mappings will be updated. */ _updateChildrenMappings(viewElement) { const domElement = this.domConverter.mapViewToDom(viewElement); if (!domElement) { // If there is no `domElement` it means that it was already removed from DOM and there is no need to process it. return; } // Removing nodes from the DOM as we iterate can cause `actualDomChildren` // (which is a live-updating `NodeList`) to get out of sync with the // indices that we compute as we iterate over `actions`. // This would produce incorrect element mappings. // // Converting live list to an array to make the list static. const actualDomChildren = Array.from(domElement.childNodes); const expectedDomChildren = Array.from(this.domConverter.viewChildrenToDom(viewElement, { withChildren: false })); const diff = this._diffNodeLists(actualDomChildren, expectedDomChildren); const actions = this._findUpdateActions(diff, actualDomChildren, expectedDomChildren, areSimilarElements); if (actions.indexOf('update') !== -1) { const counter = { equal: 0, insert: 0, delete: 0 }; for (const action of actions) { if (action === 'update') { const insertIndex = counter.equal + counter.insert; const deleteIndex = counter.equal + counter.delete; const viewChild = viewElement.getChild(insertIndex); // UIElement and RawElement are special cases. Their children are not stored in a view (#799) // so we cannot use them with replacing flow (since they use view children during rendering // which will always result in rendering empty elements). if (viewChild && !viewChild.is('uiElement') && !viewChild.is('rawElement')) { this._updateElementMappings(viewChild, actualDomChildren[deleteIndex]); } remove(expectedDomChildren[insertIndex]); counter.equal++; } else { counter[action]++; } } } } /** * Updates mappings of a given view element. * * @param viewElement The view element whose mappings will be updated. * @param domElement The DOM element representing the given view element. */ _updateElementMappings(viewElement, domElement) { // Remap 'DomConverter' bindings. this.domConverter.unbindDomElement(domElement); this.domConverter.bindElements(domElement, viewElement); // View element may have children which needs to be updated, but are not marked, mark them to update. this.markedChildren.add(viewElement); // Because we replace new view element mapping with the existing one, the corresponding DOM element // will not be rerendered. The new view element may have different attributes than the previous one. // Since its corresponding DOM element will not be rerendered, new attributes will not be added // to the DOM, so we need to mark it here to make sure its attributes gets updated. See #1427 for more // detailed case study. // Also there are cases where replaced element is removed from the view structure and then has // its attributes changed or removed. In such cases the element will not be present in `markedAttributes` // and also may be the same (`element.isSimilar()`) as the reused element not having its attributes updated. // To prevent such situations we always mark reused element to have its attributes rerenderd (#1560). this.markedAttributes.add(viewElement); } /** * Gets the position of the inline filler based on the current selection. * Here, we assume that we know that the filler is needed and * {@link #_isSelectionInInlineFiller is at the selection position}, and, since it is needed, * it is somewhere at the selection position. * * Note: The filler position cannot be restored based on the filler's DOM text node, because * when this method is called (before rendering), the bindings will often be broken. View-to-DOM * bindings are only dependable after rendering. */ _getInlineFillerPosition() { const firstPos = this.selection.getFirstPosition(); if (firstPos.parent.is('$text')) { return ViewPosition._createBefore(firstPos.parent); } else { return firstPos; } } /** * Returns `true` if the selection has not left the inline filler's text node. * If it is `true`, it means that the filler had been added for a reason and the selection did not * leave the filler's text node. For example, the user can be in the middle of a composition so it should not be touched. * * @returns `true` if the inline filler and selection are in the same place. */ _isSelectionInInlineFiller() { if (this.selection.rangeCount != 1 || !this.selection.isCollapsed) { return false; } // Note, we can't check if selection's position equals position of the // this._inlineFiller node, because of #663. We may not be able to calculate // the filler's position in the view at this stage. // Instead, we check it the other way – whether selection is anchored in // that text node or next to it. // Possible options are: // "FILLER{}" // "FILLERadded-text{}" const selectionPosition = this.selection.getFirstPosition(); const position = this.domConverter.viewPositionToDom(selectionPosition); if (position && isText(position.parent) && startsWithFiller(position.parent)) { return true; } return false; } /** * Removes the inline filler. */ _removeInlineFiller() { const domFillerNode = this._inlineFiller; // Something weird happened and the stored node doesn't contain the filler's text. if (!startsWithFiller(domFillerNode)) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Inline filler node: ' + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( domFillerNode.data ) }%c (${ domFillerNode.data.length })`, // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } /** * The inline filler node was lost. Most likely, something overwrote the filler text node * in the DOM. * * @error view-renderer-filler-was-lost */ throw new CKEditorError('view-renderer-filler-was-lost', this); } if (isInlineFiller(domFillerNode)) { domFillerNode.remove(); } else { domFillerNode.data = domFillerNode.data.substr(INLINE_FILLER_LENGTH); } this._inlineFiller = null; } /** * Checks if the inline {@link module:engine/view/filler filler} should be added. * * @returns `true` if the inline filler should be added. */ _needsInlineFillerAtSelection() { if (this.selection.rangeCount != 1 || !this.selection.isCollapsed) { return false; } const selectionPosition = this.selection.getFirstPosition(); const selectionParent = selectionPosition.parent; const selectionOffset = selectionPosition.offset; // If there is no DOM root we do not care about fillers. if (!this.domConverter.mapViewToDom(selectionParent.root)) { return false; } if (!(selectionParent.is('element'))) { return false; } // Prevent adding inline filler inside elements with contenteditable=false. // https://github.com/ckeditor/ckeditor5-engine/issues/1170 if (!isEditable(selectionParent)) { return false; } const nodeBefore = selectionPosition.nodeBefore; const nodeAfter = selectionPosition.nodeAfter; if (nodeBefore instanceof ViewText || nodeAfter instanceof ViewText) { return false; } // We have block filler, we do not need inline one. if (selectionOffset === selectionParent.getFillerOffset() && (!nodeBefore || !nodeBefore.is('element', 'br'))) { return false; } // Do not use inline filler while typing outside inline elements on Android. // The deleteContentBackward would remove part of the inline filler instead of removing last letter in a link. if (env.isAndroid && (nodeBefore || nodeAfter)) { return false; } return true; } /** * Checks if text needs to be updated and possibly updates it. * * @param viewText View text to update. * @param options.inlineFillerPosition The position where the inline filler should be rendered. */ _updateText(viewText, options) { const domText = this.domConverter.findCorrespondingDomText(viewText); const newDomText = this.domConverter.viewToDom(viewText); let expectedText = newDomText.data; const filler = options.inlineFillerPosition; if (filler && filler.parent == viewText.parent && filler.offset == viewText.index) { expectedText = INLINE_FILLER + expectedText; } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cUpdate text', // @if CK_DEBUG_TYPING // 'font-weight: normal' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } this._updateTextNode(domText, expectedText); // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.groupEnd(); // @if CK_DEBUG_TYPING // } } /** * Checks if attribute list needs to be updated and possibly updates it. * * @param viewElement The view element to update. */ _updateAttrs(viewElement) { const domElement = this.domConverter.mapViewToDom(viewElement); if (!domElement) { // If there is no `domElement` it means that 'viewElement' is outdated as its mapping was updated // in 'this._updateChildrenMappings()'. There is no need to process it as new view element which // replaced old 'viewElement' mapping was also added to 'this.markedAttributes' // in 'this._updateChildrenMappings()' so it will be processed separately. return; } // Remove attributes from DOM elements if they do not exist in the view. // // Note: It is important to first remove DOM attributes and then set new ones, because some view attributes may be renamed // as they are set on DOM (due to unsafe attributes handling). If we set the view attribute first, and then remove // non-existing DOM attributes, then we would remove the attribute that we just set. // // Note: The domElement.attributes is a live collection, so we need to convert it to an array to avoid issues. for (const domAttr of Array.from(domElement.attributes)) { const key = domAttr.name; // All other attributes not present in the DOM should be removed. if (!viewElement.hasAttribute(key)) { this.domConverter.removeDomElementAttribute(domElement, key); } } // Add or overwrite attributes. for (const key of viewElement.getAttributeKeys()) { this.domConverter.setDomElementAttribute(domElement, key, viewElement.getAttribute(key), viewElement); } } /** * Checks if elements child list needs to be updated and possibly updates it. * * Note that on Android, to reduce the risk of composition breaks, it tries to update data of an existing * child text nodes instead of replacing them completely. * * @param viewElement View element to update. * @param options.inlineFillerPosition The position where the inline filler should be rendered. */ _updateChildren(viewElement, options) { const domElement = this.domConverter.mapViewToDom(viewElement); if (!domElement) { // If there is no `domElement` it means that it was already removed from DOM. // There is no need to process it. It will be processed when re-inserted. return; } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cUpdate children', // @if CK_DEBUG_TYPING // 'font-weight: normal' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } // IME on Android inserts a new text node while typing after a link // instead of updating an existing text node that follows the link. // We must normalize those text nodes so the diff won't get confused. // https://github.com/ckeditor/ckeditor5/issues/12574. if (env.isAndroid) { let previousDomNode = null; for (const domNode of Array.from(domElement.childNodes)) { if (previousDomNode && isText(previousDomNode) && isText(domNode)) { domElement.normalize(); break; } previousDomNode = domNode; } } const inlineFillerPosition = options.inlineFillerPosition; const actualDomChildren = domElement.childNodes; const expectedDomChildren = Array.from(this.domConverter.viewChildrenToDom(viewElement, { bind: true })); // Inline filler element has to be created as it is present in the DOM, but not in the view. It is required // during diffing so text nodes could be compared correctly and also during rendering to maintain // proper order and indexes while updating the DOM. if (inlineFillerPosition && inlineFillerPosition.parent === viewElement) { addInlineFiller(domElement.ownerDocument, expectedDomChildren, inlineFillerPosition.offset); } const diff = this._diffNodeLists(actualDomChildren, expectedDomChildren); // We need to make sure that we update the existing text node and not replace it with another one. // The composition and different "language" browser extensions are fragile to text node being completely replaced. const actions = this._findUpdateActions(diff, actualDomChildren, expectedDomChildren, areTextNodes); // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping && actions.every( a => a == 'equal' ) ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cNothing to update.', // @if CK_DEBUG_TYPING // 'font-style: italic' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } let i = 0; const nodesToUnbind = new Set(); // Handle deletions first. // This is to prevent a situation where an element that already exists in `actualDomChildren` is inserted at a different // index in `actualDomChildren`. Since `actualDomChildren` is a `NodeList`, this works like move, not like an insert, // and it disrupts the whole algorithm. See https://github.com/ckeditor/ckeditor5/issues/6367. // // It doesn't matter in what order we remove or add nodes, as long as we remove and add correct nodes at correct indexes. for (const action of actions) { if (action === 'delete') { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // const node = actualDomChildren[ i ]; // @if CK_DEBUG_TYPING // if ( isText( node ) ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cRemove text node' + // @if CK_DEBUG_TYPING // `${ this.isComposing ? ' while composing (may break composition)' : '' }: ` + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( node.data ) }%c (${ node.data.length })`, // @if CK_DEBUG_TYPING // this.isComposing ? 'color: red; font-weight: bold' : '', // @if CK_DEBUG_TYPING // 'color: blue', '' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } else { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cRemove element' + // @if CK_DEBUG_TYPING // `${ this.isComposing ? ' while composing (may break composition)' : '' }: `, // @if CK_DEBUG_TYPING // this.isComposing ? 'color: red; font-weight: bold' : '', // @if CK_DEBUG_TYPING // node // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } // @if CK_DEBUG_TYPING // } nodesToUnbind.add(actualDomChildren[i]); remove(actualDomChildren[i]); } else if (action === 'equal' || action === 'update') { i++; } } i = 0; for (const action of actions) { if (action === 'insert') { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // const node = expectedDomChildren[ i ]; // @if CK_DEBUG_TYPING // if ( isText( node ) ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cInsert text node' + // @if CK_DEBUG_TYPING // `${ this.isComposing ? ' while composing (may break composition)' : '' }: ` + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( node.data ) }%c (${ node.data.length })`, // @if CK_DEBUG_TYPING // this.isComposing ? 'color: red; font-weight: bold' : '', // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } else { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cInsert element:', // @if CK_DEBUG_TYPING // 'font-weight: normal', // @if CK_DEBUG_TYPING // node // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } // @if CK_DEBUG_TYPING // } insertAt(domElement, i, expectedDomChildren[i]); i++; } // Update the existing text node data. else if (action === 'update') { this._updateTextNode(actualDomChildren[i], expectedDomChildren[i].data); i++; } else if (action === 'equal') { // Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125). // Do it here (not in the loop above) because only after insertions the `i` index is correct. this._markDescendantTextToSync(this.domConverter.domToView(expectedDomChildren[i])); i++; } } // Unbind removed nodes. When node does not have a parent it means that it was removed from DOM tree during // comparison with the expected DOM. We don't need to check child nodes, because if child node was reinserted, // it was moved to DOM tree out of the removed node. for (const node of nodesToUnbind) { if (!node.parentNode) { this.domConverter.unbindDomElement(node); } } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.groupEnd(); // @if CK_DEBUG_TYPING // } } /** * Shorthand for diffing two arrays or node lists of DOM nodes. * * @param actualDomChildren Actual DOM children * @param expectedDomChildren Expected DOM children. * @returns The list of actions based on the {@link module:utils/diff~diff} function. */ _diffNodeLists(actualDomChildren, expectedDomChildren) { actualDomChildren = filterOutFakeSelectionContainer(actualDomChildren, this._fakeSelectionContainer); return diff(actualDomChildren, expectedDomChildren, sameNodes.bind(null, this.domConverter)); } /** * Finds DOM nodes that were replaced with the similar nodes (same tag name) in the view. All nodes are compared * within one `insert`/`delete` action group, for example: * * ``` * Actual DOM: <p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p> * Expected DOM: <p>Bar<b>123</b><i>Baz</i><b>456</b></p> * Input actions: [ insert, insert, delete, delete, equal, insert, delete ] * Output actions: [ insert, replace, delete, equal, replace ] * ``` * * @param actions Actions array which is a result of the {@link module:utils/diff~diff} function. * @param actualDom Actual DOM children * @param expectedDom Expected DOM children. * @param comparator A comparator function that should return `true` if the given node should be reused * (either by the update of a text node data or an element children list for similar elements). * @returns Actions array modified with the `update` actions. */ _findUpdateActions(actions, actualDom, expectedDom, comparator) { // If there is no both 'insert' and 'delete' actions, no need to check for replaced elements. if (actions.indexOf('insert') === -1 || actions.indexOf('delete') === -1) { return actions; } let newActions = []; let actualSlice = []; let expectedSlice = []; const counter = { equal: 0, insert: 0, delete: 0 }; for (const action of actions) { if (action === 'insert') { expectedSlice.push(expectedDom[counter.equal + counter.insert]); } else if (action === 'delete') { actualSlice.push(actualDom[counter.equal + counter.delete]); } else { // equal newActions = newActions.concat(diff(actualSlice, expectedSlice, comparator) .map(action => action === 'equal' ? 'update' : action)); newActions.push('equal'); // Reset stored elements on 'equal'. actualSlice = []; expectedSlice = []; } counter[action]++; } return newActions.concat(diff(actualSlice, expectedSlice, comparator) .map(action => action === 'equal' ? 'update' : action)); } /** * Checks if text needs to be updated and possibly updates it by removing and inserting only parts * of the data from the existing text node to reduce impact on the IME composition. * * @param domText DOM text node to update. * @param expectedText The expected data of a text node. */ _updateTextNode(domText, expectedText) { const actualText = domText.data; if (actualText == expectedText) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cText node does not need update:%c ' + // @if CK_DEBUG_TYPING // `${ _escapeTextNodeData( actualText ) }%c (${ actualText.length })`, // @if CK_DEBUG_TYPING // 'font-style: italic', // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } return; } // Our approach to interleaving space character with NBSP might differ with the one implemented by the browser. // Avoid modifying the text node in the DOM if only NBSPs and spaces are interchanged. // We should avoid DOM modifications while composing to avoid breakage of composition. // See: https://github.com/ckeditor/ckeditor5/issues/13994. if (env.isAndroid && this.isComposing && actualText.replace(/\u00A0/g, ' ') == expectedText.replace(/\u00A0/g, ' ')) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cText node ignore NBSP changes while composing: ' + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( actualText ) }%c (${ actualText.length }) -> ` + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( expectedText ) }%c (${ expectedText.length })`, // @if CK_DEBUG_TYPING // 'font-style: italic', // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '', // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } return; } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cUpdate text node' + // @if CK_DEBUG_TYPING // `${ this.isComposing ? ' while composing (may break composition)' : '' }: ` + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( actualText ) }%c (${ actualText.length }) -> ` + // @if CK_DEBUG_TYPING // `%c${ _escapeTextNodeData( expectedText ) }%c (${ expectedText.length })`, // @if CK_DEBUG_TYPING // this.isComposing ? 'color: red; font-weight: bold' : '', // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '', // @if CK_DEBUG_TYPING // 'color: blue', // @if CK_DEBUG_TYPING // '' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } this._updateTextNodeInternal(domText, expectedText); } /** * Part of the `_updateTextNode` method extracted for easier testing. */ _updateTextNodeInternal(domText, expectedText) { const actions = fastDiff(domText.data, expectedText); for (const action of actions) { if (action.type === 'insert') { domText.insertData(action.index, action.values.join('')); } else { // 'delete' domText.deleteData(action.index, action.howMany); } } } /** * Marks text nodes to be synchronized. * * If a text node is passed, it will be marked. If an element is passed, all descendant text nodes inside it will be marked. * * @param viewNode View node to sync. */ _markDescendantTextToSync(viewNode) { if (!viewNode) { return; } if (viewNode.is('$text')) { this.markedTexts.add(viewNode); } else if (viewNode.is('element')) { for (const child of viewNode.getChildren()) { this._markDescendantTextToSync(child); } } } /** * Checks if the selection needs to be updated and possibly updates it. */ _updateSelection() { // Block updating DOM selection in (non-Android) Blink while the user is selecting to prevent accidental selection collapsing. // Note: Structural changes in DOM must trigger selection rendering, though. Nodes the selection was anchored // to, may disappear in DOM which would break the selection (e.g. in real-time collaboration scenarios). // https://github.com/ckeditor/ckeditor5/issues/10562, https://github.com/ckeditor/ckeditor5/issues/10723 if (env.isBlink && !env.isAndroid && this.isSelecting && !this.markedChildren.size) { return; } // If there is no selection - remove DOM and fake selections. if (this.selection.rangeCount === 0) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Update DOM selection: remove all ranges' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } this._removeDomSelection(); this._removeFakeSelection(); return; } const domEditable = this.domConverter.mapViewToDom(this.selection.editableElement); // Do not update DOM selection if there is no focus, or there is no DOM element corresponding to selection's editable element. if (!this.isFocused || !domEditable) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Skip updating DOM selection:', // @if CK_DEBUG_TYPING // `isFocused: ${ this.isFocused }, hasDomEditable: ${ !!domEditable }` // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } // But if there was a fake selection, and it is not fake anymore - remove it as it can map to no longer existing widget. // See https://github.com/ckeditor/ckeditor5/issues/18123. if (!this.selection.isFake && this._fakeSelectionContainer && this._fakeSelectionContainer.isConnected) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Remove fake selection (not focused editable)' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } this._removeFakeSelection(); } return; } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Update DOM selection' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } // Render fake selection - create the fake selection container (if needed) and move DOM selection to it. if (this.selection.isFake) { this._updateFakeSelection(domEditable); } // There was a fake selection so remove it and update the DOM selection. // This is especially important on Android because otherwise IME will try to compose over the fake selection container. else if (this._fakeSelectionContainer && this._fakeSelectionContainer.isConnected) { this._removeFakeSelection(); this._updateDomSelection(domEditable); } // Update the DOM selection in case of a plain selection change (no fake selection is involved). // On non-Android the whole rendering is disabled in composition mode (including DOM selection update), // but updating DOM selection should be also disabled on Android if in the middle of the composition // (to not interrupt it). else if (!(this.isComposing && env.isAndroid)) { this._updateDomSelection(domEditable); } } /** * Updates the fake selection. * * @param domEditable A valid DOM editable where the fake selection container should be added. */ _updateFakeSelection(domEditable) { const domDocument = domEditable.ownerDocument; if (!this._fakeSelectionContainer) { this._fakeSelectionContainer = createFakeSelectionContainer(domDocument); } const container = this._fakeSelectionContainer; // Bind fake selection container with the current selection *position*. this.domConverter.bindFakeSelection(container, this.selection); if (!this._fakeSelectionNeedsUpdate(domEditable)) { return; } if (!container.parentElement || container.parentElement != domEditable) { domEditable.appendChild(container); } container.textContent = this.selection.fakeSelectionLabel || '\u00A0'; // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Set DOM fake selection' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } const domSelection = domDocument.getSelection(); const domRange = domDocument.createRange(); domSelection.removeAllRanges(); domRange.selectNodeContents(container); domSelection.addRange(domRange); } /** * Updates the DOM selection. * * @param domEditable A valid DOM editable where the DOM selection should be rendered. */ _updateDomSelection(domEditable) { const domSelection = domEditable.ownerDocument.defaultView.getSelection(); // Let's check whether DOM selection needs updating at all. if (!this._domSelectionNeedsUpdate(domSelection)) { // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // '%cDOM selection is already correct', // @if CK_DEBUG_TYPING // 'font-style: italic;' // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } return; } // Multi-range selection is not available in most browsers, and, at least in Chrome, trying to // set such selection, that is not continuous, throws an error. Because of that, we will just use anchor // and focus of view selection. // Since we are not supporting multi-range selection, we also do not need to check if proper editable is // selected. If there is any editable selected, it is okay (editable is taken from selection anchor). const anchor = this.domConverter.viewPositionToDom(this.selection.anchor); const focus = this.domConverter.viewPositionToDom(this.selection.focus); // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'Renderer', // @if CK_DEBUG_TYPING // 'Update DOM selection:', // @if CK_DEBUG_TYPING // anchor, // @if CK_DEBUG_TYPING // focus // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } domSelection.setBaseAndExtent(anchor.parent, anchor.offset, focus.parent, focus.offset); // Firefox–specific hack (https://github.com/ckeditor/ckeditor5-engine/issues/1439). if (env.isGecko) { fixGeckoSelectionAfterBr(focus, domSelection); } } /** * Checks whether a given DOM selection needs to be updated. * * @param domSelection The DOM selection to check. */ _domSelectionNeedsUpdate(domSelection) { if (!this.domConverter.isDomSelectionCorrect(domSelection)) { // Current DOM selection is in incorrect position. We need to update it. return true; } const oldViewSelection = domSelection && this.domConverter.domSelectionToView(domSelection); if (oldViewSelection && this.selection.isEqual(oldViewSelection)) { return false; } // If selection is not collapsed, it does not need to be updated if it is similar. if (!this.selection.isCollapsed && this.selection.isSimilar(oldViewSelection)) { // Selection did not changed and is correct, do not update. return false; } // Selections are not similar. return true; } /** * Checks whether the fake selection needs to be updated. * * @param domEditable A valid DOM editable where a new fake selection container should be added. */ _fakeSelectionNeedsUpdate(domEditable) { const container = this._fakeSelectionContainer; const domSelection = domEditable.ownerDocument.getSelection(); // Fake selection needs to be updated if there's no fake selection container, or the container currently sits // in a different root. if (!container || container.