UNPKG

@ckeditor/ckeditor5-engine

Version:

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

397 lines (396 loc) • 18.2 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/dev-utils/model */ /** * Collection of methods for manipulating the {@link module:engine/model/model model} for testing purposes. */ import { ModelRootElement } from '../model/rootelement.js'; import { Model } from '../model/model.js'; import { ModelRange } from '../model/range.js'; import { ModelPosition } from '../model/position.js'; import { ModelSelection } from '../model/selection.js'; import { ModelDocumentFragment } from '../model/documentfragment.js'; import { ModelDocumentSelection } from '../model/documentselection.js'; import { EditingView } from '../view/view.js'; import { ViewContainerElement } from '../view/containerelement.js'; import { ViewRootEditableElement } from '../view/rooteditableelement.js'; import { _parseView, _stringifyView } from '../../src/dev-utils/view.js'; import { Mapper } from '../conversion/mapper.js'; import { convertCollapsedSelection, convertRangeSelection, insertAttributesAndChildren, insertElement, insertText, insertUIElement, wrap } from '../conversion/downcasthelpers.js'; import { StylesProcessor } from '../view/stylesmap.js'; import { DowncastDispatcher } from '../conversion/downcastdispatcher.js'; import { UpcastDispatcher } from '../conversion/upcastdispatcher.js'; import { toMap } from '@ckeditor/ckeditor5-utils'; import { isPlainObject } from 'es-toolkit/compat'; /** * Writes the content of a model {@link module:engine/model/document~ModelDocument document} to an HTML-like string. * * ```ts * getData( editor.model ); // -> '<paragraph>Foo![]</paragraph>' * ``` * * **Note:** A {@link module:engine/model/text~ModelText text} node that contains attributes will be represented as: * * ```xml * <$text attribute="value">Text data</$text> * ``` * * **Note:** Using this tool in production-grade code is not recommended. It was designed for development, prototyping, * debugging and testing. * * @param options.withoutSelection Whether to write the selection. When set to `true`, the selection will * not be included in the returned string. * @param options.rootName The name of the root from which the data should be stringified. If not provided, * the default `main` name will be used. * @param options.convertMarkers Whether to include markers in the returned string. * @returns The stringified data. */ export function _getModelData(model, options = {}) { if (!(model instanceof Model)) { throw new TypeError('Model needs to be an instance of module:engine/model/model~Model.'); } const rootName = options.rootName || 'main'; const root = model.document.getRoot(rootName); return _getModelData._stringify(root, options.withoutSelection ? null : model.document.selection, options.convertMarkers ? model.markers : null); } // Set stringify as getData private method - needed for testing/spying. _getModelData._stringify = _stringifyModel; /** * Sets the content of a model {@link module:engine/model/document~ModelDocument document} provided as an HTML-like string. * * ```ts * setData( editor.model, '<paragraph>Foo![]</paragraph>' ); * ``` * * **Note:** Remember to register elements in the {@link module:engine/model/model~Model#schema model's schema} before * trying to use them. * * **Note:** To create a {@link module:engine/model/text~ModelText text} node that contains attributes use: * * ```xml * <$text attribute="value">Text data</$text> * ``` * * **Note:** Using this tool in production-grade code is not recommended. It was designed for development, prototyping, * debugging and testing. * * @param data HTML-like string to write into the document. * @param options.rootName Root name where parsed data will be stored. If not provided, the default `main` * name will be used. * @param options.selectionAttributes A list of attributes which will be passed to the selection. * @param options.lastRangeBackward If set to `true`, the last range will be added as backward. * @param options.batchType Batch type used for inserting elements. See {@link module:engine/model/batch~Batch#constructor}. */ export function _setModelData(model, data, options = {}) { if (!(model instanceof Model)) { throw new TypeError('Model needs to be an instance of module:engine/model/model~Model.'); } let modelDocumentFragment; let selection = null; const modelRoot = model.document.getRoot(options.rootName || 'main'); // Parse data string to model. const parsedResult = _setModelData._parse(data, model.schema, { lastRangeBackward: options.lastRangeBackward, selectionAttributes: options.selectionAttributes, context: [modelRoot.name], inlineObjectElements: options.inlineObjectElements }); // Retrieve DocumentFragment and Selection from parsed model. if ('model' in parsedResult) { modelDocumentFragment = parsedResult.model; selection = parsedResult.selection; } else { modelDocumentFragment = parsedResult; } if (options.batchType !== undefined) { model.enqueueChange(options.batchType, writeToModel); } else { model.change(writeToModel); } function writeToModel(writer) { // Replace existing model in document by new one. writer.remove(writer.createRangeIn(modelRoot)); writer.insert(modelDocumentFragment, modelRoot); // Clean up previous document selection. writer.setSelection(null); writer.removeSelectionAttribute(model.document.selection.getAttributeKeys()); // Update document selection if specified. if (selection) { const ranges = []; for (const range of selection.getRanges()) { const start = new ModelPosition(modelRoot, range.start.path); const end = new ModelPosition(modelRoot, range.end.path); ranges.push(new ModelRange(start, end)); } writer.setSelection(ranges, { backward: selection.isBackward }); if (options.selectionAttributes) { writer.setSelectionAttribute(selection.getAttributes()); } } } } // Set parse as setData private method - needed for testing/spying. _setModelData._parse = _parseModel; /** * Converts model nodes to HTML-like string representation. * * **Note:** A {@link module:engine/model/text~ModelText text} node that contains attributes will be represented as: * * ```xml * <$text attribute="value">Text data</$text> * ``` * * @param node A node to stringify. * @param selectionOrPositionOrRange A selection instance whose ranges will be included in the returned string data. * If a range instance is provided, it will be converted to a selection containing this range. If a position instance * is provided, it will be converted to a selection containing one range collapsed at this position. * @param markers Markers to include. * @returns An HTML-like string representing the model. */ export function _stringifyModel(node, selectionOrPositionOrRange = null, markers = null) { const model = new Model(); const mapper = new Mapper(); let selection = null; let range; // Create a range witch wraps passed node. if (node instanceof ModelRootElement || node instanceof ModelDocumentFragment) { range = model.createRangeIn(node); } else { // Node is detached - create new document fragment. if (!node.parent) { const fragment = new ModelDocumentFragment(node); range = model.createRangeIn(fragment); } else { range = new ModelRange(model.createPositionBefore(node), model.createPositionAfter(node)); } } // Get selection from passed selection or position or range if at least one is specified. if (selectionOrPositionOrRange instanceof ModelSelection) { selection = selectionOrPositionOrRange; } else if (selectionOrPositionOrRange instanceof ModelDocumentSelection) { selection = selectionOrPositionOrRange; } else if (selectionOrPositionOrRange instanceof ModelRange) { selection = new ModelSelection(selectionOrPositionOrRange); } else if (selectionOrPositionOrRange instanceof ModelPosition) { selection = new ModelSelection(selectionOrPositionOrRange); } // Set up conversion. // Create a temporary view controller. const stylesProcessor = new StylesProcessor(); const view = new EditingView(stylesProcessor); const viewDocument = view.document; const viewRoot = new ViewRootEditableElement(viewDocument, 'div'); // Create a temporary root element in view document. viewRoot.rootName = 'main'; viewDocument.roots.add(viewRoot); // Create and setup downcast dispatcher. const downcastDispatcher = new DowncastDispatcher({ mapper, schema: model.schema }); // Bind root elements. mapper.bindElements(node.root, viewRoot); downcastDispatcher.on('insert:$text', insertText()); downcastDispatcher.on('insert', insertAttributesAndChildren(), { priority: 'lowest' }); downcastDispatcher.on('attribute', (evt, data, conversionApi) => { if (data.item instanceof ModelSelection || data.item instanceof ModelDocumentSelection || data.item.is('$textProxy')) { const converter = wrap((modelAttributeValue, { writer }) => { return writer.createAttributeElement('model-text-with-attributes', { [data.attributeKey]: stringifyAttributeValue(modelAttributeValue) }); }); converter(evt, data, conversionApi); } }); downcastDispatcher.on('insert', insertElement(modelItem => { // Stringify object types values for properly display as an output string. const attributes = convertAttributes(modelItem.getAttributes(), stringifyAttributeValue); return new ViewContainerElement(viewDocument, modelItem.name, attributes); })); downcastDispatcher.on('selection', convertRangeSelection()); downcastDispatcher.on('selection', convertCollapsedSelection()); downcastDispatcher.on('addMarker', insertUIElement((data, { writer }) => { const name = data.markerName + ':' + (data.isOpening ? 'start' : 'end'); return writer.createUIElement(name); })); const markersMap = new Map(); if (markers) { // To provide stable results, sort markers by name. for (const marker of Array.from(markers).sort((a, b) => a.name < b.name ? 1 : -1)) { markersMap.set(marker.name, marker.getRange()); } } // Convert model to view. const writer = view._writer; downcastDispatcher.convert(range, markersMap, writer); // Convert model selection to view selection. if (selection) { downcastDispatcher.convertSelection(selection, markers || model.markers, writer); } // Parse view to data string. let data = _stringifyView(viewRoot, viewDocument.selection, { sameSelectionCharacters: true }); // Removing unnecessary <div> and </div> added because `viewRoot` was also stringified alongside input data. data = data.substr(5, data.length - 11); view.destroy(); // Replace valid XML `model-text-with-attributes` element name to `$text`. return data.replace(new RegExp('model-text-with-attributes', 'g'), '$text'); } /** * Parses an HTML-like string and returns the model {@link module:engine/model/rootelement~ModelRootElement rootElement}. * * **Note:** To create a {@link module:engine/model/text~ModelText text} node that contains attributes use: * * ```xml * <$text attribute="value">Text data</$text> * ``` * * @param data HTML-like string to be parsed. * @param schema A schema instance used by converters for element validation. * @param options Additional configuration. * @param options.selectionAttributes A list of attributes which will be passed to the selection. * @param options.lastRangeBackward If set to `true`, the last range will be added as backward. * @param options.context The conversion context. If not provided, the default `'$root'` will be used. * @returns Returns the parsed model node or an object with two fields: `model` and `selection`, * when selection ranges were included in the data to parse. */ export function _parseModel(data, schema, options = {}) { const mapper = new Mapper(); // Replace not accepted by XML `$text` tag name by valid one `model-text-with-attributes`. data = data.replace(new RegExp('\\$text', 'g'), 'model-text-with-attributes'); // Parse data to view using view utils. const parsedResult = _parseView(data, { sameSelectionCharacters: true, lastRangeBackward: !!options.lastRangeBackward, inlineObjectElements: options.inlineObjectElements }); // Retrieve DocumentFragment and Selection from parsed view. let viewDocumentFragment; let viewSelection = null; let selection = null; if ('view' in parsedResult && 'selection' in parsedResult) { viewDocumentFragment = parsedResult.view; viewSelection = parsedResult.selection; } else { viewDocumentFragment = parsedResult; } // Set up upcast dispatcher. const modelController = new Model(); const upcastDispatcher = new UpcastDispatcher({ schema }); upcastDispatcher.on('documentFragment', convertToModelFragment(mapper)); upcastDispatcher.on('element:model-text-with-attributes', convertToModelText()); upcastDispatcher.on('element', convertToModelElement(mapper)); upcastDispatcher.on('text', convertToModelText()); // Convert view to model. let model = modelController.change(writer => upcastDispatcher.convert(viewDocumentFragment.root, writer, options.context || '$root')); mapper.bindElements(model, viewDocumentFragment.root); // If root DocumentFragment contains only one element - return that element. if (model.childCount == 1) { model = model.getChild(0); } // Convert view selection to model selection. if (viewSelection) { const ranges = []; // Convert ranges. for (const viewRange of viewSelection.getRanges()) { ranges.push(mapper.toModelRange(viewRange)); } // Create new selection. selection = new ModelSelection(ranges, { backward: viewSelection.isBackward }); // Set attributes to selection if specified. for (const [key, value] of toMap(options.selectionAttributes || [])) { selection.setAttribute(key, value); } } // Return model end selection when selection was specified. if (selection) { return { model, selection }; } // Otherwise return model only. return model; } // -- Converters view -> model ----------------------------------------------------- function convertToModelFragment(mapper) { return (evt, data, conversionApi) => { const childrenResult = conversionApi.convertChildren(data.viewItem, data.modelCursor); mapper.bindElements(data.modelCursor.parent, data.viewItem); data = Object.assign(data, childrenResult); evt.stop(); }; } function convertToModelElement(mapper) { return (evt, data, conversionApi) => { const elementName = data.viewItem.name; if (!conversionApi.schema.checkChild(data.modelCursor, elementName)) { throw new Error(`Element '${elementName}' was not allowed in given position.`); } // View attribute value is a string so we want to typecast it to the original type. // E.g. `bold="true"` - value will be parsed from string `"true"` to boolean `true`. const attributes = convertAttributes(data.viewItem.getAttributes(), parseAttributeValue); const element = conversionApi.writer.createElement(data.viewItem.name, attributes); conversionApi.writer.insert(element, data.modelCursor); mapper.bindElements(element, data.viewItem); conversionApi.convertChildren(data.viewItem, element); data.modelRange = ModelRange._createOn(element); data.modelCursor = data.modelRange.end; evt.stop(); }; } function convertToModelText() { return (evt, data, conversionApi) => { if (!conversionApi.schema.checkChild(data.modelCursor, '$text')) { throw new Error('Text was not allowed in given position.'); } let node; if (data.viewItem.is('element')) { // View attribute value is a string so we want to typecast it to the original type. // E.g. `bold="true"` - value will be parsed from string `"true"` to boolean `true`. const attributes = convertAttributes(data.viewItem.getAttributes(), parseAttributeValue); const viewText = data.viewItem.getChild(0); node = conversionApi.writer.createText(viewText.data, attributes); } else { node = conversionApi.writer.createText(data.viewItem.data); } conversionApi.writer.insert(node, data.modelCursor); data.modelRange = ModelRange._createFromPositionAndShift(data.modelCursor, node.offsetSize); data.modelCursor = data.modelRange.end; evt.stop(); }; } // Tries to get original type of attribute value using JSON parsing: // // `'true'` => `true` // `'1'` => `1` // `'{"x":1,"y":2}'` => `{ x: 1, y: 2 }` // // Parse error means that value should be a string: // // `'foobar'` => `'foobar'` function parseAttributeValue(attribute) { try { return JSON.parse(attribute); } catch { return attribute; } } // When value is an Object stringify it. function stringifyAttributeValue(data) { if (isPlainObject(data)) { return JSON.stringify(data); } return data; } // Loop trough attributes map and converts each value by passed converter. function* convertAttributes(attributes, converter) { for (const [key, value] of attributes) { yield [key, converter(value)]; } }