8
0
Просмотр исходного кода

First implementation of the cell properties form.

Aleksander Nowodzinski 6 лет назад
Родитель
Сommit
aa6dfddd98

+ 8 - 0
packages/ckeditor5-table/src/tablecellproperties.js

@@ -8,6 +8,7 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TableCellPropertiesUI from './tablecellpropertiesui';
 import { downcastToStyle, upcastAttribute, upcastBorderStyles } from './tableproperties/utils';
 
 /**
@@ -23,6 +24,13 @@ export default class TableCellProperties extends Plugin {
 		return 'TableCellProperties';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableCellPropertiesUI ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */

+ 317 - 0
packages/ckeditor5-table/src/tablecellpropertiesui.js

@@ -0,0 +1,317 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellpropertiesui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { getTableWidgetAncestor } from './utils';
+import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import TableCellPropertiesView from './ui/tablecellpropertiesview';
+import tableCellProperties from './../theme/icons/table-cell-properties.svg';
+import { repositionContextualBalloon, getBalloonPositionData } from './ui/utils';
+import { findAncestor } from './commands/utils';
+
+const DEFAULT_BORDER_STYLE = 'none';
+const DEFAULT_HORIZONTAL_ALIGNMENT = 'left';
+const DEFAULT_VERTICAL_ALIGNMENT = 'middle';
+
+// Attributes that set the same value for "top", "right", "bottom", and "left".
+const QUAD_DIRECTION_ATTRIBUTES = [ 'borderStyle', 'borderWidth', 'borderColor', 'padding' ];
+
+/**
+ * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button
+ * that opens a form allowing to specify visual styling of a table cell.
+ *
+ * It uses the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCellPropertiesUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableCellPropertiesUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = editor.plugins.get( ContextualBalloon );
+
+		/**
+		 * The batch used to undo all changes made by the form (which are live)
+		 * if "Cancel" was pressed. Each time the view is shown, a new batch is created.
+		 *
+		 * @private
+		 * @member {module:engine/model/batch~Batch}
+		 */
+		this._batch = null;
+
+		// Create the view that displays the properties form.
+		this._createPropertiesView();
+
+		// Make the form dynamic, i.e. create bindings between view fields and the model.
+		this._startRespondingToChangesInView();
+
+		editor.ui.componentFactory.add( 'tableCellProperties', locale => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Cell properties' ),
+				icon: tableCellProperties,
+				tooltip: true
+			} );
+
+			this.listenTo( view, 'execute', () => this._showView() );
+
+			return view;
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		// Destroy created UI components as they are not automatically destroyed.
+		// See https://github.com/ckeditor/ckeditor5/issues/1341.
+		this.view.destroy();
+	}
+
+	/**
+	 * Creates the {@link module:table/ui/tablecellpropertiesview~TableCellPropertiesView} instance.
+	 *
+	 * @private
+	 * @returns {module:table/ui/tablecellpropertiesview~TableCellPropertiesView} The cell properties form
+	 * view instance.
+	 */
+	_createPropertiesView() {
+		const editor = this.editor;
+		const view = editor.editing.view;
+		const viewDocument = view.document;
+
+		/**
+		 * The properties form view displayed inside the balloon.
+		 *
+		 * @member {module:table/ui/tablecellpropertiesview~TableCellPropertiesView}
+		 */
+		this.view = new TableCellPropertiesView( editor.locale );
+
+		// Render the view so its #element is available for clickOutsideHandler.
+		this.view.render();
+
+		this.listenTo( this.view, 'submit', () => {
+			this._hideView();
+		} );
+
+		this.listenTo( this.view, 'cancel', () => {
+			editor.execute( 'undo', this._batch );
+			this._hideView();
+		} );
+
+		// Close the balloon on Esc key press when the **form has focus**.
+		this.view.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideView();
+			cancel();
+		} );
+
+		// Reposition the balloon or hide the form if an image widget is no longer selected.
+		this.listenTo( editor.ui, 'update', () => {
+			if ( !getTableWidgetAncestor( viewDocument.selection ) ) {
+				this._hideView();
+			} else if ( this._isViewVisible ) {
+				repositionContextualBalloon( editor );
+			}
+		} );
+
+		// Close on click outside of balloon panel element.
+		clickOutsideHandler( {
+			emitter: this.view,
+			activator: () => this._isViewInBalloon,
+			contextElements: [ this._balloon.view.element ],
+			callback: () => this._hideView()
+		} );
+	}
+
+	_startRespondingToChangesInView() {
+		const editor = this.editor;
+		const model = editor.model;
+		const document = model.document;
+		const selection = document.selection;
+
+		this.view.on( 'update', ( evt, data ) => {
+			const firstPosition = selection.getFirstPosition();
+			const tableCell = findAncestor( 'tableCell', firstPosition );
+
+			// Enqueue all changes into a single batch so clicking "Cancel" can undo them
+			// as a single undo steps. It's a better UX than dozens of undo steps, e.g. each
+			// for a single value change.
+			editor.model.enqueueChange( this._batch, writer => {
+				for ( const property in data ) {
+					const value = data[ property ];
+
+					if ( QUAD_DIRECTION_ATTRIBUTES.includes( property ) ) {
+						writer.setAttribute( property, {
+							top: value,
+							right: value,
+							bottom: value,
+							left: value
+						}, tableCell );
+					} else {
+						writer.setAttribute( property, value, tableCell );
+					}
+				}
+			} );
+		} );
+	}
+
+	_fillViewFormFromSelectedCell() {
+		const editor = this.editor;
+		const model = editor.model;
+		const document = model.document;
+		const selection = document.selection;
+		const firstPosition = selection.getFirstPosition();
+		const tableCell = findAncestor( 'tableCell', firstPosition );
+
+		const borderWidth = unifyQuadDirectionPropertyValue( tableCell.getAttribute( 'borderWidth' ) ) || '';
+		const borderColor = unifyQuadDirectionPropertyValue( tableCell.getAttribute( 'borderColor' ) ) || '';
+		const borderStyle = unifyQuadDirectionPropertyValue( tableCell.getAttribute( 'borderStyle' ) ) || DEFAULT_BORDER_STYLE;
+		const padding = unifyQuadDirectionPropertyValue( tableCell.getAttribute( 'padding' ) ) || '';
+		const backgroundColor = tableCell.getAttribute( 'backgroundColor' ) || '';
+		const horizontalAlignment = tableCell.getAttribute( 'horizontalAlignment' ) || DEFAULT_HORIZONTAL_ALIGNMENT;
+		const verticalAlignment = tableCell.getAttribute( 'verticalAlignment' ) || DEFAULT_VERTICAL_ALIGNMENT;
+
+		const view = this.view;
+
+		view.borderWidthInput.inputView.element.value = '';
+		view.borderColorInput.inputView.element.value = '';
+		view.paddingInput.inputView.element.value = '';
+		view.backgroundInput.inputView.element.value = '';
+
+		view.set( {
+			borderWidth,
+			borderColor,
+			borderStyle,
+			padding,
+			backgroundColor,
+			horizontalAlignment,
+			verticalAlignment
+		} );
+	}
+
+	_showView() {
+		if ( this._isViewVisible ) {
+			return;
+		}
+
+		const editor = this.editor;
+
+		if ( !this._isViewInBalloon ) {
+			this._balloon.add( {
+				view: this.view,
+				position: getBalloonPositionData( editor )
+			} );
+		}
+
+		// Create a new batch. Clicking "Cancel" will undo this batch.
+		this._batch = editor.model.createBatch();
+
+		// Update the view with the model values.
+		this._fillViewFormFromSelectedCell();
+
+		// Basic a11y.
+		this.view.focus();
+	}
+
+	/**
+	 * Removes the {@link #view} from the {@link #_balloon}.
+	 *
+	 * See {@link #_addFormView}, {@link #_addActionsView}.
+	 *
+	 * @protected
+	 */
+	_hideView() {
+		if ( !this._isViewInBalloon ) {
+			return;
+		}
+
+		const editor = this.editor;
+
+		this.stopListening( editor.ui, 'update' );
+		this.stopListening( this._balloon, 'change:visibleView' );
+
+		// Make sure the focus always gets back to the editable _before_ removing the focused properties view.
+		// Doing otherwise causes issues in some browsers. See https://github.com/ckeditor/ckeditor5-link/issues/193.
+		editor.editing.view.focus();
+
+		if ( this._isViewInBalloon ) {
+			// TODO below
+			// Blur the input element before removing it from DOM to prevent issues in some browsers.
+			// See https://github.com/ckeditor/ckeditor5/issues/1501.
+			// this.formView.saveButtonView.focus();
+
+			this._balloon.remove( this.view );
+
+			// Because the form has an input which has focus, the focus must be brought back
+			// to the editor. Otherwise, it would be lost.
+			this.editor.editing.view.focus();
+		}
+	}
+
+	/**
+	 * Returns `true` when the {@link #view} is the visible view in the {@link #_balloon}.
+	 *
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _isViewVisible() {
+		return this._balloon.visibleView === this.view;
+	}
+
+	/**
+	 * Returns `true` when the {@link #view} is in the {@link #_balloon}.
+	 *
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _isViewInBalloon() {
+		return this._balloon.hasView( this.view );
+	}
+}
+
+function unifyQuadDirectionPropertyValue( value ) {
+	if ( !value ) {
+		return;
+	}
+
+	// Unify width to one value. If different values are set default to top (or right, etc).
+	value = value.top || value.right || value.bottom || value.left;
+
+	return value;
+}

+ 8 - 0
packages/ckeditor5-table/src/tableproperties.js

@@ -8,6 +8,7 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TablePropertiesUI from './tablepropertiesui';
 import { downcastTableAttribute, upcastAttribute, upcastBorderStyles } from './tableproperties/utils';
 
 /**
@@ -23,6 +24,13 @@ export default class TableProperties extends Plugin {
 		return 'TableProperties';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TablePropertiesUI ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */

+ 45 - 0
packages/ckeditor5-table/src/tablepropertiesui.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablepropertiesui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import tableProperties from './../theme/icons/table-properties.svg';
+
+/**
+ * TODO
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TablePropertiesUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'tableProperties', locale => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Table properties' ),
+				icon: tableProperties,
+				tooltip: true
+			} );
+
+			this.listenTo( view, 'execute', () => this._showUI() );
+
+			return view;
+		} );
+	}
+
+	_showUI() {
+	}
+}

+ 581 - 0
packages/ckeditor5-table/src/ui/tablecellpropertiesview.js

@@ -0,0 +1,581 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/ui/tablecellpropertiesview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import Model from '@ckeditor/ckeditor5-ui/src/model';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+
+import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
+import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
+import LabelView from '@ckeditor/ckeditor5-ui/src/label/labelview';
+import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import uid from '@ckeditor/ckeditor5-utils/src/uid';
+
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+
+// TODO: These **must** be transferred to ckeditor5-core.
+import alignLeftIcon from '@ckeditor/ckeditor5-alignment/theme/icons/align-left.svg';
+import alignRightIcon from '@ckeditor/ckeditor5-alignment/theme/icons/align-right.svg';
+import alignCenterIcon from '@ckeditor/ckeditor5-alignment/theme/icons/align-center.svg';
+import alignJustifyIcon from '@ckeditor/ckeditor5-alignment/theme/icons/align-justify.svg';
+
+import alignTopIcon from '../../theme/icons/align-top.svg';
+import alignMiddleIcon from '../../theme/icons/align-middle.svg';
+import alignBottomIcon from '../../theme/icons/align-bottom.svg';
+
+import '../../theme/form.css';
+import '../../theme/tablecellproperties.css';
+
+const ALIGNMENT_ICONS = {
+	left: alignLeftIcon,
+	center: alignCenterIcon,
+	right: alignRightIcon,
+	justify: alignJustifyIcon,
+	top: alignTopIcon,
+	middle: alignMiddleIcon,
+	bottom: alignBottomIcon
+};
+
+/**
+ * TODO
+ *
+ * @extends module:ui/view~View
+ */
+export default class TableCellPropertiesView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		/**
+		 * Tracks information about the DOM focus in the form.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * A collection of views that can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		this._createBorderFields();
+		this._createBackgroundField();
+		this._createPaddingField();
+		this._createAlignmentFields();
+		this._createActionButtons();
+
+		this.set( {
+			borderStyle: 'none',
+			borderWidth: null,
+			borderColor: null,
+			padding: null,
+			backgroundColor: null,
+			horizontalAlignment: 'left',
+			verticalAlignment: 'middle'
+		} );
+
+		this.setTemplate( {
+			tag: 'form',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form',
+					'ck-table-cell-properties-form'
+				],
+				// https://github.com/ckeditor/ckeditor5-link/issues/90
+				tabindex: '-1'
+			},
+			children: [
+				{
+					tag: 'div',
+					attributes: {
+						class: [
+							'ck',
+							'ck-form__header'
+						]
+					},
+					children: [
+						'Cell properties'
+					]
+				},
+
+				// Border
+				createFormRowDefinition( {
+					ariaLabelledBy: this.borderRowLabel,
+					className: 'ck-table-cell-properties-form__border-row',
+					children: [
+						this.borderRowLabel,
+
+						// TODO: This should become a new component or be integrated into LabeledInputView.
+						{
+							tag: 'div',
+							attributes: {
+								class: [
+									'ck',
+									'ck-labeled-dropdown',
+									'ck-table-cell-properties-form__border-style'
+								],
+							},
+							children: [
+								this.borderStyleDropdownLabel,
+								this.borderStyleDropdown,
+							]
+						},
+						this.borderWidthInput,
+						this.borderColorInput
+					]
+				} ),
+
+				// Background & Padding
+				createFormRowDefinition( {
+					children: [
+						this.paddingInput,
+						this.backgroundInput,
+					]
+				} ),
+
+				// Alignment
+				createFormRowDefinition( {
+					ariaLabelledBy: this.alignmentLabel,
+					className: 'ck-table-cell-properties-form__alignment-row',
+					children: [
+						this.alignmentLabel,
+						this.horizontalAlignmentToolbar,
+						this.verticalAlignmentToolbar
+					]
+				} ),
+
+				// Action buttons
+				createFormRowDefinition( {
+					className: 'ck-table-cell-properties-form__action-row',
+					children: [
+						this.saveButtonView,
+						this.cancelButtonView
+					]
+				} )
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		submitHandler( {
+			view: this
+		} );
+
+		const focusableChildViews = [
+			this.borderStyleDropdown,
+			this.borderWidthInput,
+			this.borderColorInput,
+			this.paddingInput,
+			this.backgroundInput,
+			this.horizontalAlignmentToolbar,
+			this.verticalAlignmentToolbar,
+			this.saveButtonView,
+			this.cancelButtonView
+		];
+
+		focusableChildViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		this.keystrokes.listenTo( this.element );
+	}
+
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * TODO
+	 */
+	_createBorderFields() {
+		const locale = this.locale;
+		const t = this.t;
+
+		// -- Group label ---------------------------------------------
+
+		const borderRowLabel = this.borderRowLabel = new LabelView( locale );
+		borderRowLabel.text = t( 'Border' );
+
+		// -- Style ---------------------------------------------------
+
+		const borderStyleDropdown = this.borderStyleDropdown = createDropdown( locale );
+		borderStyleDropdown.buttonView.set( {
+			isOn: false,
+			withText: true,
+			tooltip: t( 'Style' )
+		} );
+
+		borderStyleDropdown.buttonView.bind( 'label' ).to( this, 'borderStyle', value => {
+			return this._borderStyleLabels[ value ];
+		} );
+
+		borderStyleDropdown.on( 'execute', evt => {
+			const value = evt.source._borderStyleValue;
+
+			// Update the UI.
+			this.borderStyle = value;
+
+			// Update the editor model.
+			this.fire( 'update', {
+				borderStyle: evt.source._borderStyleValue
+			} );
+		} );
+
+		addListToDropdown( borderStyleDropdown, this._getBorderStyleDefinitions() );
+
+		this.borderStyleDropdownLabel = new LabelView( locale );
+		this.borderStyleDropdownLabel.text = t( 'Style' );
+
+		// -- Width ---------------------------------------------------
+
+		const borderWidthInput = this.borderWidthInput = new LabeledInputView( locale, InputTextView );
+
+		borderWidthInput.set( {
+			label: t( 'Width' ),
+			class: 'ck-table-cell-properties-form__border-width',
+		} );
+
+		borderWidthInput.bind( 'value' ).to( this, 'borderWidth' );
+		borderWidthInput.bind( 'isReadOnly' ).to( this, 'borderStyle', value => {
+			return value === 'none';
+		} );
+		borderWidthInput.inputView.on( 'input', () => {
+			this.fire( 'update', {
+				borderWidth: borderWidthInput.inputView.element.value
+			} );
+		} );
+
+		// -- Color ---------------------------------------------------
+
+		const borderColorInput = this.borderColorInput = new LabeledInputView( locale, InputTextView );
+		borderColorInput.label = t( 'Color' );
+		borderColorInput.bind( 'value' ).to( this, 'borderColor' );
+		borderColorInput.bind( 'isReadOnly' ).to( this, 'borderStyle', value => {
+			return value === 'none';
+		} );
+
+		borderColorInput.inputView.on( 'input', () => {
+			this.fire( 'update', {
+				borderColor: borderColorInput.inputView.element.value
+			} );
+		} );
+	}
+
+	/**
+	 * TODO
+	 */
+	_createBackgroundField() {
+		const locale = this.locale;
+		const t = this.t;
+		const backgroundInput = this.backgroundInput = new LabeledInputView( locale, InputTextView );
+
+		backgroundInput.label = t( 'Background' );
+		backgroundInput.bind( 'value' ).to( this, 'backgroundColor' );
+
+		backgroundInput.inputView.on( 'input', () => {
+			this.fire( 'update', {
+				backgroundColor: backgroundInput.inputView.element.value
+			} );
+		} );
+	}
+
+	/**
+	 * TODO
+	 */
+	_createPaddingField() {
+		const locale = this.locale;
+		const t = this.t;
+		const paddingInput = this.paddingInput = new LabeledInputView( locale, InputTextView );
+
+		paddingInput.set( {
+			label: t( 'Padding' ),
+			class: 'ck-table-cell-properties-form__padding',
+		} );
+
+		paddingInput.bind( 'value' ).to( this, 'padding' );
+		paddingInput.inputView.on( 'input', () => {
+			this.fire( 'update', {
+				padding: paddingInput.inputView.element.value
+			} );
+		} );
+	}
+
+	/**
+	 * TODO
+	 */
+	_createAlignmentFields() {
+		const locale = this.locale;
+		const t = this.t;
+
+		this.alignmentLabel = new LabelView( locale );
+		this.alignmentLabel.text = t( 'Text alignment' );
+
+		// -- Horizontal ---------------------------------------------------
+
+		this.horizontalAlignmentToolbar = new ToolbarView( locale );
+		this.horizontalAlignmentToolbar.ariaLabel = t( 'Horizontal text alignment toolbar' );
+		this._fillAlignmentToolbar( this.horizontalAlignmentToolbar, this._horizontalAlignmentLabels, 'horizontalAlignment' );
+
+		// -- Vertical -----------------------------------------------------
+
+		this.verticalAlignmentToolbar = new ToolbarView( locale );
+		this.verticalAlignmentToolbar.ariaLabel = t( 'Vertical text alignment toolbar' );
+		this._fillAlignmentToolbar( this.verticalAlignmentToolbar, this._verticalAlignmentLabels, 'verticalAlignment' );
+	}
+
+	/**
+	 *
+	 */
+	_createActionButtons() {
+		const locale = this.locale;
+		const t = this.t;
+
+		/**
+		 * The Save button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		const saveButtonView = this.saveButtonView = new ButtonView( locale );
+
+		saveButtonView.set( {
+			label: t( 'Save' ),
+			icon: checkIcon,
+			class: 'ck-button-save',
+			type: 'submit',
+			withText: true,
+		} );
+
+		/**
+		 * The Cancel button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		const cancelButtonView = this.cancelButtonView = new ButtonView( locale );
+
+		cancelButtonView.set( {
+			label: t( 'Cancel' ),
+			icon: cancelIcon,
+			class: 'ck-button-cancel',
+			type: 'cancel',
+			withText: true,
+		} );
+
+		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
+	}
+
+	/**
+	 * TODO
+	 */
+	_getBorderStyleDefinitions() {
+		const itemDefinitions = new Collection();
+
+		for ( const style in this._borderStyleLabels ) {
+			const definition = {
+				type: 'button',
+				model: new Model( {
+					_borderStyleValue: style,
+					label: this._borderStyleLabels[ style ],
+					withText: true,
+				} )
+			};
+
+			definition.model.bind( 'isOn' ).to( this, 'borderStyle', value => {
+				return value === style;
+			} );
+
+			itemDefinitions.add( definition );
+		}
+
+		return itemDefinitions;
+	}
+
+	/**
+	 * TODO
+	 *
+	 * @param {*} toolbar
+	 * @param {*} labels
+	 * @param {*} propertyName
+	 */
+	_fillAlignmentToolbar( toolbar, labels, propertyName ) {
+		for ( const alignment in labels ) {
+			const button = createAlignmentButton(
+				this.locale,
+				labels[ alignment ],
+				ALIGNMENT_ICONS[ alignment ]
+			);
+
+			button.bind( 'isOn' ).to( this, propertyName, value => {
+				return value === alignment;
+			} );
+
+			button.on( 'execute', () => {
+				// Update the UI.
+				this[ propertyName ] = alignment;
+
+				// Update the editor model.
+				this.fire( 'update', {
+					[ propertyName ]: alignment
+				} );
+			} );
+
+			toolbar.items.add( button );
+		}
+	}
+
+	/**
+	 * TODO
+	 */
+	get _borderStyleLabels() {
+		const t = this.t;
+
+		return {
+			none: t( 'None' ),
+			solid: t( 'Solid' ),
+			dotted: t( 'Dotted' ),
+			dashed: t( 'Dashed' ),
+			double: t( 'Double' ),
+			groove: t( 'Groove' ),
+			ridge: t( 'Ridge' ),
+			inset: t( 'Inset' ),
+			outset: t( 'Outset' ),
+		};
+	}
+
+	/**
+	 * TODO
+	 */
+	get _horizontalAlignmentLabels() {
+		const t = this.t;
+
+		return {
+			left: t( 'Align cell text to the left' ),
+			center: t( 'Align cell text to the center' ),
+			right: t( 'Align cell text to the right' ),
+			justify: t( 'Justify cell text' ),
+		};
+	}
+
+	/**
+	 * TODO
+	 */
+	get _verticalAlignmentLabels() {
+		const t = this.t;
+
+		return {
+			top: t( 'Align cell text to the top' ),
+			middle: t( 'Align cell text to the middle' ),
+			bottom: t( 'Align cell text to the bottom' )
+		};
+	}
+}
+
+function createAlignmentButton( locale, label, icon ) {
+	const button = new ButtonView( locale );
+
+	button.set( {
+		label,
+		icon,
+	} );
+
+	return button;
+}
+
+function createFormRowDefinition( {
+	children,
+	className,
+	ariaLabelledBy
+} ) {
+	const def = {
+		tag: 'div',
+		attributes: {
+			class: [
+				'ck',
+				'ck-form__row'
+			]
+		},
+		children
+	};
+
+	// Note: Flexbox does not work on fieldset elements in Chrome
+	// (https://bugs.chromium.org/p/chromium/issues/detail?id=375693).
+	// This is why "role" is used and the label has an id. It's a hack but better than nothing.
+	if ( ariaLabelledBy ) {
+		const id = `ck-editor__aria-label_${ uid() }`;
+
+		ariaLabelledBy.extendTemplate( {
+			attributes: {
+				id
+			}
+		} );
+
+		def.attributes.role = 'group';
+		def.attributes[ 'aria-labelledby' ] = id;
+	}
+
+	if ( className ) {
+		def.attributes.class.push( className );
+	}
+
+	return def;
+}

+ 54 - 0
packages/ckeditor5-table/src/ui/utils.js

@@ -0,0 +1,54 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/ui/utils
+ */
+
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { getTableWidgetAncestor } from '../utils';
+
+/**
+ * A helper utility that positions the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance
+ * with respect to the table in the editor content, if one is selected.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ */
+export function repositionContextualBalloon( editor ) {
+	const balloon = editor.plugins.get( 'ContextualBalloon' );
+
+	if ( getTableWidgetAncestor( editor.editing.view.document.selection ) ) {
+		const position = getBalloonPositionData( editor );
+
+		balloon.updatePosition( position );
+	}
+}
+
+/**
+ * Returns the positioning options that control the geometry of the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
+ * to the selected element in the editor content.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @returns {module:utils/dom/position~Options}
+ */
+export function getBalloonPositionData( editor ) {
+	const editingView = editor.editing.view;
+	const defaultPositions = BalloonPanelView.defaultPositions;
+	const modelWidget = getTableWidgetAncestor( editor.editing.view.document.selection );
+
+	return {
+		target: editingView.domConverter.viewToDom( modelWidget ),
+		positions: [
+			defaultPositions.northArrowSouth,
+			defaultPositions.northArrowSouthWest,
+			defaultPositions.northArrowSouthEast,
+			defaultPositions.southArrowNorth,
+			defaultPositions.southArrowNorthWest,
+			defaultPositions.southArrowNorthEast
+		]
+	};
+}

+ 6 - 4
packages/ckeditor5-table/tests/manual/tableproperties.js

@@ -13,7 +13,6 @@ import Indent from '@ckeditor/ckeditor5-indent/src/indent';
 import TableProperties from '../../src/tableproperties';
 import TableCellProperties from '../../src/tablecellproperties';
 import TableColumnRowProperties from '../../src/tablecolumnrowproperties';
-import TableStyleUI from '../../src/tablestyleui';
 
 const sourceElement = document.querySelector( '#editor' );
 const clonedSource = sourceElement.cloneNode( true );
@@ -22,13 +21,16 @@ document.querySelector( '#cloned-source' ).append( ...clonedSource.childNodes );
 
 ClassicEditor
 	.create( sourceElement, {
-		plugins: [ ArticlePluginSet, Alignment, Indent, IndentBlock, TableProperties, TableColumnRowProperties, TableCellProperties,
-			TableStyleUI ],
+		plugins: [
+			ArticlePluginSet, Alignment, Indent, IndentBlock,
+
+			TableProperties, TableColumnRowProperties, TableCellProperties
+		],
 		toolbar: [
 			'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
 		],
 		table: {
-			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableCellStyle' ],
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ],
 			tableToolbar: [ 'bold', 'italic' ]
 		}
 	} )

+ 69 - 0
packages/ckeditor5-table/theme/form.css

@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-form {
+	padding: 0 0 var(--ck-spacing-large);
+
+	&:focus {
+		/* https://github.com/ckeditor/ckeditor5-link/issues/90 */
+		outline: none;
+	}
+
+	& .ck.ck-form__header {
+		font-weight: bold;
+		padding: 0 var(--ck-spacing-large);
+		height: 38px;
+		line-height: 38px;
+		border-bottom: 1px solid var(--ck-color-base-border);
+	}
+
+	& .ck.ck-input-text {
+		min-width: 100%;
+		width: 0;
+	}
+
+	& .ck.ck-dropdown {
+		min-width: 100%;
+
+		& .ck-dropdown__button {
+			&:not(:focus) {
+				border: 1px solid var(--ck-color-base-border);
+			}
+
+			& .ck-button__label {
+				width: 4em;
+			}
+		}
+	}
+
+	& .ck-form__row {
+		display: flex;
+		flex-direction: row;
+		flex-wrap: nowrap;
+		justify-content: space-between;
+
+		padding: var(--ck-spacing-standard) var(--ck-spacing-large) 0;
+
+		/* Ignore labels that work as fieldset legends */
+		& > *:not(.ck-label) {
+			flex-grow: 1;
+
+			& + * {
+				padding-left: var(--ck-spacing-large);
+			}
+		}
+
+		&.ck-table-cell-properties-form__action-row {
+			& .ck-button-save,
+			& .ck-button-cancel {
+				justify-content: center;
+			}
+
+			& .ck-button .ck-button__label {
+				color: var(--ck-color-text);
+			}
+		}
+	}
+}

+ 1 - 0
packages/ckeditor5-table/theme/icons/align-bottom.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M9.239 13.938l-2.88-1.663a.75.75 0 01.75-1.3L9 12.067V4.75a.75.75 0 111.5 0v7.318l1.89-1.093a.75.75 0 01.75 1.3l-2.879 1.663a.752.752 0 01-.511.187.752.752 0 01-.511-.187zM4.25 17a.75.75 0 110-1.5h10.5a.75.75 0 010 1.5H4.25z"/></svg>

+ 1 - 0
packages/ckeditor5-table/theme/icons/align-middle.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M9.75 11.875a.752.752 0 01.508.184l2.883 1.666a.75.75 0 01-.659 1.344l-.091-.044-1.892-1.093.001 4.318a.75.75 0 11-1.5 0v-4.317l-1.89 1.092a.75.75 0 01-.75-1.3l2.879-1.663a.752.752 0 01.51-.187zM15.25 9a.75.75 0 110 1.5H4.75a.75.75 0 110-1.5h10.5zM9.75.375a.75.75 0 01.75.75v4.318l1.89-1.093.092-.045a.75.75 0 01.659 1.344l-2.883 1.667a.752.752 0 01-.508.184.752.752 0 01-.511-.187L6.359 5.65a.75.75 0 01.75-1.299L9 5.442 9 1.125a.75.75 0 01.75-.75z"/></svg>

+ 1 - 0
packages/ckeditor5-table/theme/icons/align-top.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.261 7.062l2.88 1.663a.75.75 0 01-.75 1.3L10.5 8.933v7.317a.75.75 0 11-1.5 0V8.932l-1.89 1.093a.75.75 0 01-.75-1.3l2.879-1.663a.752.752 0 01.511-.187.752.752 0 01.511.187zM15.25 4a.75.75 0 110 1.5H4.75a.75.75 0 010-1.5h10.5z"/></svg>

+ 1 - 0
packages/ckeditor5-table/theme/icons/table-cell-properties.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g><path d="M11.105 18l-.17 1H2.5A1.5 1.5 0 011 17.5v-15A1.5 1.5 0 012.5 1h15A1.5 1.5 0 0119 2.5v9.975l-.85-.124-.15-.302V8h-5v4h.021l-.172.351-1.916.28-.151.027c-.287.063-.54.182-.755.341L8 13v5h3.105zM2 12h5V8H2v4zm10-4H8v4h4V8zM2 2v5h5V2H2zm0 16h5v-5H2v5zM13 7h5V2h-5v5zM8 2v5h4V2H8z" opacity=".6"/><path d="M15.5 11.5l1.323 2.68 2.957.43-2.14 2.085.505 2.946L15.5 18.25l-2.645 1.39.505-2.945-2.14-2.086 2.957-.43L15.5 11.5zM13 6a1 1 0 011 1v3.172a2.047 2.047 0 00-.293.443l-.858 1.736-1.916.28-.151.027A1.976 1.976 0 009.315 14H7a1 1 0 01-1-1V7a1 1 0 011-1h6zm-1 2H8v4h4V8z"/></g></svg>

+ 1 - 0
packages/ckeditor5-table/theme/icons/table-properties.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g><path d="M8 2v5h4V2h1v5h5v1h-5v4h.021l-.172.351-1.916.28-.151.027c-.287.063-.54.182-.755.341L8 13v5H7v-5H2v-1h5V8H2V7h5V2h1zm4 6H8v4h4V8z" opacity=".6"/><path d="M15.5 11.5l1.323 2.68 2.957.43-2.14 2.085.505 2.946L15.5 18.25l-2.645 1.39.505-2.945-2.14-2.086 2.957-.43L15.5 11.5zM17 1a2 2 0 012 2v9.475l-.85-.124-.857-1.736a2.048 2.048 0 00-.292-.44L17 3H3v14h7.808l.402.392L10.935 19H3a2 2 0 01-2-2V3a2 2 0 012-2h14z"/></g></svg>

+ 84 - 0
packages/ckeditor5-table/theme/tablecellproperties.css

@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-table-cell-properties-form {
+	width: 280px;
+
+	& .ck-form__row {
+		&.ck-table-cell-properties-form__border-row {
+			flex-wrap: wrap;
+
+			& > .ck.ck-label {
+				width: 100%;
+				min-width: 100%;
+			}
+
+			& .ck-labeled-input,
+			& .ck-labeled-dropdown {
+				display: flex;
+				flex-direction: column-reverse;
+				align-items: center;
+
+				& .ck-label {
+					font-size: 10px;
+				}
+			}
+
+			& .ck-labeled-dropdown {
+				flex-grow: 0;
+			}
+
+			& .ck-table-cell-properties-form__border-style {
+				width: 80px;
+				min-width: 80px;
+			}
+
+			& .ck-table-cell-properties-form__border-width {
+				width: 55px;
+				min-width: 55px;
+				flex-grow: 0;
+			}
+		}
+
+		& .ck-table-cell-properties-form__padding {
+			width: 135px;
+			min-width: 135px;
+			flex-grow: 0;
+		}
+
+		&.ck-table-cell-properties-form__alignment-row {
+			flex-wrap: wrap;
+
+			& > .ck.ck-label {
+				width: 100%;
+				min-width: 100%;
+			}
+
+			& .ck.ck-toolbar {
+				padding: 0;
+				background: 0;
+				flex-grow: 0;
+
+				& .ck-toolbar__items > * {
+					margin: 0;
+
+					&:first-child {
+						border-top-right-radius: 0;
+						border-bottom-right-radius: 0;
+					}
+
+					&:last-child {
+						border-top-left-radius: 0;
+						border-bottom-left-radius: 0;
+					}
+
+					&:not(:first-child):not(:last-child) {
+						border-radius: 0;
+					}
+				}
+			}
+		}
+	}
+}