UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

445 lines (444 loc) • 17.2 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 */ 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 } };