UNPKG

@ckeditor/ckeditor5-engine

Version:

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

1,095 lines 65.8 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/schema */ import { ModelElement } from './element.js'; import { ModelPosition } from './position.js'; import { ModelRange } from './range.js'; import { ModelText } from './text.js'; import { ModelTreeWalker } from './treewalker.js'; import { CKEditorError, first, ObservableMixin } from '@ckeditor/ckeditor5-utils'; /** * The model's schema. It defines the allowed and disallowed structures of nodes as well as nodes' attributes. * The schema is usually defined by the features and based on them, the editing framework and features * make decisions on how to change and process the model. * * The instance of schema is available in {@link module:engine/model/model~Model#schema `editor.model.schema`}. * * Read more about the schema in: * * * The {@glink framework/architecture/editing-engine#schema schema section} of the * {@glink framework/architecture/editing-engine Introduction to the Editing engine architecture} guide. * * The {@glink framework/deep-dive/schema Schema deep-dive} guide. */ export class ModelSchema extends /* #__PURE__ */ ObservableMixin() { _sourceDefinitions = {}; /** * A dictionary containing attribute properties. */ _attributeProperties = Object.create(null); /** * Stores additional callbacks registered for schema items, which are evaluated when {@link ~ModelSchema#checkChild} is called. * * Keys are schema item names for which the callbacks are registered. Values are arrays with the callbacks. * * Some checks are added under {@link ~ModelSchema#_genericCheckSymbol} key, these are * evaluated for every {@link ~ModelSchema#checkChild} call. */ _customChildChecks = new Map(); /** * Stores additional callbacks registered for attribute names, which are evaluated when {@link ~ModelSchema#checkAttribute} is called. * * Keys are schema attribute names for which the callbacks are registered. Values are arrays with the callbacks. * * Some checks are added under {@link ~ModelSchema#_genericCheckSymbol} key, these are evaluated for every * {@link ~ModelSchema#checkAttribute} call. */ _customAttributeChecks = new Map(); _genericCheckSymbol = Symbol('$generic'); _compiledDefinitions; /** * Creates a schema instance. */ constructor() { super(); this.decorate('checkChild'); this.decorate('checkAttribute'); this.on('checkAttribute', (evt, args) => { args[0] = new ModelSchemaContext(args[0]); }, { priority: 'highest' }); this.on('checkChild', (evt, args) => { args[0] = new ModelSchemaContext(args[0]); args[1] = this.getDefinition(args[1]); }, { priority: 'highest' }); } /** * Registers a schema item. Can only be called once for every item name. * * ```ts * schema.register( 'paragraph', { * inheritAllFrom: '$block' * } ); * ``` */ register(itemName, definition) { if (this._sourceDefinitions[itemName]) { /** * A single item cannot be registered twice in the schema. * * This situation may happen when: * * * Two or more plugins called {@link module:engine/model/schema~ModelSchema#register `register()`} with the same name. * This will usually mean that there is a collision between plugins which try to use the same element in the model. * Unfortunately, the only way to solve this is by modifying one of these plugins to use a unique model element name. * * A single plugin was loaded twice. This happens when it is installed by npm/yarn in two versions * and usually means one or more of the following issues: * * a version mismatch (two of your dependencies require two different versions of this plugin), * * incorrect imports (this plugin is somehow imported twice in a way which confuses webpack), * * mess in `node_modules/` (`rm -rf node_modules/` may help). * * **Note:** Check the logged `itemName` to better understand which plugin was duplicated/conflicting. * * @param itemName The name of the model element that is being registered twice. * @error schema-cannot-register-item-twice */ throw new CKEditorError('schema-cannot-register-item-twice', this, { itemName }); } this._sourceDefinitions[itemName] = [ Object.assign({}, definition) ]; this._clearCache(); } /** * Extends a {@link #register registered} item's definition. * * Extending properties such as `allowIn` will add more items to the existing properties, * while redefining properties such as `isBlock` will override the previously defined ones. * * ```ts * schema.register( 'foo', { * allowIn: '$root', * isBlock: true; * } ); * schema.extend( 'foo', { * allowIn: 'blockQuote', * isBlock: false * } ); * * schema.getDefinition( 'foo' ); * // { * // allowIn: [ '$root', 'blockQuote' ], * // isBlock: false * // } * ``` */ extend(itemName, definition) { if (!this._sourceDefinitions[itemName]) { /** * Cannot extend an item which was not registered yet. * * This error happens when a plugin tries to extend the schema definition of an item which was not * {@link module:engine/model/schema~ModelSchema#register registered} yet. * * @param itemName The name of the model element which is being extended. * @error schema-cannot-extend-missing-item */ throw new CKEditorError('schema-cannot-extend-missing-item', this, { itemName }); } this._sourceDefinitions[itemName].push(Object.assign({}, definition)); this._clearCache(); } /** * Returns data of all registered items. * * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element, * checking a list of all block elements, etc). * Use specific methods (such as {@link #checkChild `checkChild()`} or {@link #isLimit `isLimit()`}) * in other cases. */ getDefinitions() { if (!this._compiledDefinitions) { this._compile(); } return this._compiledDefinitions; } /** * Returns a definition of the given item or `undefined` if an item is not registered. * * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element, * checking a list of all block elements, etc). * Use specific methods (such as {@link #checkChild `checkChild()`} or {@link #isLimit `isLimit()`}) * in other cases. */ getDefinition(item) { let itemName; if (typeof item == 'string') { itemName = item; } else if ('is' in item && (item.is('$text') || item.is('$textProxy'))) { itemName = '$text'; } // Element or module:engine/model/schema~ModelSchemaContextItem. else { itemName = item.name; } return this.getDefinitions()[itemName]; } /** * Returns `true` if the given item is registered in the schema. * * ```ts * schema.isRegistered( 'paragraph' ); // -> true * schema.isRegistered( editor.model.document.getRoot() ); // -> true * schema.isRegistered( 'foo' ); // -> false * ``` */ isRegistered(item) { return !!this.getDefinition(item); } /** * Returns `true` if the given item is defined to be * a block by the {@link module:engine/model/schema~ModelSchemaItemDefinition}'s `isBlock` property. * * ```ts * schema.isBlock( 'paragraph' ); // -> true * schema.isBlock( '$root' ); // -> false * * const paragraphElement = writer.createElement( 'paragraph' ); * schema.isBlock( paragraphElement ); // -> true * ``` * * See the {@glink framework/deep-dive/schema#block-elements Block elements} section of * the {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ isBlock(item) { const def = this.getDefinition(item); return !!(def && def.isBlock); } /** * Returns `true` if the given item should be treated as a limit element. * * It considers an item to be a limit element if its * {@link module:engine/model/schema~ModelSchemaItemDefinition}'s * {@link module:engine/model/schema~ModelSchemaItemDefinition#isLimit `isLimit`} or * {@link module:engine/model/schema~ModelSchemaItemDefinition#isObject `isObject`} property * was set to `true`. * * ```ts * schema.isLimit( 'paragraph' ); // -> false * schema.isLimit( '$root' ); // -> true * schema.isLimit( editor.model.document.getRoot() ); // -> true * schema.isLimit( 'imageBlock' ); // -> true * ``` * * See the {@glink framework/deep-dive/schema#limit-elements Limit elements} section of * the {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ isLimit(item) { const def = this.getDefinition(item); if (!def) { return false; } return !!(def.isLimit || def.isObject); } /** * Returns `true` if the given item should be treated as an object element. * * It considers an item to be an object element if its * {@link module:engine/model/schema~ModelSchemaItemDefinition}'s * {@link module:engine/model/schema~ModelSchemaItemDefinition#isObject `isObject`} property * was set to `true`. * * ```ts * schema.isObject( 'paragraph' ); // -> false * schema.isObject( 'imageBlock' ); // -> true * * const imageElement = writer.createElement( 'imageBlock' ); * schema.isObject( imageElement ); // -> true * ``` * * See the {@glink framework/deep-dive/schema#object-elements Object elements} section of * the {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ isObject(item) { const def = this.getDefinition(item); if (!def) { return false; } // Note: Check out the implementation of #isLimit(), #isSelectable(), and #isContent() // to understand why these three constitute an object. return !!(def.isObject || (def.isLimit && def.isSelectable && def.isContent)); } /** * Returns `true` if the given item is defined to be * an inline element by the {@link module:engine/model/schema~ModelSchemaItemDefinition}'s `isInline` property. * * ```ts * schema.isInline( 'paragraph' ); // -> false * schema.isInline( 'softBreak' ); // -> true * * const text = writer.createText( 'foo' ); * schema.isInline( text ); // -> true * ``` * * See the {@glink framework/deep-dive/schema#inline-elements Inline elements} section of * the {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ isInline(item) { const def = this.getDefinition(item); return !!(def && def.isInline); } /** * Returns `true` if the given item is defined to be * a selectable element by the {@link module:engine/model/schema~ModelSchemaItemDefinition}'s `isSelectable` property. * * ```ts * schema.isSelectable( 'paragraph' ); // -> false * schema.isSelectable( 'heading1' ); // -> false * schema.isSelectable( 'imageBlock' ); // -> true * schema.isSelectable( 'tableCell' ); // -> true * * const text = writer.createText( 'foo' ); * schema.isSelectable( text ); // -> false * ``` * * See the {@glink framework/deep-dive/schema#selectable-elements Selectable elements section} of * the {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ isSelectable(item) { const def = this.getDefinition(item); if (!def) { return false; } return !!(def.isSelectable || def.isObject); } /** * Returns `true` if the given item is defined to be * a content by the {@link module:engine/model/schema~ModelSchemaItemDefinition}'s `isContent` property. * * ```ts * schema.isContent( 'paragraph' ); // -> false * schema.isContent( 'heading1' ); // -> false * schema.isContent( 'imageBlock' ); // -> true * schema.isContent( 'horizontalLine' ); // -> true * * const text = writer.createText( 'foo' ); * schema.isContent( text ); // -> true * ``` * * See the {@glink framework/deep-dive/schema#content-elements Content elements section} of * the {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ isContent(item) { const def = this.getDefinition(item); if (!def) { return false; } return !!(def.isContent || def.isObject); } /** * Checks whether the given node can be a child of the given context. * * ```ts * schema.checkChild( model.document.getRoot(), paragraph ); // -> false * * schema.register( 'paragraph', { * allowIn: '$root' * } ); * * schema.checkChild( model.document.getRoot(), paragraph ); // -> true * ``` * * Both {@link module:engine/model/schema~ModelSchema#addChildCheck callback checks} and declarative rules (added when * {@link module:engine/model/schema~ModelSchema#register registering} and * {@link module:engine/model/schema~ModelSchema#extend extending} items) * are evaluated when this method is called. * * Note that callback checks have bigger priority than declarative rules checks and may overwrite them. * * Note that when verifying whether the given node can be a child of the given context, the schema also verifies the entire * context &ndash; from its root to its last element. Therefore, it is possible for `checkChild()` to return `false` even though * the `context` last element can contain the checked child. It happens if one of the `context` elements does not allow its child. * When `context` is verified, {@link module:engine/model/schema~ModelSchema#addChildCheck custom checks} are considered as well. * * @fires checkChild * @param context The context in which the child will be checked. * @param def The child to check. */ checkChild(context, def) { // Note: `context` and `def` are already normalized here to `ModelSchemaContext` and `ModelSchemaCompiledItemDefinition`. if (!def) { return false; } return this._checkContextMatch(context, def); } /** * Checks whether the given attribute can be applied in the given context (on the last item of the context). * * ```ts * schema.checkAttribute( textNode, 'bold' ); // -> false * * schema.extend( '$text', { * allowAttributes: 'bold' * } ); * * schema.checkAttribute( textNode, 'bold' ); // -> true * ``` * * Both {@link module:engine/model/schema~ModelSchema#addAttributeCheck callback checks} and declarative rules (added when * {@link module:engine/model/schema~ModelSchema#register registering} and * {@link module:engine/model/schema~ModelSchema#extend extending} items) * are evaluated when this method is called. * * Note that callback checks have bigger priority than declarative rules checks and may overwrite them. * * @fires checkAttribute * @param context The context in which the attribute will be checked. * @param attributeName Name of attribute to check in the given context. */ checkAttribute(context, attributeName) { // Note: `context` is already normalized here to `ModelSchemaContext`. const def = this.getDefinition(context.last); if (!def) { return false; } // First, check all attribute checks declared as callbacks. // Note that `_evaluateAttributeChecks()` will return `undefined` if neither child check was applicable (no decision was made). const isAllowed = this._evaluateAttributeChecks(context, attributeName); // If the decision was not made inside attribute check callbacks, then use declarative rules. return isAllowed !== undefined ? isAllowed : def.allowAttributes.includes(attributeName); } /** * Checks whether the given element (`elementToMerge`) can be merged with the specified base element (`positionOrBaseElement`). * * In other words &ndash; both elements are not a limit elements and whether `elementToMerge`'s children * {@link #checkChild are allowed} in the `positionOrBaseElement`. * * This check ensures that elements merged with {@link module:engine/model/writer~ModelWriter#merge `Writer#merge()`} * will be valid. * * Instead of elements, you can pass the instance of the {@link module:engine/model/position~ModelPosition} class as the * `positionOrBaseElement`. It means that the elements before and after the position will be checked whether they can be merged. * * @param positionOrBaseElement The position or base element to which the `elementToMerge` will be merged. * @param elementToMerge The element to merge. Required if `positionOrBaseElement` is an element. */ checkMerge(positionOrBaseElement, elementToMerge) { if (positionOrBaseElement instanceof ModelPosition) { const nodeBefore = positionOrBaseElement.nodeBefore; const nodeAfter = positionOrBaseElement.nodeAfter; if (!(nodeBefore instanceof ModelElement)) { /** * The node before the merge position must be an element. * * @error schema-check-merge-no-element-before */ throw new CKEditorError('schema-check-merge-no-element-before', this); } if (!(nodeAfter instanceof ModelElement)) { /** * The node after the merge position must be an element. * * @error schema-check-merge-no-element-after */ throw new CKEditorError('schema-check-merge-no-element-after', this); } return this.checkMerge(nodeBefore, nodeAfter); } if (this.isLimit(positionOrBaseElement) || this.isLimit(elementToMerge)) { return false; } for (const child of elementToMerge.getChildren()) { if (!this.checkChild(positionOrBaseElement, child)) { return false; } } return true; } /** * Allows registering a callback to the {@link #checkChild} method calls. * * Callbacks allow you to implement rules which are not otherwise possible to achieve * by using the declarative API of {@link module:engine/model/schema~ModelSchemaItemDefinition}. * * Note that callback checks have bigger priority than declarative rules checks and may overwrite them. * * For example, by using this method you can disallow elements in specific contexts: * * ```ts * // Disallow `heading1` inside a `blockQuote` that is inside a table. * schema.addChildCheck( ( context, childDefinition ) => { * if ( context.endsWith( 'tableCell blockQuote' ) ) { * return false; * } * }, 'heading1' ); * ``` * * You can skip the optional `itemName` parameter to evaluate the callback for every `checkChild()` call. * * ```ts * // Inside specific custom element, allow only children, which allows for a specific attribute. * schema.addChildCheck( ( context, childDefinition ) => { * if ( context.endsWith( 'myElement' ) ) { * return childDefinition.allowAttributes.includes( 'myAttribute' ); * } * } ); * ``` * * Please note that the generic callbacks may affect the editor performance and should be avoided if possible. * * When one of the callbacks makes a decision (returns `true` or `false`) the processing is finished and other callbacks are not fired. * Callbacks are fired in the order they were added, however generic callbacks are fired before callbacks added for a specified item. * * You can also use `checkChild` event, if you need even better control. The result from the example above could also be * achieved with following event callback: * * ```ts * schema.on( 'checkChild', ( evt, args ) => { * const context = args[ 0 ]; * const childDefinition = args[ 1 ]; * * if ( context.endsWith( 'myElement' ) ) { * // Prevent next listeners from being called. * evt.stop(); * // Set the `checkChild()` return value. * evt.return = childDefinition.allowAttributes.includes( 'myAttribute' ); * } * }, { priority: 'high' } ); * ``` * * Note that the callback checks and declarative rules checks are processed on `normal` priority. * * Adding callbacks this way can also negatively impact editor performance. * * @param callback The callback to be called. It is called with two parameters: * {@link module:engine/model/schema~ModelSchemaContext} (context) instance and * {@link module:engine/model/schema~ModelSchemaCompiledItemDefinition} (definition). The callback may return `true/false` to * override `checkChild()`'s return value. If it does not return a boolean value, the default algorithm (or other callbacks) will define * `checkChild()`'s return value. * @param itemName Name of the schema item for which the callback is registered. If specified, the callback will be run only for * `checkChild()` calls which `def` parameter matches the `itemName`. Otherwise, the callback will run for every `checkChild` call. */ addChildCheck(callback, itemName) { const key = itemName !== undefined ? itemName : this._genericCheckSymbol; const checks = this._customChildChecks.get(key) || []; checks.push(callback); this._customChildChecks.set(key, checks); } /** * Allows registering a callback to the {@link #checkAttribute} method calls. * * Callbacks allow you to implement rules which are not otherwise possible to achieve * by using the declarative API of {@link module:engine/model/schema~ModelSchemaItemDefinition}. * * Note that callback checks have bigger priority than declarative rules checks and may overwrite them. * * For example, by using this method you can disallow setting attributes on nodes in specific contexts: * * ```ts * // Disallow setting `bold` on text inside `heading1` element: * schema.addAttributeCheck( context => { * if ( context.endsWith( 'heading1 $text' ) ) { * return false; * } * }, 'bold' ); * ``` * * You can skip the optional `attributeName` parameter to evaluate the callback for every `checkAttribute()` call. * * ```ts * // Disallow formatting attributes on text inside custom `myTitle` element: * schema.addAttributeCheck( ( context, attributeName ) => { * if ( context.endsWith( 'myTitle $text' ) && schema.getAttributeProperties( attributeName ).isFormatting ) { * return false; * } * } ); * ``` * * Please note that the generic callbacks may affect the editor performance and should be avoided if possible. * * When one of the callbacks makes a decision (returns `true` or `false`) the processing is finished and other callbacks are not fired. * Callbacks are fired in the order they were added, however generic callbacks are fired before callbacks added for a specified item. * * You can also use {@link #event:checkAttribute} event, if you need even better control. The result from the example above could also * be achieved with following event callback: * * ```ts * schema.on( 'checkAttribute', ( evt, args ) => { * const context = args[ 0 ]; * const attributeName = args[ 1 ]; * * if ( context.endsWith( 'myTitle $text' ) && schema.getAttributeProperties( attributeName ).isFormatting ) { * // Prevent next listeners from being called. * evt.stop(); * // Set the `checkAttribute()` return value. * evt.return = false; * } * }, { priority: 'high' } ); * ``` * * Note that the callback checks and declarative rules checks are processed on `normal` priority. * * Adding callbacks this way can also negatively impact editor performance. * * @param callback The callback to be called. It is called with two parameters: * {@link module:engine/model/schema~ModelSchemaContext `context`} and attribute name. The callback may return `true` or `false`, to * override `checkAttribute()`'s return value. If it does not return a boolean value, the default algorithm (or other callbacks) * will define `checkAttribute()`'s return value. * @param attributeName Name of the attribute for which the callback is registered. If specified, the callback will be run only for * `checkAttribute()` calls with matching `attributeName`. Otherwise, the callback will run for every `checkAttribute()` call. */ addAttributeCheck(callback, attributeName) { const key = attributeName !== undefined ? attributeName : this._genericCheckSymbol; const checks = this._customAttributeChecks.get(key) || []; checks.push(callback); this._customAttributeChecks.set(key, checks); } /** * This method allows assigning additional metadata to the model attributes. For example, * {@link module:engine/model/schema~ModelAttributeProperties `AttributeProperties#isFormatting` property} is * used to mark formatting attributes (like `bold` or `italic`). * * ```ts * // Mark bold as a formatting attribute. * schema.setAttributeProperties( 'bold', { * isFormatting: true * } ); * * // Override code not to be considered a formatting markup. * schema.setAttributeProperties( 'code', { * isFormatting: false * } ); * ``` * * Properties are not limited to members defined in the * {@link module:engine/model/schema~ModelAttributeProperties `AttributeProperties` type} and you can also use custom properties: * * ```ts * schema.setAttributeProperties( 'blockQuote', { * customProperty: 'value' * } ); * ``` * * Subsequent calls with the same attribute will extend its custom properties: * * ```ts * schema.setAttributeProperties( 'blockQuote', { * one: 1 * } ); * * schema.setAttributeProperties( 'blockQuote', { * two: 2 * } ); * * console.log( schema.getAttributeProperties( 'blockQuote' ) ); * // Logs: { one: 1, two: 2 } * ``` * * @param attributeName A name of the attribute to receive the properties. * @param properties A dictionary of properties. */ setAttributeProperties(attributeName, properties) { this._attributeProperties[attributeName] = Object.assign(this.getAttributeProperties(attributeName), properties); } /** * Returns properties associated with a given model attribute. See {@link #setAttributeProperties `setAttributeProperties()`}. * * @param attributeName A name of the attribute. */ getAttributeProperties(attributeName) { return this._attributeProperties[attributeName] || Object.create(null); } /** * Returns the lowest {@link module:engine/model/schema~ModelSchema#isLimit limit element} containing the entire * selection/range/position or the root otherwise. * * @param selectionOrRangeOrPosition The selection/range/position to check. * @returns The lowest limit element containing the entire `selectionOrRangeOrPosition`. */ getLimitElement(selectionOrRangeOrPosition) { let element; if (selectionOrRangeOrPosition instanceof ModelPosition) { element = selectionOrRangeOrPosition.parent; } else { const ranges = selectionOrRangeOrPosition instanceof ModelRange ? [selectionOrRangeOrPosition] : Array.from(selectionOrRangeOrPosition.getRanges()); // Find the common ancestor for all selection's ranges. element = ranges .reduce((element, range) => { const rangeCommonAncestor = range.getCommonAncestor(); if (!element) { return rangeCommonAncestor; } return element.getCommonAncestor(rangeCommonAncestor, { includeSelf: true }); }, null); } while (!this.isLimit(element)) { if (element.parent) { element = element.parent; } else { break; } } return element; } /** * Checks whether the attribute is allowed in selection: * * * if the selection is not collapsed, then checks if the attribute is allowed on any of nodes in that range, * * if the selection is collapsed, then checks if on the selection position there's a text with the * specified attribute allowed. * * @param selection Selection which will be checked. * @param attribute The name of the attribute to check. */ checkAttributeInSelection(selection, attribute) { if (selection.isCollapsed) { const firstPosition = selection.getFirstPosition(); const context = [ ...firstPosition.getAncestors(), new ModelText('', selection.getAttributes()) ]; // Check whether schema allows for a text with the attribute in the selection. return this.checkAttribute(context, attribute); } else { const ranges = selection.getRanges(); // For all ranges, check nodes in them until you find a node that is allowed to have the attribute. for (const range of ranges) { for (const value of range) { if (this.checkAttribute(value.item, attribute)) { // If we found a node that is allowed to have the attribute, return true. return true; } } } } // If we haven't found such node, return false. return false; } /** * Transforms the given set of ranges into a set of ranges where the given attribute is allowed (and can be applied). * * @param ranges Ranges to be validated. * @param attribute The name of the attribute to check. * @returns Ranges in which the attribute is allowed. */ *getValidRanges(ranges, attribute) { ranges = convertToMinimalFlatRanges(ranges); for (const range of ranges) { yield* this._getValidRangesForRange(range, attribute); } } /** * Basing on given `position`, finds and returns a {@link module:engine/model/range~ModelRange range} which is * nearest to that `position` and is a correct range for selection. * * The correct selection range might be collapsed when it is located in a position where the text node can be placed. * Non-collapsed range is returned when selection can be placed around element marked as an "object" in * the {@link module:engine/model/schema~ModelSchema schema}. * * Direction of searching for the nearest correct selection range can be specified as: * * * `both` - searching will be performed in both ways, * * `forward` - searching will be performed only forward, * * `backward` - searching will be performed only backward. * * When valid selection range cannot be found, `null` is returned. * * @param position Reference position where new selection range should be looked for. * @param direction Search direction. * @returns Nearest selection range or `null` if one cannot be found. */ getNearestSelectionRange(position, direction = 'both') { if (position.root.rootName == '$graveyard') { // No valid selection range in the graveyard. // This is important when getting the document selection default range. return null; } // Return collapsed range if provided position is valid. if (this.checkChild(position, '$text')) { return new ModelRange(position); } let backwardWalker, forwardWalker; // Never leave a limit element. const limitElement = position.getAncestors().reverse().find(item => this.isLimit(item)) || position.root; if (direction == 'both' || direction == 'backward') { backwardWalker = new ModelTreeWalker({ boundaries: ModelRange._createIn(limitElement), startPosition: position, direction: 'backward' }); } if (direction == 'both' || direction == 'forward') { forwardWalker = new ModelTreeWalker({ boundaries: ModelRange._createIn(limitElement), startPosition: position }); } for (const data of combineWalkers(backwardWalker, forwardWalker)) { const type = (data.walker == backwardWalker ? 'elementEnd' : 'elementStart'); const value = data.value; if (value.type == type && this.isObject(value.item)) { return ModelRange._createOn(value.item); } if (this.checkChild(value.nextPosition, '$text')) { return new ModelRange(value.nextPosition); } } return null; } /** * Tries to find position ancestors that allow to insert a given node. * It starts searching from the given position and goes node by node to the top of the model tree * as long as a {@link module:engine/model/schema~ModelSchema#isLimit limit element}, an * {@link module:engine/model/schema~ModelSchema#isObject object element} or a topmost ancestor is not reached. * * @param position The position that the search will start from. * @param node The node for which an allowed parent should be found or its name. * @returns Allowed parent or null if nothing was found. */ findAllowedParent(position, node) { let parent = position.parent; while (parent) { if (this.checkChild(parent, node)) { return parent; } // Do not split limit elements. if (this.isLimit(parent)) { return null; } parent = parent.parent; } return null; } /** * Sets attributes allowed by the schema on a given node. * * @param node A node to set attributes on. * @param attributes Attributes keys and values. * @param writer An instance of the model writer. */ setAllowedAttributes(node, attributes, writer) { const model = writer.model; for (const [attributeName, attributeValue] of Object.entries(attributes)) { if (model.schema.checkAttribute(node, attributeName)) { writer.setAttribute(attributeName, attributeValue, node); } } } /** * Removes attributes disallowed by the schema. * * @param nodes Nodes that will be filtered. */ removeDisallowedAttributes(nodes, writer) { for (const node of nodes) { // When node is a `Text` it has no children, so just filter it out. if (node.is('$text')) { removeDisallowedAttributeFromNode(this, node, writer); } // In a case of `Element` iterates through positions between nodes inside this element // and filter out node before the current position, or position parent when position // is at start of an element. Using positions prevent from omitting merged nodes // see https://github.com/ckeditor/ckeditor5-engine/issues/1789. else { const rangeInNode = ModelRange._createIn(node); const positionsInRange = rangeInNode.getPositions(); for (const position of positionsInRange) { const item = position.nodeBefore || position.parent; removeDisallowedAttributeFromNode(this, item, writer); } } } } /** * Gets attributes of a node that have a given property. * * @param node Node to get attributes from. * @param propertyName Name of the property that attribute must have to return it. * @param propertyValue Desired value of the property that we want to check. * When `undefined` attributes will be returned if they have set a given property no matter what the value is. If specified it will * return attributes which given property's value is equal to this parameter. * @returns Object with attributes' names as key and attributes' values as value. */ getAttributesWithProperty(node, propertyName, propertyValue) { const attributes = {}; for (const [attributeName, attributeValue] of node.getAttributes()) { const attributeProperties = this.getAttributeProperties(attributeName); if (attributeProperties[propertyName] === undefined) { continue; } if (propertyValue === undefined || propertyValue === attributeProperties[propertyName]) { attributes[attributeName] = attributeValue; } } return attributes; } /** * Creates an instance of the schema context. */ createContext(context) { return new ModelSchemaContext(context); } _clearCache() { this._compiledDefinitions = null; } _compile() { const definitions = {}; const sourceRules = this._sourceDefinitions; const itemNames = Object.keys(sourceRules); for (const itemName of itemNames) { definitions[itemName] = compileBaseItemRule(sourceRules[itemName], itemName); } const items = Object.values(definitions); // Sometimes features add rules (allows, disallows) for items that has not been registered yet. We allow that, to make it easier // to put the schema together. However, sometimes these items are never registered. To prevent operating // removeUnregisteredEntries( definitions, items ); // 1. Propagate `childItem.allowIn` to `parentItem.allowChildren` and vice versa, so that these properties are completely mirrored // for all children and parents. Do the same for `disallowIn` and `disallowChildren`. for (const item of items) { propagateAllowIn(definitions, item); propagateAllowChildren(definitions, item); propagateDisallowIn(definitions, item); propagateDisallowChildren(definitions, item); } // 2. Remove from `allowIn` and `allowChildren` these items which where disallowed by `disallowIn` and `disallowChildren`. // Do the same for attributes. Now we have a clear situation where which item/attribute is allowed. Inheritance is in next steps. for (const item of items) { resolveDisallows(definitions, item); } // 3. Compile `item.allowContentOf` property. For each entry in `allowContentOf`, we want to take `allowChildren` and rewrite // them into `item.allowChildren`. `item.disallowChildren` is used to filter out some entries. This way "own rules" have higher // priority than "inherited rules". Mirroring from step 1. is maintained. for (const item of items) { compileAllowContentOf(definitions, item); } // 4. Compile `item.allowWhere` property. For each entry in `allowWhere`, we want to take `allowIn` and rewrite them into // `item.allowIn`. `item.disallowIn` is used to filter out some entries. This way "own rules" have higher priority than // "inherited rules". Mirroring from step 1. is maintained. for (const item of items) { compileAllowWhere(definitions, item); } // 5. Compile `item.allowAttributesOf`. For each entry in `allowAttributesOf`, we want to take `allowAttributes` and rewrite them // into `item.allowAttributes`. `item.disallowAttributes` is used to filter out some entries. This way "own rules" have higher // priority than "inherited rules". for (const item of items) { compileAllowAttributesOf(definitions, item); } // 6. Compile `item.inheritTypesFrom` property. For each entry in `inheritTypesFrom`, we want to take `is*` properties and // set them on `item` (if they are not set yet). for (const item of items) { compileInheritPropertiesFrom(definitions, item); } // Compile final definitions. Unnecessary properties are removed and some additional cleaning is applied. this._compiledDefinitions = compileDefinitions(definitions); } _checkContextMatch(context, def) { const parentItem = context.last; // First, check all child checks declared as callbacks. // Note that `_evaluateChildChecks()` will return `undefined` if neither child check was applicable (no decision was made). let isAllowed = this._evaluateChildChecks(context, def); // If the decision was not made inside child check callbacks, then use declarative rules. isAllowed = isAllowed !== undefined ? isAllowed : def.allowIn.includes(parentItem.name); // If the item is not allowed in the `context`, return `false`. if (!isAllowed) { return false; } // If the item is allowed, recursively verify the rest of the `context`. const parentItemDefinition = this.getDefinition(parentItem); const parentContext = context.trimLast(); // One of the items in the original `context` did not have a definition specified. In this case, the whole context is disallowed. if (!parentItemDefinition) { return false; } // Whole `context` was verified and passed checks. if (parentContext.length == 0) { return true; } // Verify "truncated" parent context. The last item of the original context is now the definition to check. return this._checkContextMatch(parentContext, parentItemDefinition); } /** * Calls child check callbacks to decide whether `def` is allowed in `context`. It uses both generic and specific (defined for `def` * item) callbacks. If neither callback makes a decision, `undefined` is returned. * * Note that the first callback that makes a decision "wins", i.e., if any callback returns `true` or `false`, then the processing * is over and that result is returned. */ _evaluateChildChecks(context, def) { const genericChecks = this._customChildChecks.get(this._genericCheckSymbol) || []; const childChecks = this._customChildChecks.get(def.name) || []; for (const check of [...genericChecks, ...childChecks]) { const result = check(context, def); if (result !== undefined) { return result; } } } /** * Calls attribute check callbacks to decide whether `attributeName` can be set on the last element of `context`. It uses both * generic and specific (defined for `attributeName`) callbacks. If neither callback makes a decision, `undefined` is returned. * * Note that the first callback that makes a decision "wins", i.e., if any callback returns `true` or `false`, then the processing * is over and that result is returned. */ _evaluateAttributeChecks(context, attributeName) { const genericChecks = this._customAttributeChecks.get(this._genericCheckSymbol) || []; const childChecks = this._customAttributeChecks.get(attributeName) || []; for (const check of [...genericChecks, ...childChecks]) { const result = check(context, attributeName); if (result !== undefined) { return result; } } } /** * Takes a flat range and an attribute name. Traverses the range recursively and deeply to find and return all ranges * inside the given range on which the attribute can be applied. * * This is a helper function for {@link ~ModelSchema#getValidRanges}. * * @param range The range to process. * @param attribute The name of the attribute to check. * @returns Ranges in which the attribute is allowed. */ *_getValidRangesForRange(range, attribute) { let start = range.start; let end = range.start; for (const item of range.getItems({ shallow: true })) { if (item.is('element')) { yield* this._getValidRangesForRange(ModelRange._createIn(item), attribute); } if (!this.checkAttribute(item, attribute)) { if (!start.isEqual(end)) { yield new ModelRange(start, end); } start = ModelPosition._createAfter(item); } end = ModelPosition._createAfter(item); } if (!start.isEqual(end)) { yield new ModelRange(start, end); } } /** * Returns a model range which is optimal (in terms of UX) for inserting a widget block. * * For instance, if a selection is in the middle of a paragraph, the collapsed range before this paragraph * will be returned so that it is not split. If the selection is at the end of a paragraph, * the collapsed range after this paragraph will be returned. * * Note: If the selection is placed in an empty block, the range in that block will be returned. If that range * is then passed to {@link module:engine/model/model~Model#insertContent}, the block will be fully replaced * by the inserted widget block. * * @internal * @param selection The selection based on which the insertion position should be calculated. * @param place The place where to look for optimal insertion range. * The `auto` value will determine itself the best position for insertion. * The `before` value will try to find a position before selection. * The `after` value will try to find a position after selection. * @returns The optimal range. */ findOptimalInsertionRange(selection, place) { const selectedElement = selection.getSelectedElement(); if (selectedElement && this.isObject(selectedElement) && !this.isInline(selectedElement)) { if (place == 'before' || place == 'after') { return new ModelRange(ModelPosition._createAt(selectedElement, place)); } return ModelRange._createOn(selectedElement); } const firstBlock = first(selection.getSelectedBlocks()); // There are no block elements within ancestors (in the current limit element). if (!firstBlock) { return new ModelRange(selection.focus); } // If inserting into an empty block – return position in that block. It will get // replaced with the image by insertContent(). #42. if (firstBlock.isEmpty) { return new ModelRange(ModelPosition._createAt(firstBlock, 0)); } const positionAfter = ModelPosition._createAfter(firstBlock); // If selection is at the end of the block - return position after the block. if (selection.focus.isTouching(positionAfter)) { return new ModelRange(positionAfter); } // Otherwise, return position before the block. return new ModelRange(ModelPosition._createBefore(firstBlock)); } } /** * A schema context &ndash; a list of ancestors of a given position in the document. * * Considering such position: * * ```xml * <$root> * <blockQuote> * <paragraph> * ^ * </paragraph> * </blockQuote> * </$root> * ``` * * The context of this position is its {@link module:engine/model/position~ModelPosition#getAncestors lists of ancestors}: * * [ rootElement, blockQuoteElement, paragraphElement ] * * Contexts are used in the {@link module:engine/model/schema~ModelSchema#event:checkChild `Schema#checkChild`} and * {@link module:engine/model/schema~ModelSchema#event:checkAttribute `Schema#checkAttribute`} events as a definition * of a place in the document where the check occurs. The context instances are created based on the first arguments * of the {@link module:engine/model/schema~ModelSchema#checkChild `Schema#checkChild()`} and * {@link module:engine/model/schema~ModelSchema#checkAttribute `Schema#