UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

547 lines (546 loc) • 22.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 table/tableproperties/tablepropertiesediting */ import { Plugin } from 'ckeditor5/src/core.js'; import { addBackgroundStylesRules, addBorderStylesRules, addMarginStylesRules } from 'ckeditor5/src/engine.js'; import { first } from 'ckeditor5/src/utils.js'; import { TableEditing } from '../tableediting.js'; import { downcastAttributeToStyle, downcastTableAttribute, getDefaultValueAdjusted, upcastBorderStyles, upcastStyleToAttribute, upcastTableAlignmentConfig, DEFAULT_TABLE_ALIGNMENT_OPTIONS } from '../converters/tableproperties.js'; import { TableBackgroundColorCommand } from './commands/tablebackgroundcolorcommand.js'; import { TableBorderColorCommand } from './commands/tablebordercolorcommand.js'; import { TableBorderStyleCommand } from './commands/tableborderstylecommand.js'; import { TableBorderWidthCommand } from './commands/tableborderwidthcommand.js'; import { TableWidthCommand } from './commands/tablewidthcommand.js'; import { TableHeightCommand } from './commands/tableheightcommand.js'; import { TableAlignmentCommand } from './commands/tablealignmentcommand.js'; import { getNormalizedDefaultTableProperties } from '../utils/table-properties.js'; import { getViewTableFromWrapper } from '../utils/structure.js'; /** * The table properties editing feature. * * Introduces table's model attributes and their conversion: * * - border: `tableBorderStyle`, `tableBorderColor` and `tableBorderWidth` * - background color: `tableBackgroundColor` * - horizontal alignment: `tableAlignment` * - width & height: `tableWidth` & `tableHeight` * * It also registers commands used to manipulate the above attributes: * * - border: `'tableBorderStyle'`, `'tableBorderColor'` and `'tableBorderWidth'` commands * - background color: `'tableBackgroundColor'` * - horizontal alignment: `'tableAlignment'` * - width & height: `'tableWidth'` & `'tableHeight'` */ export class TablePropertiesEditing extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'TablePropertiesEditing'; } /** * @inheritDoc * @internal */ static get licenseFeatureCode() { return 'TCP'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get isPremiumPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [TableEditing]; } /** * @inheritDoc */ init() { const editor = this.editor; const schema = editor.model.schema; const conversion = editor.conversion; editor.config.define('table.tableProperties.defaultProperties', {}); const defaultTableProperties = getNormalizedDefaultTableProperties(editor.config.get('table.tableProperties.defaultProperties'), { includeAlignmentProperty: true }); const useInlineStyles = editor.config.get('table.tableProperties.alignment.useInlineStyles') !== false; editor.data.addStyleProcessorRules(addMarginStylesRules); editor.data.addStyleProcessorRules(addBorderStylesRules); enableBorderProperties(editor, { color: defaultTableProperties.borderColor, style: defaultTableProperties.borderStyle, width: defaultTableProperties.borderWidth }); editor.commands.add('tableBorderColor', new TableBorderColorCommand(editor, defaultTableProperties.borderColor)); editor.commands.add('tableBorderStyle', new TableBorderStyleCommand(editor, defaultTableProperties.borderStyle)); editor.commands.add('tableBorderWidth', new TableBorderWidthCommand(editor, defaultTableProperties.borderWidth)); if (editor.config.get('experimentalFlags.useExtendedTableBlockAlignment')) { enableExtendedAlignmentProperty(schema, conversion, defaultTableProperties.alignment, useInlineStyles); } else { enableAlignmentProperty(schema, conversion, defaultTableProperties.alignment); } editor.commands.add('tableAlignment', new TableAlignmentCommand(editor, defaultTableProperties.alignment)); enableTableToFigureProperty(schema, conversion, { modelAttribute: 'tableWidth', styleName: 'width', attributeName: 'width', attributeType: 'length', defaultValue: defaultTableProperties.width }); editor.commands.add('tableWidth', new TableWidthCommand(editor, defaultTableProperties.width)); enableTableToFigureProperty(schema, conversion, { modelAttribute: 'tableHeight', styleName: 'height', attributeName: 'height', attributeType: 'length', defaultValue: defaultTableProperties.height }); editor.commands.add('tableHeight', new TableHeightCommand(editor, defaultTableProperties.height)); editor.data.addStyleProcessorRules(addBackgroundStylesRules); enableProperty(schema, conversion, { modelAttribute: 'tableBackgroundColor', styleName: 'background-color', attributeName: 'bgcolor', attributeType: 'color', defaultValue: defaultTableProperties.backgroundColor }); editor.commands.add('tableBackgroundColor', new TableBackgroundColorCommand(editor, defaultTableProperties.backgroundColor)); if (editor.config.get('experimentalFlags.useExtendedTableBlockAlignment')) { const viewDoc = editor.editing.view.document; // Adjust clipboard output to wrap tables in divs if needed (for alignment). this.listenTo(viewDoc, 'clipboardOutput', (evt, data) => { editor.editing.view.change(writer => { for (const { item } of writer.createRangeIn(data.content)) { wrapInDivIfNeeded(item, writer); } data.dataTransfer.setData('text/html', this.editor.data.htmlProcessor.toData(data.content)); }); }, { priority: 'lowest' }); } } } /** * Checks whether the view element is a table and if it needs to be wrapped in a div for alignment purposes. * If so, it wraps it in a div and inserts it into the data content. */ function wrapInDivIfNeeded(viewItem, writer) { if (!viewItem.is('element', 'table')) { return; } const alignAttribute = viewItem.getAttribute('align'); const floatAttribute = viewItem.getStyle('float'); const marginLeft = viewItem.getStyle('margin-left'); const marginRight = viewItem.getStyle('margin-right'); if ( // Align center. (alignAttribute && alignAttribute === 'center') || // Align right with text wrapping. (floatAttribute && floatAttribute === 'right' && alignAttribute && alignAttribute === 'right')) { insertWrapperWithAlignment(writer, alignAttribute, viewItem); return; } // Align right with no text wrapping. if (floatAttribute === undefined && marginLeft === 'auto' && marginRight === '0') { insertWrapperWithAlignment(writer, 'right', viewItem); } } function insertWrapperWithAlignment(writer, align, table) { const position = writer.createPositionBefore(table); const wrapper = writer.createContainerElement('div', { align }, table); writer.insert(position, wrapper); } /** * Enables `tableBorderStyle'`, `tableBorderColor'` and `tableBorderWidth'` attributes for table. * * @param defaultBorder The default border values. * @param defaultBorder.color The default `tableBorderColor` value. * @param defaultBorder.style The default `tableBorderStyle` value. * @param defaultBorder.width The default `tableBorderWidth` value. */ function enableBorderProperties(editor, defaultBorder) { const { conversion } = editor; const { schema } = editor.model; const modelAttributes = { width: 'tableBorderWidth', color: 'tableBorderColor', style: 'tableBorderStyle' }; schema.extend('table', { allowAttributes: Object.values(modelAttributes) }); for (const modelAttribute of Object.values(modelAttributes)) { schema.setAttributeProperties(modelAttribute, { isFormatting: true }); } upcastBorderStyles(editor, 'table', modelAttributes, defaultBorder); downcastTableAttribute(conversion, { modelAttribute: modelAttributes.color, styleName: 'border-color' }); downcastTableAttribute(conversion, { modelAttribute: modelAttributes.style, styleName: 'border-style' }); downcastTableAttribute(conversion, { modelAttribute: modelAttributes.width, styleName: 'border-width' }); } /** * Enables the extended block`'alignment'` attribute for table. * * @param defaultValue The default alignment value. */ function enableExtendedAlignmentProperty(schema, conversion, defaultValue, useInlineStyles) { schema.extend('table', { allowAttributes: ['tableAlignment'] }); schema.setAttributeProperties('tableAlignment', { isFormatting: true }); conversion.for('downcast') .attributeToAttribute({ model: { name: 'table', key: 'tableAlignment', values: ['left', 'center', 'right', 'blockLeft', 'blockRight'] }, view: { left: useInlineStyles ? { key: 'style', value: { float: 'left', 'margin-right': 'var(--ck-content-table-style-spacing, 1.5em)' } } : { key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.left.className }, right: useInlineStyles ? { key: 'style', value: { float: 'right', 'margin-left': 'var(--ck-content-table-style-spacing, 1.5em)' } } : { key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className }, center: useInlineStyles ? { key: 'style', value: { 'margin-left': 'auto', 'margin-right': 'auto' } } : { key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className }, blockLeft: useInlineStyles ? { key: 'style', value: { 'margin-left': '0', 'margin-right': 'auto' } } : { key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }, blockRight: useInlineStyles ? { key: 'style', value: { 'margin-left': 'auto', 'margin-right': '0' } } : { key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className } }, converterPriority: 'high' }); /** * Enables upcasting of the `tableAlignment` attribute. */ upcastTableAlignmentConfig.forEach(config => { conversion.for('upcast').attributeToAttribute({ view: config.view, model: { key: 'tableAlignment', value: (viewElement, conversionApi, data) => { if (isNonTableFigureElement(viewElement)) { return; } const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); const align = config.getAlign(viewElement); const consumables = config.getConsumables(viewElement); conversionApi.consumable.consume(viewElement, consumables); if (align !== localDefaultValue) { return align; } } } }); }); conversion.for('upcast').add(upcastTableAlignedDiv(defaultValue)); } /** * Enables the `'alignment'` attribute for table. * * @param defaultValue The default alignment value. */ function enableAlignmentProperty(schema, conversion, defaultValue) { const ALIGN_VALUES_REG_EXP = /^(left|center|right)$/; const FLOAT_VALUES_REG_EXP = /^(left|none|right)$/; schema.extend('table', { allowAttributes: ['tableAlignment'] }); schema.setAttributeProperties('tableAlignment', { isFormatting: true }); conversion.for('downcast') .attributeToAttribute({ model: { name: 'table', key: 'tableAlignment', values: ['left', 'center', 'right'] }, view: { left: { key: 'style', value: { float: 'left' } }, right: { key: 'style', value: { float: 'right' } }, center: (alignment, conversionApi, data) => { const value = data.item.getAttribute('tableType') !== 'layout' ? { // Model: `alignment:center` => CSS: `float:none`. float: 'none' } : { 'margin-left': 'auto', 'margin-right': 'auto' }; return { key: 'style', value }; } }, converterPriority: 'high' }); conversion.for('upcast') // Support for the `float:*;` CSS definition for the table alignment. .attributeToAttribute({ view: { name: /^(table|figure)$/, styles: { float: FLOAT_VALUES_REG_EXP } }, model: { key: 'tableAlignment', value: (viewElement, conversionApi, data) => { // Ignore other figure elements. if (viewElement.name == 'figure' && !viewElement.hasClass('table')) { return; } const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); let align = viewElement.getStyle('float'); // CSS: `float:none` => Model: `alignment:center`. if (align === 'none') { align = 'center'; } if (align !== localDefaultValue) { return align; } // Consume the style even if not applied to the element so it won't be processed by other converters. conversionApi.consumable.consume(viewElement, { styles: 'float' }); } } }) // Support for the `margin-left:auto; margin-right:auto;` CSS definition for the table alignment. .attributeToAttribute({ view: { name: /^(table|figure)$/, styles: { 'margin-left': 'auto', 'margin-right': 'auto' } }, model: { key: 'tableAlignment', value: (viewElement, conversionApi, data) => { // Ignore other figure elements. if (viewElement.name == 'figure' && !viewElement.hasClass('table')) { return; } const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); const align = 'center'; if (align !== localDefaultValue) { return align; } // Consume the styles even if not applied to the element so it won't be processed by other converters. conversionApi.consumable.consume(viewElement, { styles: ['margin-left', 'margin-right'] }); } } }) // Support for the `align` attribute as the backward compatibility while pasting from other sources. .attributeToAttribute({ view: { name: 'table', attributes: { align: ALIGN_VALUES_REG_EXP } }, model: { key: 'tableAlignment', value: (viewElement, conversionApi, data) => { const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); const align = viewElement.getAttribute('align'); if (align !== localDefaultValue) { return align; } // Consume the attribute even if not applied to the element so it won't be processed by other converters. conversionApi.consumable.consume(viewElement, { attributes: 'align' }); } } }); } /** * Returns a function that converts the table view representation: * * ```html * <div align="right"><table>...</table></div> * <!-- or --> * <div align="center"><table>...</table></div> * <!-- or --> * <div align="left"><table>...</table></div> * ``` * * to the model representation: * * ```xml * <table tableAlignment="right|center|left"></table> * ``` * * @internal */ function upcastTableAlignedDiv(defaultValue) { return (dispatcher) => { dispatcher.on('element:div', (evt, data, conversionApi) => { // Do not convert if this is not a "table wrapped in div with align attribute". if (!conversionApi.consumable.test(data.viewItem, { name: true, attributes: 'align' })) { return; } // Find a table element inside the div element. const viewTable = getViewTableFromWrapper(data.viewItem); // Do not convert if table element is absent or was already converted. if (!viewTable || !conversionApi.consumable.test(viewTable, { name: true })) { return; } // Consume the div to prevent other converters from processing it again. conversionApi.consumable.consume(data.viewItem, { name: true, attributes: 'align' }); // Convert view table to model table. const conversionResult = conversionApi.convertItem(viewTable, data.modelCursor); // Get table element from conversion result. const modelTable = first(conversionResult.modelRange.getItems()); // When table wasn't successfully converted then finish conversion. if (!modelTable || !modelTable.is('element', 'table')) { // Revert consumed div so other features can convert it. conversionApi.consumable.revert(data.viewItem, { name: true, attributes: 'align' }); // If anyway some table content was converted, we have to pass the model range and cursor. if (conversionResult.modelRange && !conversionResult.modelRange.isCollapsed) { data.modelRange = conversionResult.modelRange; data.modelCursor = conversionResult.modelCursor; } return; } const alignAttributeFromDiv = data.viewItem.getAttribute('align'); const alignAttributeFromTable = viewTable.getAttribute('align'); const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); const align = convertToTableAlignment(alignAttributeFromDiv, alignAttributeFromTable, localDefaultValue); if (align) { conversionApi.writer.setAttribute('tableAlignment', align, modelTable); } conversionApi.convertChildren(data.viewItem, conversionApi.writer.createPositionAt(modelTable, 'end')); conversionApi.updateConversionResult(modelTable, data); }); }; } /** * Converts div `align` and table `align` attributes to the model `tableAlignment` attribute. * * @param divAlign The value of the div `align` attribute. * @param tableAlign The value of the table `align` attribute. * @param defaultValue The default alignment value. * @returns The model `tableAlignment` value or `undefined` if no conversion is needed. */ function convertToTableAlignment(divAlign, tableAlign, defaultValue) { if (divAlign) { switch (divAlign) { case 'right': if (tableAlign === 'right') { return 'right'; } else if (tableAlign === 'left') { return 'left'; } else { return 'blockRight'; } case 'center': return 'center'; case 'left': return tableAlign === undefined ? 'blockLeft' : 'left'; default: return defaultValue; } } return undefined; } /** * Enables conversion for an attribute for simple view-model mappings. * * @param options.defaultValue The default value for the specified `modelAttribute`. */ function enableProperty(schema, conversion, options) { const { modelAttribute } = options; schema.extend('table', { allowAttributes: [modelAttribute] }); schema.setAttributeProperties(modelAttribute, { isFormatting: true }); upcastStyleToAttribute(conversion, { viewElement: 'table', ...options }); downcastTableAttribute(conversion, options); } /** * Enables conversion for an attribute for simple view (figure) to model (table) mappings. */ function enableTableToFigureProperty(schema, conversion, options) { const { modelAttribute } = options; schema.extend('table', { allowAttributes: [modelAttribute] }); schema.setAttributeProperties(modelAttribute, { isFormatting: true }); upcastStyleToAttribute(conversion, { viewElement: /^(table|figure)$/, shouldUpcast: (viewElement) => !(viewElement.name == 'table' && viewElement.parent.name == 'figure' || viewElement.name == 'figure' && !viewElement.hasClass('table')), ...options }); downcastAttributeToStyle(conversion, { modelElement: 'table', ...options }); } /** * Checks whether a given figure element should be ignored when upcasting table properties. */ function isNonTableFigureElement(viewElement) { return viewElement.name == 'figure' && !viewElement.hasClass('table'); }