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