UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

386 lines (385 loc) 16.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 */ /** * @module table/tablecellproperties/tablecellpropertiesui */ import { Plugin } from 'ckeditor5/src/core.js'; import { IconTableCellProperties } from 'ckeditor5/src/icons.js'; import { ButtonView, clickOutsideHandler, ContextualBalloon, getLocalizedColorOptions, normalizeColorOptions } from 'ckeditor5/src/ui.js'; import TableCellPropertiesView from './ui/tablecellpropertiesview.js'; import { colorFieldValidator, getLocalizedColorErrorText, getLocalizedLengthErrorText, defaultColors, lengthFieldValidator, lineWidthFieldValidator } from '../utils/ui/table-properties.js'; import { debounce } from 'es-toolkit/compat'; import { getSelectionAffectedTableWidget, getTableWidgetAncestor } from '../utils/ui/widget.js'; import { getBalloonCellPositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon.js'; import { getNormalizedDefaultCellProperties, getNormalizedDefaultProperties } from '../utils/table-properties.js'; const ERROR_TEXT_TIMEOUT = 500; // Map of view properties and related commands. const propertyToCommandMap = { borderStyle: 'tableCellBorderStyle', borderColor: 'tableCellBorderColor', borderWidth: 'tableCellBorderWidth', height: 'tableCellHeight', width: 'tableCellWidth', padding: 'tableCellPadding', backgroundColor: 'tableCellBackgroundColor', horizontalAlignment: 'tableCellHorizontalAlignment', verticalAlignment: 'tableCellVerticalAlignment' }; /** * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button * that opens a form allowing to specify the visual styling of a table cell. * * It uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}. */ export default class TableCellPropertiesUI extends Plugin { /** * The default table cell properties. */ _defaultContentTableCellProperties; /** * The default layout table cell properties. */ _defaultLayoutTableCellProperties; /** * The contextual balloon plugin instance. */ _balloon; /** * The cell properties form view displayed inside the balloon. */ view; /** * The cell properties form view displayed inside the balloon (content table). */ _viewWithContentTableDefaults; /** * The cell properties form view displayed inside the balloon (layout table). */ _viewWithLayoutTableDefaults; /** * The batch used to undo all changes made by the form (which are live, as the user types) * when "Cancel" was pressed. Each time the view is shown, a new batch is created. */ _undoStepBatch; /** * Flag used to indicate whether view is ready to execute update commands * (it finished loading initial data). */ _isReady; /** * @inheritDoc */ static get requires() { return [ContextualBalloon]; } /** * @inheritDoc */ static get pluginName() { return 'TableCellPropertiesUI'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ constructor(editor) { super(editor); editor.config.define('table.tableCellProperties', { borderColors: defaultColors, backgroundColors: defaultColors }); } /** * @inheritDoc */ init() { const editor = this.editor; const t = editor.t; this._defaultContentTableCellProperties = getNormalizedDefaultCellProperties(editor.config.get('table.tableCellProperties.defaultProperties'), { includeVerticalAlignmentProperty: true, includeHorizontalAlignmentProperty: true, includePaddingProperty: true, isRightToLeftContent: editor.locale.contentLanguageDirection === 'rtl' }); this._defaultLayoutTableCellProperties = getNormalizedDefaultProperties(undefined, { includeVerticalAlignmentProperty: true, includeHorizontalAlignmentProperty: true, isRightToLeftContent: editor.locale.contentLanguageDirection === 'rtl' }); this._balloon = editor.plugins.get(ContextualBalloon); this.view = null; this._isReady = false; editor.ui.componentFactory.add('tableCellProperties', locale => { const view = new ButtonView(locale); view.set({ label: t('Cell properties'), icon: IconTableCellProperties, tooltip: true }); this.listenTo(view, 'execute', () => this._showView()); const commands = Object.values(propertyToCommandMap) .map(commandName => editor.commands.get(commandName)); view.bind('isEnabled').toMany(commands, 'isEnabled', (...areEnabled) => (areEnabled.some(isCommandEnabled => isCommandEnabled))); return view; }); } /** * @inheritDoc */ destroy() { super.destroy(); // Destroy created UI components as they are not automatically destroyed. // See https://github.com/ckeditor/ckeditor5/issues/1341. if (this.view) { this.view.destroy(); } } /** * Creates the {@link module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView} instance. * * @returns The cell properties form view instance. */ _createPropertiesView(defaultTableCellProperties) { const editor = this.editor; const config = editor.config.get('table.tableCellProperties'); const borderColorsConfig = normalizeColorOptions(config.borderColors); const localizedBorderColors = getLocalizedColorOptions(editor.locale, borderColorsConfig); const backgroundColorsConfig = normalizeColorOptions(config.backgroundColors); const localizedBackgroundColors = getLocalizedColorOptions(editor.locale, backgroundColorsConfig); const hasColorPicker = config.colorPicker !== false; const view = new TableCellPropertiesView(editor.locale, { borderColors: localizedBorderColors, backgroundColors: localizedBackgroundColors, defaultTableCellProperties, colorPickerConfig: hasColorPicker ? (config.colorPicker || {}) : false }); const t = editor.t; // Render the view so its #element is available for the clickOutsideHandler. view.render(); this.listenTo(view, 'submit', () => { this._hideView(); }); this.listenTo(view, 'cancel', () => { // https://github.com/ckeditor/ckeditor5/issues/6180 if (this._undoStepBatch.operations.length) { editor.execute('undo', this._undoStepBatch); } this._hideView(); }); // Close the balloon on Esc key press. view.keystrokes.set('Esc', (data, cancel) => { this._hideView(); cancel(); }); // Close on click outside of balloon panel element. clickOutsideHandler({ emitter: view, activator: () => this._isViewInBalloon, contextElements: [this._balloon.view.element], callback: () => this._hideView() }); const colorErrorText = getLocalizedColorErrorText(t); const lengthErrorText = getLocalizedLengthErrorText(t); // Create the "UI -> editor data" binding. // These listeners update the editor data (via table commands) when any observable // property of the view has changed. They also validate the value and display errors in the UI // when necessary. This makes the view live, which means the changes are // visible in the editing as soon as the user types or changes fields' values. view.on('change:borderStyle', this._getPropertyChangeCallback('tableCellBorderStyle')); view.on('change:borderColor', this._getValidatedPropertyChangeCallback({ viewField: view.borderColorInput, commandName: 'tableCellBorderColor', errorText: colorErrorText, validator: colorFieldValidator })); view.on('change:borderWidth', this._getValidatedPropertyChangeCallback({ viewField: view.borderWidthInput, commandName: 'tableCellBorderWidth', errorText: lengthErrorText, validator: lineWidthFieldValidator })); view.on('change:padding', this._getValidatedPropertyChangeCallback({ viewField: view.paddingInput, commandName: 'tableCellPadding', errorText: lengthErrorText, validator: lengthFieldValidator })); view.on('change:width', this._getValidatedPropertyChangeCallback({ viewField: view.widthInput, commandName: 'tableCellWidth', errorText: lengthErrorText, validator: lengthFieldValidator })); view.on('change:height', this._getValidatedPropertyChangeCallback({ viewField: view.heightInput, commandName: 'tableCellHeight', errorText: lengthErrorText, validator: lengthFieldValidator })); view.on('change:backgroundColor', this._getValidatedPropertyChangeCallback({ viewField: view.backgroundInput, commandName: 'tableCellBackgroundColor', errorText: colorErrorText, validator: colorFieldValidator })); view.on('change:horizontalAlignment', this._getPropertyChangeCallback('tableCellHorizontalAlignment')); view.on('change:verticalAlignment', this._getPropertyChangeCallback('tableCellVerticalAlignment')); return view; } /** * In this method the "editor data -> UI" binding is happening. * * When executed, this method obtains selected cell property values from various table commands * and passes them to the {@link #view}. * * This way, the UI stays up–to–date with the editor data. */ _fillViewFormFromCommandValues() { const commands = this.editor.commands; const borderStyleCommand = commands.get('tableCellBorderStyle'); Object.entries(propertyToCommandMap) .map(([property, commandName]) => { const propertyKey = property; const defaultValue = this.view === this._viewWithContentTableDefaults ? this._defaultContentTableCellProperties[propertyKey] || '' : this._defaultLayoutTableCellProperties[propertyKey] || ''; return [ property, commands.get(commandName).value || defaultValue ]; }) .forEach(([property, value]) => { // Do not set the `border-color` and `border-width` fields if `border-style:none`. if ((property === 'borderColor' || property === 'borderWidth') && borderStyleCommand.value === 'none') { return; } this.view.set(property, value); }); this._isReady = true; } /** * Shows the {@link #view} in the {@link #_balloon}. * * **Note**: Each time a view is shown, a new {@link #_undoStepBatch} is created. It contains * all changes made to the document when the view is visible, allowing a single undo step * for all of them. */ _showView() { const editor = this.editor; const viewTable = getSelectionAffectedTableWidget(editor.editing.view.document.selection); const modelTable = viewTable && editor.editing.mapper.toModelElement(viewTable); const useDefaults = !modelTable || modelTable.getAttribute('tableType') !== 'layout'; if (useDefaults && !this._viewWithContentTableDefaults) { this._viewWithContentTableDefaults = this._createPropertiesView(this._defaultContentTableCellProperties); } else if (!useDefaults && !this._viewWithLayoutTableDefaults) { this._viewWithLayoutTableDefaults = this._createPropertiesView(this._defaultLayoutTableCellProperties); } this.view = useDefaults ? this._viewWithContentTableDefaults : this._viewWithLayoutTableDefaults; this.listenTo(editor.ui, 'update', () => { this._updateView(); }); // Update the view with the model values. this._fillViewFormFromCommandValues(); this._balloon.add({ view: this.view, position: getBalloonCellPositionData(editor) }); // Create a new batch. Clicking "Cancel" will undo this batch. this._undoStepBatch = editor.model.createBatch(); // Basic a11y. this.view.focus(); } /** * Removes the {@link #view} from the {@link #_balloon}. */ _hideView() { const editor = this.editor; this.stopListening(editor.ui, 'update'); this._isReady = false; // Blur any input element before removing it from DOM to prevent issues in some browsers. // See https://github.com/ckeditor/ckeditor5/issues/1501. this.view.saveButtonView.focus(); this._balloon.remove(this.view); // Make sure the focus is not lost in the process by putting it directly // into the editing view. this.editor.editing.view.focus(); } /** * Repositions the {@link #_balloon} or hides the {@link #view} if a table cell is no longer selected. */ _updateView() { const editor = this.editor; const viewDocument = editor.editing.view.document; if (!getTableWidgetAncestor(viewDocument.selection)) { this._hideView(); } else if (this._isViewVisible) { repositionContextualBalloon(editor, 'cell'); } } /** * Returns `true` when the {@link #view} is visible in the {@link #_balloon}. */ get _isViewVisible() { return !!this.view && this._balloon.visibleView === this.view; } /** * Returns `true` when the {@link #view} is in the {@link #_balloon}. */ get _isViewInBalloon() { return !!this.view && this._balloon.hasView(this.view); } /** * Creates a callback that when executed upon the {@link #view view's} property change * executes a related editor command with the new property value. * * @param defaultValue The default value of the command. */ _getPropertyChangeCallback(commandName) { return (evt, propertyName, newValue) => { if (!this._isReady) { return; } this.editor.execute(commandName, { value: newValue, batch: this._undoStepBatch }); }; } /** * Creates a callback that when executed upon the {@link #view view's} property change: * * Executes a related editor command with the new property value if the value is valid, * * Or sets the error text next to the invalid field, if the value did not pass the validation. */ _getValidatedPropertyChangeCallback(options) { const { commandName, viewField, validator, errorText } = options; const setErrorTextDebounced = debounce(() => { viewField.errorText = errorText; }, ERROR_TEXT_TIMEOUT); return (evt, propertyName, newValue) => { setErrorTextDebounced.cancel(); // Do not execute the command on initial call (opening the table properties view). if (!this._isReady) { return; } if (validator(newValue)) { this.editor.execute(commandName, { value: newValue, batch: this._undoStepBatch }); viewField.errorText = null; } else { setErrorTextDebounced(); } }; } }