@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
445 lines (444 loc) • 17.2 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
*/
import { first } from 'ckeditor5/src/utils.js';
const ALIGN_VALUES_REG_EXP = /^(left|center|right)$/;
const FLOAT_VALUES_REG_EXP = /^(left|none|right)$/;
/**
* Conversion helper for upcasting attributes using normalized styles.
*
* @param options.modelAttribute The attribute to set.
* @param options.styleName The style name to convert.
* @param options.attributeName The HTML attribute name to convert.
* @param options.attributeType The HTML attribute type for value normalization.
* @param options.viewElement The view element name that should be converted.
* @param options.defaultValue The default value for the specified `modelAttribute`.
* @param options.shouldUpcast The function which returns `true` if style should be upcasted from this element.
* @internal
*/
export function upcastStyleToAttribute(conversion, options) {
const { modelAttribute, styleName, attributeName, attributeType, viewElement, defaultValue, shouldUpcast = () => true, reduceBoxSides = false } = options;
conversion.for('upcast').attributeToAttribute({
view: {
name: viewElement,
styles: {
[styleName]: /[\s\S]+/
}
},
model: {
key: modelAttribute,
value: (viewElement, conversionApi, data) => {
// Ignore table elements inside figures and figures without the table class.
if (!shouldUpcast(viewElement)) {
return;
}
const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data);
const normalized = viewElement.getNormalizedStyle(styleName);
const value = reduceBoxSides ? reduceBoxSidesValue(normalized) : normalized;
if (localDefaultValue !== value) {
return value;
}
// Consume the style even if not applied to the element so it won't be processed by other converters.
conversionApi.consumable.consume(viewElement, { styles: styleName });
}
}
});
if (attributeName) {
conversion.for('upcast').attributeToAttribute({
view: {
name: viewElement,
attributes: {
[attributeName]: /.+/
}
},
model: {
key: modelAttribute,
value: (viewElement, conversionApi, data) => {
// Convert attributes of table and table cell elements, ignore figure.
// Do not convert attribute if related style is set as it has a higher priority.
// Do not convert attribute if the element is a table inside a figure with the related style set.
if (viewElement.name == 'figure' ||
viewElement.hasStyle(styleName) ||
viewElement.name == 'table' && viewElement.parent.name == 'figure' && viewElement.parent.hasStyle(styleName)) {
return;
}
const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data);
let value = viewElement.getAttribute(attributeName);
if (value && attributeType == 'length' && !value.endsWith('px')) {
value += 'px';
}
if (localDefaultValue !== value) {
return value;
}
// Consume the attribute even if not applied to the element so it won't be processed by other converters.
conversionApi.consumable.consume(viewElement, { attributes: attributeName });
}
}
});
}
}
/**
* Conversion helper for upcasting border styles for view elements.
*
* @param editor The editor instance.
* @param defaultBorder The default border values.
* @param defaultBorder.color The default `borderColor` value.
* @param defaultBorder.style The default `borderStyle` value.
* @param defaultBorder.width The default `borderWidth` value.
* @internal
*/
export function upcastBorderStyles(editor, viewElementName, modelAttributes, defaultBorder) {
const { conversion } = editor;
conversion.for('upcast').add(dispatcher => dispatcher.on('element:' + viewElementName, (evt, data, conversionApi) => {
// If the element was not converted by element-to-element converter,
// we should not try to convert the style. See #8393.
if (!data.modelRange) {
return;
}
// Check the most detailed properties. These will be always set directly or
// when using the "group" properties like: `border-(top|right|bottom|left)` or `border`.
const stylesToConsume = [
'border-top-width',
'border-top-color',
'border-top-style',
'border-bottom-width',
'border-bottom-color',
'border-bottom-style',
'border-right-width',
'border-right-color',
'border-right-style',
'border-left-width',
'border-left-color',
'border-left-style'
].filter(styleName => data.viewItem.hasStyle(styleName));
if (!stylesToConsume.length) {
return;
}
const matcherPattern = {
styles: stylesToConsume
};
// Try to consume appropriate values from consumable values list.
if (!conversionApi.consumable.test(data.viewItem, matcherPattern)) {
return;
}
const modelElement = [...data.modelRange.getItems({ shallow: true })].pop();
const tableElement = modelElement.findAncestor('table', { includeSelf: true });
let localDefaultBorder = defaultBorder;
if (tableElement && tableElement.getAttribute('tableType') == 'layout') {
localDefaultBorder = {
style: 'none',
color: '',
width: ''
};
}
conversionApi.consumable.consume(data.viewItem, matcherPattern);
const normalizedBorder = {
style: data.viewItem.getNormalizedStyle('border-style'),
color: data.viewItem.getNormalizedStyle('border-color'),
width: data.viewItem.getNormalizedStyle('border-width')
};
const reducedBorder = {
style: reduceBoxSidesValue(normalizedBorder.style),
color: reduceBoxSidesValue(normalizedBorder.color),
width: reduceBoxSidesValue(normalizedBorder.width)
};
if (reducedBorder.style !== localDefaultBorder.style) {
conversionApi.writer.setAttribute(modelAttributes.style, reducedBorder.style, modelElement);
}
if (reducedBorder.color !== localDefaultBorder.color) {
conversionApi.writer.setAttribute(modelAttributes.color, reducedBorder.color, modelElement);
}
if (reducedBorder.width !== localDefaultBorder.width) {
conversionApi.writer.setAttribute(modelAttributes.width, reducedBorder.width, modelElement);
}
}));
if (editor.config.get('experimentalFlags.upcastTableBorderZeroAttributes')) {
// If parent table has `border="0"` attribute then set border style to `none`
// all table cells of that table and table itself.
conversion.for('upcast').add(dispatcher => {
dispatcher.on(`element:${viewElementName}`, (evt, data, conversionApi) => {
const { modelRange, viewItem } = data;
const viewTable = (viewItem.is('element', 'table') ?
viewItem :
viewItem.findAncestor('table'));
// If something already consumed the border attribute on the nearest table element, skip the conversion.
if (!conversionApi.consumable.test(viewTable, { attributes: 'border' })) {
return;
}
// Ignore tables with border different than "0".
if (viewTable.getAttribute('border') !== '0') {
return;
}
const modelElement = modelRange?.start?.nodeAfter;
// If model element has already border style attribute, skip the conversion.
if (!modelElement || modelElement.hasAttribute(modelAttributes.style)) {
return;
}
conversionApi.writer.setAttribute(modelAttributes.style, 'none', modelElement);
if (viewItem.is('element', 'table')) {
conversionApi.consumable.consume(viewItem, { attributes: 'border' });
}
});
});
}
}
/**
* Conversion helper for downcasting an attribute to a style.
*
* @internal
*/
export function downcastAttributeToStyle(conversion, options) {
const { modelElement, modelAttribute, styleName } = options;
conversion.for('downcast').attributeToAttribute({
model: {
name: modelElement,
key: modelAttribute
},
view: modelAttributeValue => ({
key: 'style',
value: {
[styleName]: modelAttributeValue
}
})
});
}
/**
* Conversion helper for downcasting attributes from the model table to a view table (not to `<figure>`).
*
* @internal
*/
export function downcastTableAttribute(conversion, options) {
const { modelAttribute, styleName } = options;
conversion.for('downcast').add(dispatcher => dispatcher.on(`attribute:${modelAttribute}:table`, (evt, data, conversionApi) => {
const { item, attributeNewValue } = data;
const { mapper, writer } = conversionApi;
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
const table = [...mapper.toViewElement(item).getChildren()].find(child => child.is('element', 'table'));
if (attributeNewValue) {
writer.setStyle(styleName, attributeNewValue, table);
}
else {
writer.removeStyle(styleName, table);
}
}));
}
/**
* Returns the default value for table or table cell property adjusted for layout tables.
*
* @internal
*/
export function getDefaultValueAdjusted(defaultValue, layoutTableDefault, data) {
const modelElement = data.modelRange && first(data.modelRange.getItems({ shallow: true }));
const tableElement = modelElement && modelElement.is('element') && modelElement.findAncestor('table', { includeSelf: true });
if (tableElement && tableElement.getAttribute('tableType') === 'layout') {
return layoutTableDefault;
}
return defaultValue;
}
/**
* Reduces the full top, right, bottom, left object to a single string if all sides are equal.
* Returns original style otherwise.
*/
function reduceBoxSidesValue(style) {
if (!style) {
return;
}
const sides = ['top', 'right', 'bottom', 'left'];
const allSidesDefined = sides.every(side => style[side]);
if (!allSidesDefined) {
return style;
}
const topSideStyle = style.top;
const allSidesEqual = sides.every(side => style[side] === topSideStyle);
if (!allSidesEqual) {
return style;
}
return topSideStyle;
}
/**
* Default table alignment options.
*/
export const DEFAULT_TABLE_ALIGNMENT_OPTIONS = {
left: { className: 'table-style-align-left' },
center: { className: 'table-style-align-center' },
right: { className: 'table-style-align-right' },
blockLeft: { className: 'table-style-block-align-left' },
blockRight: { className: 'table-style-block-align-right' }
};
/**
* Configuration for upcasting table alignment from view to model.
*/
export const upcastTableAlignmentConfig = [
// Support for the `float:*;` CSS definition for the table alignment.
{
view: {
name: /^(table|figure)$/,
styles: {
float: FLOAT_VALUES_REG_EXP
}
},
getAlign: (viewElement) => {
let align = viewElement.getStyle('float');
if (align === 'none') {
align = 'center';
}
return align;
},
getConsumables(viewElement) {
const float = viewElement.getStyle('float');
const styles = ['float'];
if (float === 'left' && viewElement.hasStyle('margin-right')) {
styles.push('margin-right');
}
else if (float === 'right' && viewElement.hasStyle('margin-left')) {
styles.push('margin-left');
}
return { styles };
}
},
// Support for the `margin-left:auto; margin-right:auto;` CSS definition for the table alignment.
{
view: {
name: /^(table|figure)$/,
styles: {
'margin-left': 'auto',
'margin-right': 'auto'
}
},
getAlign: () => 'center',
getConsumables: () => {
return { styles: ['margin-left', 'margin-right'] };
}
},
// Support for the left alignment using CSS classes.
{
view: {
name: /^(table|figure)$/,
key: 'class',
value: 'table-style-align-left'
},
getAlign: () => 'left',
getConsumables() {
return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.left.className };
}
},
// Support for the right alignment using CSS classes.
{
view: {
name: /^(table|figure)$/,
key: 'class',
value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className
},
getAlign: () => 'right',
getConsumables() {
return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className };
}
},
// Support for the center alignment using CSS classes.
{
view: {
name: /^(table|figure)$/,
key: 'class',
value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className
},
getAlign: () => 'center',
getConsumables() {
return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className };
}
},
// Support for the block alignment left using CSS classes.
{
view: {
name: /^(table|figure)$/,
key: 'class',
value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className
},
getAlign: () => 'blockLeft',
getConsumables() {
return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className };
}
},
// Support for the block alignment right using CSS classes.
{
view: {
name: /^(table|figure)$/,
key: 'class',
value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className
},
getAlign: () => 'blockRight',
getConsumables() {
return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className };
}
},
// Support for the block alignment left using margin CSS styles.
{
view: {
name: /^(table|figure)$/,
styles: {
'margin-left': '0',
'margin-right': 'auto'
}
},
getAlign: () => 'blockLeft',
getConsumables() {
return { styles: ['margin-left', 'margin-right'] };
}
},
// Support for the block alignment right using margin CSS styles.
{
view: {
name: /^(table|figure)$/,
styles: {
'margin-left': 'auto',
'margin-right': '0'
}
},
getAlign: () => 'blockRight',
getConsumables() {
return { styles: ['margin-left', 'margin-right'] };
}
},
// Support for the `align` attribute as the backward compatibility while pasting from other sources.
{
view: {
name: 'table',
attributes: {
align: ALIGN_VALUES_REG_EXP
}
},
getAlign: (viewElement) => viewElement.getAttribute('align'),
getConsumables() {
return { attributes: 'align' };
}
}
];
export const downcastTableAlignmentConfig = {
center: {
align: 'center',
style: 'margin-left: auto; margin-right: auto;',
className: 'table-style-align-center'
},
left: {
align: 'left',
style: 'float: left;',
className: 'table-style-align-left'
},
right: {
align: 'right',
style: 'float: right;',
className: 'table-style-align-right'
},
blockLeft: {
align: undefined,
style: 'margin-left: 0; margin-right: auto;',
className: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className
},
blockRight: {
align: undefined,
style: 'margin-left: auto; margin-right: 0;',
className: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className
}
};