UNPKG

13.1 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module table/tableproperties/tablepropertiesui
7 */
8import { Plugin } from 'ckeditor5/src/core';
9import { ButtonView, ContextualBalloon, clickOutsideHandler, getLocalizedColorOptions, normalizeColorOptions } from 'ckeditor5/src/ui';
10import { debounce } from 'lodash-es';
11import TablePropertiesView from './ui/tablepropertiesview';
12import tableProperties from './../../theme/icons/table-properties.svg';
13import { colorFieldValidator, getLocalizedColorErrorText, getLocalizedLengthErrorText, lengthFieldValidator, lineWidthFieldValidator, defaultColors } from '../utils/ui/table-properties';
14import { getTableWidgetAncestor } from '../utils/ui/widget';
15import { getBalloonTablePositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon';
16import { getNormalizedDefaultProperties } from '../utils/table-properties';
17const ERROR_TEXT_TIMEOUT = 500;
18// Map of view properties and related commands.
19const propertyToCommandMap = {
20 borderStyle: 'tableBorderStyle',
21 borderColor: 'tableBorderColor',
22 borderWidth: 'tableBorderWidth',
23 backgroundColor: 'tableBackgroundColor',
24 width: 'tableWidth',
25 height: 'tableHeight',
26 alignment: 'tableAlignment'
27};
28/**
29 * The table properties UI plugin. It introduces the `'tableProperties'` button
30 * that opens a form allowing to specify visual styling of an entire table.
31 *
32 * It uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
33 */
34export default class TablePropertiesUI extends Plugin {
35 /**
36 * @inheritDoc
37 */
38 static get requires() {
39 return [ContextualBalloon];
40 }
41 /**
42 * @inheritDoc
43 */
44 static get pluginName() {
45 return 'TablePropertiesUI';
46 }
47 /**
48 * @inheritDoc
49 */
50 constructor(editor) {
51 super(editor);
52 /**
53 * The properties form view displayed inside the balloon.
54 */
55 this.view = null;
56 editor.config.define('table.tableProperties', {
57 borderColors: defaultColors,
58 backgroundColors: defaultColors
59 });
60 }
61 /**
62 * @inheritDoc
63 */
64 init() {
65 const editor = this.editor;
66 const t = editor.t;
67 this._defaultTableProperties = getNormalizedDefaultProperties(editor.config.get('table.tableProperties.defaultProperties'), {
68 includeAlignmentProperty: true
69 });
70 this._balloon = editor.plugins.get(ContextualBalloon);
71 editor.ui.componentFactory.add('tableProperties', locale => {
72 const view = new ButtonView(locale);
73 view.set({
74 label: t('Table properties'),
75 icon: tableProperties,
76 tooltip: true
77 });
78 this.listenTo(view, 'execute', () => this._showView());
79 const commands = Object.values(propertyToCommandMap)
80 .map(commandName => editor.commands.get(commandName));
81 view.bind('isEnabled').toMany(commands, 'isEnabled', (...areEnabled) => (areEnabled.some(isCommandEnabled => isCommandEnabled)));
82 return view;
83 });
84 }
85 /**
86 * @inheritDoc
87 */
88 destroy() {
89 super.destroy();
90 // Destroy created UI components as they are not automatically destroyed.
91 // See https://github.com/ckeditor/ckeditor5/issues/1341.
92 if (this.view) {
93 this.view.destroy();
94 }
95 }
96 /**
97 * Creates the {@link module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView} instance.
98 *
99 * @returns The table properties form view instance.
100 */
101 _createPropertiesView() {
102 const editor = this.editor;
103 const config = editor.config.get('table.tableProperties');
104 const borderColorsConfig = normalizeColorOptions(config.borderColors);
105 const localizedBorderColors = getLocalizedColorOptions(editor.locale, borderColorsConfig);
106 const backgroundColorsConfig = normalizeColorOptions(config.backgroundColors);
107 const localizedBackgroundColors = getLocalizedColorOptions(editor.locale, backgroundColorsConfig);
108 const view = new TablePropertiesView(editor.locale, {
109 borderColors: localizedBorderColors,
110 backgroundColors: localizedBackgroundColors,
111 defaultTableProperties: this._defaultTableProperties
112 });
113 const t = editor.t;
114 // Render the view so its #element is available for the clickOutsideHandler.
115 view.render();
116 this.listenTo(view, 'submit', () => {
117 this._hideView();
118 });
119 this.listenTo(view, 'cancel', () => {
120 // https://github.com/ckeditor/ckeditor5/issues/6180
121 if (this._undoStepBatch.operations.length) {
122 editor.execute('undo', this._undoStepBatch);
123 }
124 this._hideView();
125 });
126 // Close the balloon on Esc key press.
127 view.keystrokes.set('Esc', (data, cancel) => {
128 this._hideView();
129 cancel();
130 });
131 // Close on click outside of balloon panel element.
132 clickOutsideHandler({
133 emitter: view,
134 activator: () => this._isViewInBalloon,
135 contextElements: [this._balloon.view.element],
136 callback: () => this._hideView()
137 });
138 const colorErrorText = getLocalizedColorErrorText(t);
139 const lengthErrorText = getLocalizedLengthErrorText(t);
140 // Create the "UI -> editor data" binding.
141 // These listeners update the editor data (via table commands) when any observable
142 // property of the view has changed. They also validate the value and display errors in the UI
143 // when necessary. This makes the view live, which means the changes are
144 // visible in the editing as soon as the user types or changes fields' values.
145 view.on('change:borderStyle', this._getPropertyChangeCallback('tableBorderStyle'));
146 view.on('change:borderColor', this._getValidatedPropertyChangeCallback({
147 viewField: view.borderColorInput,
148 commandName: 'tableBorderColor',
149 errorText: colorErrorText,
150 validator: colorFieldValidator
151 }));
152 view.on('change:borderWidth', this._getValidatedPropertyChangeCallback({
153 viewField: view.borderWidthInput,
154 commandName: 'tableBorderWidth',
155 errorText: lengthErrorText,
156 validator: lineWidthFieldValidator
157 }));
158 view.on('change:backgroundColor', this._getValidatedPropertyChangeCallback({
159 viewField: view.backgroundInput,
160 commandName: 'tableBackgroundColor',
161 errorText: colorErrorText,
162 validator: colorFieldValidator
163 }));
164 view.on('change:width', this._getValidatedPropertyChangeCallback({
165 viewField: view.widthInput,
166 commandName: 'tableWidth',
167 errorText: lengthErrorText,
168 validator: lengthFieldValidator
169 }));
170 view.on('change:height', this._getValidatedPropertyChangeCallback({
171 viewField: view.heightInput,
172 commandName: 'tableHeight',
173 errorText: lengthErrorText,
174 validator: lengthFieldValidator
175 }));
176 view.on('change:alignment', this._getPropertyChangeCallback('tableAlignment'));
177 return view;
178 }
179 /**
180 * In this method the "editor data -> UI" binding is happening.
181 *
182 * When executed, this method obtains selected table property values from various table commands
183 * and passes them to the {@link #view}.
184 *
185 * This way, the UI stays up–to–date with the editor data.
186 */
187 _fillViewFormFromCommandValues() {
188 const commands = this.editor.commands;
189 const borderStyleCommand = commands.get('tableBorderStyle');
190 Object.entries(propertyToCommandMap)
191 .map(([property, commandName]) => {
192 const propertyKey = property;
193 const defaultValue = this._defaultTableProperties[propertyKey] || '';
194 return [propertyKey, (commands.get(commandName).value || defaultValue)];
195 })
196 .forEach(([property, value]) => {
197 // Do not set the `border-color` and `border-width` fields if `border-style:none`.
198 if ((property === 'borderColor' || property === 'borderWidth') && borderStyleCommand.value === 'none') {
199 return;
200 }
201 this.view.set(property, value);
202 });
203 this._isReady = true;
204 }
205 /**
206 * Shows the {@link #view} in the {@link #_balloon}.
207 *
208 * **Note**: Each time a view is shown, the new {@link #_undoStepBatch} is created that contains
209 * all changes made to the document when the view is visible, allowing a single undo step
210 * for all of them.
211 */
212 _showView() {
213 const editor = this.editor;
214 if (!this.view) {
215 this.view = this._createPropertiesView();
216 }
217 this.listenTo(editor.ui, 'update', () => {
218 this._updateView();
219 });
220 // Update the view with the model values.
221 this._fillViewFormFromCommandValues();
222 this._balloon.add({
223 view: this.view,
224 position: getBalloonTablePositionData(editor)
225 });
226 // Create a new batch. Clicking "Cancel" will undo this batch.
227 this._undoStepBatch = editor.model.createBatch();
228 // Basic a11y.
229 this.view.focus();
230 }
231 /**
232 * Removes the {@link #view} from the {@link #_balloon}.
233 */
234 _hideView() {
235 const editor = this.editor;
236 this.stopListening(editor.ui, 'update');
237 this._isReady = false;
238 // Blur any input element before removing it from DOM to prevent issues in some browsers.
239 // See https://github.com/ckeditor/ckeditor5/issues/1501.
240 this.view.saveButtonView.focus();
241 this._balloon.remove(this.view);
242 // Make sure the focus is not lost in the process by putting it directly
243 // into the editing view.
244 this.editor.editing.view.focus();
245 }
246 /**
247 * Repositions the {@link #_balloon} or hides the {@link #view} if a table is no longer selected.
248 */
249 _updateView() {
250 const editor = this.editor;
251 const viewDocument = editor.editing.view.document;
252 if (!getTableWidgetAncestor(viewDocument.selection)) {
253 this._hideView();
254 }
255 else if (this._isViewVisible) {
256 repositionContextualBalloon(editor, 'table');
257 }
258 }
259 /**
260 * Returns `true` when the {@link #view} is the visible in the {@link #_balloon}.
261 */
262 get _isViewVisible() {
263 return !!this.view && this._balloon.visibleView === this.view;
264 }
265 /**
266 * Returns `true` when the {@link #view} is in the {@link #_balloon}.
267 */
268 get _isViewInBalloon() {
269 return !!this.view && this._balloon.hasView(this.view);
270 }
271 /**
272 * Creates a callback that when executed upon {@link #view view's} property change
273 * executes a related editor command with the new property value.
274 *
275 * If new value will be set to the default value, the command will not be executed.
276 *
277 * @param commandName The command that will be executed.
278 */
279 _getPropertyChangeCallback(commandName) {
280 return (evt, propertyName, newValue) => {
281 // Do not execute the command on initial call (opening the table properties view).
282 if (!this._isReady) {
283 return;
284 }
285 this.editor.execute(commandName, {
286 value: newValue,
287 batch: this._undoStepBatch
288 });
289 };
290 }
291 /**
292 * Creates a callback that when executed upon {@link #view view's} property change:
293 * * executes a related editor command with the new property value if the value is valid,
294 * * or sets the error text next to the invalid field, if the value did not pass the validation.
295 */
296 _getValidatedPropertyChangeCallback(options) {
297 const { commandName, viewField, validator, errorText } = options;
298 const setErrorTextDebounced = debounce(() => {
299 viewField.errorText = errorText;
300 }, ERROR_TEXT_TIMEOUT);
301 return (evt, propertyName, newValue) => {
302 setErrorTextDebounced.cancel();
303 // Do not execute the command on initial call (opening the table properties view).
304 if (!this._isReady) {
305 return;
306 }
307 if (validator(newValue)) {
308 this.editor.execute(commandName, {
309 value: newValue,
310 batch: this._undoStepBatch
311 });
312 viewField.errorText = null;
313 }
314 else {
315 setErrorTextDebounced();
316 }
317 };
318 }
319}