UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

413 lines (412 loc) • 19.2 kB
/** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module table/tablecellproperties/tablecellpropertiesediting */ import { priorities } from 'ckeditor5/src/utils.js'; import { Plugin } from 'ckeditor5/src/core.js'; import { addBorderStylesRules, addPaddingStylesRules, addBackgroundStylesRules } from 'ckeditor5/src/engine.js'; import { downcastAttributeToStyle, getDefaultValueAdjusted, upcastBorderStyles } from '../converters/tableproperties.js'; import { TableEditing } from './../tableediting.js'; import { TableCellWidthEditing } from '../tablecellwidth/tablecellwidthediting.js'; import { TableCellPaddingCommand } from './commands/tablecellpaddingcommand.js'; import { TableCellHeightCommand } from './commands/tablecellheightcommand.js'; import { TableCellBackgroundColorCommand } from './commands/tablecellbackgroundcolorcommand.js'; import { TableCellVerticalAlignmentCommand } from './commands/tablecellverticalalignmentcommand.js'; import { TableCellHorizontalAlignmentCommand } from './commands/tablecellhorizontalalignmentcommand.js'; import { TableCellBorderStyleCommand } from './commands/tablecellborderstylecommand.js'; import { TableCellBorderColorCommand } from './commands/tablecellbordercolorcommand.js'; import { TableCellBorderWidthCommand } from './commands/tablecellborderwidthcommand.js'; import { TableCellTypeCommand, updateTablesHeadingAttributes } from './commands/tablecelltypecommand.js'; import { getNormalizedDefaultCellProperties } from '../utils/table-properties.js'; import { enableProperty } from '../utils/common.js'; import { TableUtils } from '../tableutils.js'; import { TableWalker } from '../tablewalker.js'; const VALIGN_VALUES_REG_EXP = /^(top|middle|bottom)$/; const ALIGN_VALUES_REG_EXP = /^(left|center|right|justify)$/; /** * The table cell properties editing feature. * * Introduces table cell model attributes and their conversion: * * - border: `tableCellBorderStyle`, `tableCellBorderColor` and `tableCellBorderWidth` * - background color: `tableCellBackgroundColor` * - cell padding: `tableCellPadding` * - horizontal and vertical alignment: `tableCellHorizontalAlignment`, `tableCellVerticalAlignment` * - cell width and height: `tableCellWidth`, `tableCellHeight` * * It also registers commands used to manipulate the above attributes: * * - border: the `'tableCellBorderStyle'`, `'tableCellBorderColor'` and `'tableCellBorderWidth'` commands * - background color: the `'tableCellBackgroundColor'` command * - cell padding: the `'tableCellPadding'` command * - horizontal and vertical alignment: the `'tableCellHorizontalAlignment'` and `'tableCellVerticalAlignment'` commands * - width and height: the `'tableCellWidth'` and `'tableCellHeight'` commands */ export class TableCellPropertiesEditing extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'TableCellPropertiesEditing'; } /** * @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, TableCellWidthEditing]; } /** * @inheritDoc */ init() { const editor = this.editor; const schema = editor.model.schema; const conversion = editor.conversion; editor.config.define('table.tableCellProperties.defaultProperties', {}); const defaultTableCellProperties = getNormalizedDefaultCellProperties(editor.config.get('table.tableCellProperties.defaultProperties'), { includeVerticalAlignmentProperty: true, includeHorizontalAlignmentProperty: true, includePaddingProperty: true, isRightToLeftContent: editor.locale.contentLanguageDirection === 'rtl' }); editor.data.addStyleProcessorRules(addBorderStylesRules); enableBorderProperties(editor, { color: defaultTableCellProperties.borderColor, style: defaultTableCellProperties.borderStyle, width: defaultTableCellProperties.borderWidth }); editor.commands.add('tableCellBorderStyle', new TableCellBorderStyleCommand(editor, defaultTableCellProperties.borderStyle)); editor.commands.add('tableCellBorderColor', new TableCellBorderColorCommand(editor, defaultTableCellProperties.borderColor)); editor.commands.add('tableCellBorderWidth', new TableCellBorderWidthCommand(editor, defaultTableCellProperties.borderWidth)); enableProperty(schema, conversion, { modelAttribute: 'tableCellHeight', styleName: 'height', attributeName: 'height', attributeType: 'length', defaultValue: defaultTableCellProperties.height }); editor.commands.add('tableCellHeight', new TableCellHeightCommand(editor, defaultTableCellProperties.height)); editor.data.addStyleProcessorRules(addPaddingStylesRules); enableProperty(schema, conversion, { modelAttribute: 'tableCellPadding', styleName: 'padding', reduceBoxSides: true, defaultValue: defaultTableCellProperties.padding }); editor.commands.add('tableCellPadding', new TableCellPaddingCommand(editor, defaultTableCellProperties.padding)); editor.data.addStyleProcessorRules(addBackgroundStylesRules); enableProperty(schema, conversion, { modelAttribute: 'tableCellBackgroundColor', styleName: 'background-color', attributeName: 'bgcolor', attributeType: 'color', defaultValue: defaultTableCellProperties.backgroundColor }); editor.commands.add('tableCellBackgroundColor', new TableCellBackgroundColorCommand(editor, defaultTableCellProperties.backgroundColor)); enableHorizontalAlignmentProperty(schema, conversion, defaultTableCellProperties.horizontalAlignment); editor.commands.add('tableCellHorizontalAlignment', new TableCellHorizontalAlignmentCommand(editor, defaultTableCellProperties.horizontalAlignment)); enableVerticalAlignmentProperty(schema, conversion, defaultTableCellProperties.verticalAlignment); editor.commands.add('tableCellVerticalAlignment', new TableCellVerticalAlignmentCommand(editor, defaultTableCellProperties.verticalAlignment)); if (editor.config.get('experimentalFlags.tableCellTypeSupport')) { enableCellTypeProperty(editor); editor.commands.add('tableCellType', new TableCellTypeCommand(editor)); } } } /** * Enables the `'tableCellBorderStyle'`, `'tableCellBorderColor'` and `'tableCellBorderWidth'` attributes for table cells. * * @param editor The editor instance. * @param defaultBorder The default border values. * @param defaultBorder.color The default `tableCellBorderColor` value. * @param defaultBorder.style The default `tableCellBorderStyle` value. * @param defaultBorder.width The default `tableCellBorderWidth` value. */ function enableBorderProperties(editor, defaultBorder) { const { conversion } = editor; const { schema } = editor.model; const modelAttributes = { width: 'tableCellBorderWidth', color: 'tableCellBorderColor', style: 'tableCellBorderStyle' }; schema.extend('tableCell', { allowAttributes: Object.values(modelAttributes) }); for (const modelAttribute of Object.values(modelAttributes)) { schema.setAttributeProperties(modelAttribute, { isFormatting: true }); } upcastBorderStyles(editor, 'td', modelAttributes, defaultBorder); upcastBorderStyles(editor, 'th', modelAttributes, defaultBorder); downcastAttributeToStyle(conversion, { modelElement: 'tableCell', modelAttribute: modelAttributes.style, styleName: 'border-style' }); downcastAttributeToStyle(conversion, { modelElement: 'tableCell', modelAttribute: modelAttributes.color, styleName: 'border-color' }); downcastAttributeToStyle(conversion, { modelElement: 'tableCell', modelAttribute: modelAttributes.width, styleName: 'border-width' }); } /** * Enables the `'tableCellHorizontalAlignment'` attribute for table cells. * * @param defaultValue The default horizontal alignment value. */ function enableHorizontalAlignmentProperty(schema, conversion, defaultValue) { schema.extend('tableCell', { allowAttributes: ['tableCellHorizontalAlignment'] }); schema.setAttributeProperties('tableCellHorizontalAlignment', { isFormatting: true }); conversion.for('downcast') .attributeToAttribute({ model: { name: 'tableCell', key: 'tableCellHorizontalAlignment' }, view: alignment => ({ key: 'style', value: { 'text-align': alignment } }) }); conversion.for('upcast') // Support for the `text-align:*;` CSS definition for the table cell alignment. .attributeToAttribute({ view: { name: /^(td|th)$/, styles: { 'text-align': ALIGN_VALUES_REG_EXP } }, model: { key: 'tableCellHorizontalAlignment', value: (viewElement, conversionApi, data) => { const localDefaultValue = getDefaultValueAdjusted(defaultValue, 'left', data); const align = viewElement.getStyle('text-align'); 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: 'text-align' }); } } }) // Support for the `align` attribute as the backward compatibility while pasting from other sources. .attributeToAttribute({ view: { name: /^(td|th)$/, attributes: { align: ALIGN_VALUES_REG_EXP } }, model: { key: 'tableCellHorizontalAlignment', value: (viewElement, conversionApi, data) => { const localDefaultValue = getDefaultValueAdjusted(defaultValue, 'left', data); const align = viewElement.getAttribute('align'); 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, { attributes: 'align' }); } } }); } /** * Enables the `'verticalAlignment'` attribute for table cells. * * @param defaultValue The default vertical alignment value. */ function enableVerticalAlignmentProperty(schema, conversion, defaultValue) { schema.extend('tableCell', { allowAttributes: ['tableCellVerticalAlignment'] }); schema.setAttributeProperties('tableCellVerticalAlignment', { isFormatting: true }); conversion.for('downcast') .attributeToAttribute({ model: { name: 'tableCell', key: 'tableCellVerticalAlignment' }, view: alignment => ({ key: 'style', value: { 'vertical-align': alignment } }) }); conversion.for('upcast') // Support for the `vertical-align:*;` CSS definition for the table cell alignment. .attributeToAttribute({ view: { name: /^(td|th)$/, styles: { 'vertical-align': VALIGN_VALUES_REG_EXP } }, model: { key: 'tableCellVerticalAlignment', value: (viewElement, conversionApi, data) => { const localDefaultValue = getDefaultValueAdjusted(defaultValue, 'middle', data); const align = viewElement.getStyle('vertical-align'); 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: 'vertical-align' }); } } }) // Support for the `align` attribute as the backward compatibility while pasting from other sources. .attributeToAttribute({ view: { name: /^(td|th)$/, attributes: { valign: VALIGN_VALUES_REG_EXP } }, model: { key: 'tableCellVerticalAlignment', value: (viewElement, conversionApi, data) => { const localDefaultValue = getDefaultValueAdjusted(defaultValue, 'middle', data); const valign = viewElement.getAttribute('valign'); if (valign !== localDefaultValue) { return valign; } // Consume the attribute even if not applied to the element so it won't be processed by other converters. conversionApi.consumable.consume(viewElement, { attributes: 'valign' }); } } }); } /** * Enables the `tableCellType` attribute for table cells. */ function enableCellTypeProperty(editor) { const { model, conversion, editing } = editor; const { schema } = model; const tableUtils = editor.plugins.get(TableUtils); schema.extend('tableCell', { allowAttributes: ['tableCellType'] }); schema.setAttributeProperties('tableCellType', { isFormatting: true }); // Do not allow setting `tableCellType` in layout tables. schema.addAttributeCheck(context => { const nearestTable = Array.from(context).reverse().find(item => item.name === 'table'); if (nearestTable?.getAttribute('tableType') === 'layout') { return false; } }, 'tableCellType'); // Upcast conversion for td/th elements. conversion.for('upcast').add(dispatcher => { dispatcher.on('element:th', (evt, data, conversionApi) => { const { writer } = conversionApi; const { modelRange } = data; const modelElement = modelRange?.start.nodeAfter; if (modelElement?.is('element', 'tableCell')) { writer.setAttribute('tableCellType', 'header', modelElement); } }); // Table type is examined after all other cell converters, on low priority, so // we double check if there is any `th` left in the table. If so, the table is converted to a content table. dispatcher.on('element:table', (evt, data, conversionApi) => { const { writer } = conversionApi; const { modelRange } = data; const modelElement = modelRange?.start.nodeAfter; if (modelElement?.is('element', 'table') && modelElement.getAttribute('tableType') === 'layout') { for (const { cell } of new TableWalker(modelElement)) { if (cell.getAttribute('tableCellType') === 'header') { writer.setAttribute('tableType', 'content', modelElement); break; } } } }, { priority: priorities.low - 1 }); }); // Registers a post-fixer that ensures the `headingRows` and `headingColumns` attributes // are consistent with the `tableCellType` attribute of the cells. `tableCellType` has priority // over `headingRows` and `headingColumns` and we use it to adjust the heading sections of the table. model.document.registerPostFixer(writer => { // 1. Collect all tables that need to be checked. const changes = model.document.differ.getChanges(); const tablesToCheck = new Set(); for (const change of changes) { // Check if headingRows or headingColumns changed. if (change.type === 'attribute' && (change.attributeKey === 'headingRows' || change.attributeKey === 'headingColumns')) { const table = change.range.start.nodeAfter; if (table?.is('element', 'table') && table.root.rootName !== '$graveyard') { tablesToCheck.add(table); } } // Check if tableCellType changed. if (change.type === 'attribute' && change.attributeKey === 'tableCellType') { const cell = change.range.start.nodeAfter; if (cell?.is('element', 'tableCell') && cell.root.rootName !== '$graveyard') { const table = cell.findAncestor('table'); if (table) { tablesToCheck.add(table); } } } // Check if new headers were inserted. if (change.type === 'insert' && change.position.nodeAfter) { for (const { item } of model.createRangeOn(change.position.nodeAfter)) { if (item.is('element', 'tableCell') && item.getAttribute('tableCellType') && item.root.rootName !== '$graveyard') { const table = item.findAncestor('table'); if (table) { tablesToCheck.add(table); } } } } } // 2. Update the attributes of the collected tables. return updateTablesHeadingAttributes(tableUtils, writer, tablesToCheck); }); // Refresh the table cells in the editing view when their `tableCellType` attribute changes. model.document.on('change:data', () => { const { differ } = model.document; const cellsToReconvert = new Set(); for (const change of differ.getChanges()) { // If the `tableCellType` attribute changed, the entire cell needs to be re-rendered. if (change.type === 'attribute' && change.attributeKey === 'tableCellType') { const tableCell = change.range.start.nodeAfter; if (tableCell.is('element', 'tableCell')) { cellsToReconvert.add(tableCell); } } } // Reconvert table cells that had their `tableCellType` attribute changed. for (const tableCell of cellsToReconvert) { const viewElement = editing.mapper.toViewElement(tableCell); const cellType = tableCell.getAttribute('tableCellType'); const expectedElementName = cellType === 'header' ? 'th' : 'td'; if (viewElement?.name !== expectedElementName) { editing.reconvertItem(tableCell); } } }); }