@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
413 lines (412 loc) • 19.2 kB
JavaScript
/**
* @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);
}
}
});
}