UNPKG

@ckeditor/ckeditor5-html-support

Version:

HTML Support feature for CKEditor 5.

210 lines (209 loc) • 8.78 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 */ import { Plugin } from 'ckeditor5/src/core.js'; import { updateViewAttributes } from '../utils.js'; import { DataFilter } from '../datafilter.js'; import { getDescendantElement } from './integrationutils.js'; const STYLE_ATTRIBUTES_TO_PROPAGATE = [ 'width', 'max-width', 'min-width', 'height', 'min-height', 'max-height' ]; /** * Provides the General HTML Support integration with {@link module:table/table~Table Table} feature. */ export class TableElementSupport extends Plugin { /** * @inheritDoc */ static get requires() { return [DataFilter]; } /** * @inheritDoc */ static get pluginName() { return 'TableElementSupport'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ init() { const editor = this.editor; if (!editor.plugins.has('TableEditing')) { return; } const schema = editor.model.schema; const conversion = editor.conversion; const dataFilter = editor.plugins.get(DataFilter); const tableUtils = editor.plugins.get('TableUtils'); dataFilter.on('register:figure', () => { conversion.for('upcast').add(viewToModelFigureAttributeConverter(dataFilter)); }); dataFilter.on('register:table', (evt, definition) => { if (definition.model !== 'table') { return; } schema.extend('table', { allowAttributes: [ 'htmlTableAttributes', // Figure, thead and tbody elements don't have model counterparts. // We will be preserving attributes on table element using these attribute keys. 'htmlFigureAttributes', 'htmlTheadAttributes', 'htmlTbodyAttributes' ] }); conversion.for('upcast').add(viewToModelTableAttributeConverter(dataFilter)); conversion.for('downcast').add(modelToViewTableAttributeConverter()); editor.model.document.registerPostFixer(createHeadingRowsPostFixer(editor.model, tableUtils)); evt.stop(); }); } } /** * Creates a model post-fixer for thead and tbody GHS related attributes. */ function createHeadingRowsPostFixer(model, tableUtils) { return writer => { const changes = model.document.differ.getChanges(); let wasFixed = false; for (const change of changes) { if (change.type != 'attribute' || change.attributeKey != 'headingRows') { continue; } const table = change.range.start.nodeAfter; const hasTHeadAttributes = table.getAttribute('htmlTheadAttributes'); const hasTBodyAttributes = table.getAttribute('htmlTbodyAttributes'); if (hasTHeadAttributes && !change.attributeNewValue) { writer.removeAttribute('htmlTheadAttributes', table); wasFixed = true; } else if (hasTBodyAttributes && change.attributeNewValue == tableUtils.getRows(table)) { writer.removeAttribute('htmlTbodyAttributes', table); wasFixed = true; } } return wasFixed; }; } /** * View-to-model conversion helper preserving allowed attributes on {@link module:table/table~Table Table} * feature model element. * * @returns Returns a conversion callback. */ function viewToModelTableAttributeConverter(dataFilter) { return (dispatcher) => { dispatcher.on('element:table', (evt, data, conversionApi) => { if (!data.modelRange) { return; } const viewTableElement = data.viewItem; // Prevent `table` class on both <table> and <figure> elements simultaneously. conversionApi.consumable.consume(viewTableElement, { classes: 'table' }); preserveElementAttributes(viewTableElement, 'htmlTableAttributes'); for (const childNode of viewTableElement.getChildren()) { if (childNode.is('element', 'thead')) { preserveElementAttributes(childNode, 'htmlTheadAttributes'); } if (childNode.is('element', 'tbody')) { preserveElementAttributes(childNode, 'htmlTbodyAttributes'); } } function preserveElementAttributes(viewElement, attributeName) { const viewAttributes = dataFilter.processViewAttributes(viewElement, conversionApi); if (viewAttributes) { conversionApi.writer.setAttribute(attributeName, viewAttributes, data.modelRange); } } }, { priority: 'low' }); }; } /** * View-to-model conversion helper preserving allowed attributes on {@link module:table/table~Table Table} * feature model element from figure view element. * * @returns Returns a conversion callback. */ function viewToModelFigureAttributeConverter(dataFilter) { return (dispatcher) => { dispatcher.on('element:figure', (evt, data, conversionApi) => { const viewFigureElement = data.viewItem; if (!data.modelRange || !viewFigureElement.hasClass('table')) { return; } const viewAttributes = dataFilter.processViewAttributes(viewFigureElement, conversionApi); if (viewAttributes) { conversionApi.writer.setAttribute('htmlFigureAttributes', viewAttributes, data.modelRange); } }, { priority: 'low' }); }; } /** * Model-to-view conversion helper applying attributes from {@link module:table/table~Table Table} * feature. * * @returns Returns a conversion callback. */ function modelToViewTableAttributeConverter() { return (dispatcher) => { addAttributeConversionDispatcherHandler('table', 'htmlTableAttributes'); addAttributeConversionDispatcherHandler('figure', 'htmlFigureAttributes'); addAttributeConversionDispatcherHandler('thead', 'htmlTheadAttributes'); addAttributeConversionDispatcherHandler('tbody', 'htmlTbodyAttributes'); function addAttributeConversionDispatcherHandler(elementName, attributeName) { dispatcher.on(`attribute:${attributeName}:table`, (evt, data, conversionApi) => { if (!conversionApi.consumable.test(data.item, evt.name)) { return; } const containerElement = conversionApi.mapper.toViewElement(data.item); const viewElement = getDescendantElement(conversionApi.writer, containerElement, elementName); if (!viewElement) { return; } conversionApi.consumable.consume(data.item, evt.name); // Downcast selected styles to a figure element instead of a table element. if (attributeName === 'htmlTableAttributes' && containerElement !== viewElement) { const oldAttributes = splitAttributesForFigureAndTable(data.attributeOldValue); const newAttributes = splitAttributesForFigureAndTable(data.attributeNewValue); updateViewAttributes(conversionApi.writer, oldAttributes.tableAttributes, newAttributes.tableAttributes, viewElement); updateViewAttributes(conversionApi.writer, oldAttributes.figureAttributes, newAttributes.figureAttributes, containerElement); } else { updateViewAttributes(conversionApi.writer, data.attributeOldValue, data.attributeNewValue, viewElement); } }); } }; } /** * Splits styles based on the `STYLE_ATTRIBUTES_TO_PROPAGATE` pattern that should be moved to the parent element * and those that should remain on element. */ function splitAttributesForFigureAndTable(data) { const figureAttributes = {}; const tableAttributes = { ...data }; if (!data || !('styles' in data)) { return { figureAttributes, tableAttributes }; } tableAttributes.styles = {}; for (const [key, value] of Object.entries(data.styles)) { if (STYLE_ATTRIBUTES_TO_PROPAGATE.includes(key)) { figureAttributes.styles = { ...figureAttributes.styles, [key]: value }; } else { tableAttributes.styles = { ...tableAttributes.styles, [key]: value }; } } return { figureAttributes, tableAttributes }; }