Sfoglia il codice sorgente

Merge pull request #94 from ckeditor/t/ckeditor5-image/241

Feature: Introduced image widget resizer. See ckeditor/ckeditor5-image#241.
Piotrek Koszuliński 6 anni fa
parent
commit
9aa97a09ee

+ 2 - 1
packages/ckeditor5-widget/package.json

@@ -12,7 +12,8 @@
     "@ckeditor/ckeditor5-core": "^12.2.1",
     "@ckeditor/ckeditor5-engine": "^13.2.1",
     "@ckeditor/ckeditor5-ui": "^13.0.2",
-    "@ckeditor/ckeditor5-utils": "^13.0.1"
+    "@ckeditor/ckeditor5-utils": "^13.0.1",
+    "lodash-es": "^4.17.10"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^11.1.3",

+ 162 - 0
packages/ckeditor5-widget/src/widgetresize.js

@@ -0,0 +1,162 @@
+/**
+ * @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 widget/widgetresize
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Resizer from './widgetresize/resizer';
+import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import { throttle } from 'lodash-es';
+
+/**
+ * Widget resize feature plugin.
+ *
+ * Use the {@link module:widget/widgetresize~WidgetResize#attachTo} method to create a resizer for the specified widget.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class WidgetResize extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'WidgetResize';
+	}
+
+	init() {
+		this.resizers = [];
+		this.activeResizer = null;
+
+		const domDocument = global.window.document;
+		const THROTTLE_THRESHOLD = 16; // 16ms = ~60fps
+
+		this.editor.model.schema.setAttributeProperties( 'width', {
+			isFormatting: true
+		} );
+
+		this._observer = Object.create( DomEmitterMixin );
+
+		this._observer.listenTo( domDocument, 'mousedown', ( event, domEventData ) => {
+			if ( !Resizer.isResizeHandle( domEventData.target ) ) {
+				return;
+			}
+
+			const resizeHandle = domEventData.target;
+
+			this.activeResizer = this._getResizerByHandle( resizeHandle );
+
+			if ( this.activeResizer ) {
+				this.activeResizer.begin( resizeHandle );
+			}
+		} );
+
+		this._observer.listenTo( domDocument, 'mousemove', throttle( ( event, domEventData ) => {
+			if ( this.activeResizer ) {
+				this.activeResizer.updateSize( domEventData );
+			}
+		}, THROTTLE_THRESHOLD ) );
+
+		this._observer.listenTo( domDocument, 'mouseup', () => {
+			if ( this.activeResizer ) {
+				this.activeResizer.commit();
+
+				this.activeResizer = null;
+			}
+		} );
+
+		const redrawResizers = throttle( () => {
+			for ( const context of this.resizers ) {
+				context.redraw();
+			}
+		}, THROTTLE_THRESHOLD );
+
+		// Redrawing on any change of the UI of the editor (including content changes).
+		this.editor.ui.on( 'update', redrawResizers );
+
+		// Resizers need to be redrawn upon window resize, because new window might shrink resize host.
+		this._observer.listenTo( global.window, 'resize', redrawResizers );
+	}
+
+	destroy() {
+		this._observer.stopListening();
+	}
+
+	/**
+	 * @param {module:widget/widgetresize~ResizerOptions} [options] Resizer options.
+	 * @returns {module:widget/widgetresize/resizer~Resizer}
+	 */
+	attachTo( options ) {
+		const resizer = new Resizer( options );
+
+		resizer.attach();
+
+		this.editor.editing.view.once( 'render', () => resizer.redraw() );
+
+		this.resizers.push( resizer );
+
+		return resizer;
+	}
+
+	_getResizerByHandle( domResizeHandle ) {
+		for ( const resizer of this.resizers ) {
+			if ( resizer.containsHandle( domResizeHandle ) ) {
+				return resizer;
+			}
+		}
+	}
+}
+
+/**
+ * Interface describing a resizer. It allows to specify resizing host, custom logic for calculating aspect ratio etc.
+ *
+ * @interface ResizerOptions
+ */
+
+/**
+ * @member {module:engine/model/element~Element} module:widget/widgetresize~ResizerOptions#modelElement
+ */
+
+/**
+ * @member {module:engine/view/containerelement~ContainerElement} module:widget/widgetresize~ResizerOptions#viewElement
+ */
+
+/**
+ * @member {module:engine/view/downcastwriter~DowncastWriter} module:widget/widgetresize~ResizerOptions#downcastWriter
+ */
+
+/**
+ * A callback to be executed once resizing process is done.
+ *
+ * It receives a `Number` (`newValue`) as a parameter.
+ *
+ * For example, {@link module:image/imageresize~ImageResize} uses it to execute image resize command,
+ * which puts new value into the model.
+ *
+ * ```js
+ * {
+ *	modelElement: data.item,
+ *	viewElement: widget,
+ *	downcastWriter: conversionApi.writer,
+ *
+ *	onCommit( newValue ) {
+ *		editor.execute( 'imageResize', { width: newValue } );
+ *	}
+ * };
+ * ```
+ *
+ *
+ * @member {Function} module:widget/widgetresize~ResizerOptions#onCommit
+ */
+
+/**
+ * @member {Function} module:widget/widgetresize~ResizerOptions#getResizeHost
+ */
+
+/**
+ * @member {Function} module:widget/widgetresize~ResizerOptions#isCentered
+ */

+ 481 - 0
packages/ckeditor5-widget/src/widgetresize/resizer.js

@@ -0,0 +1,481 @@
+/**
+ * @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 widget/widgetresize/resizer
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import Template from '@ckeditor/ckeditor5-ui/src/template';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+
+import ResizeState from './resizerstate';
+
+/**
+ * Represents a resizer for a single resizable object.
+ *
+ * @mixes module:utils/observablemixin~ObservableMixin
+ */
+export default class Resizer {
+	/**
+	 * @param {module:widget/widgetresize~ResizerOptions} options Resizer options.
+	 */
+	constructor( options ) {
+		/**
+		 * Stores the state of resizable host geometry, such as original width, currently proposed height, etc.
+		 *
+		 * Note that a new state is created for each resize transaction.
+		 *
+		 * @readonly
+		 * @member {module:widget/widgetresize/resizerstate~ResizerState} #state
+		 */
+
+		/**
+		 * A view displaying new proposed element's size during the resizing.
+		 *
+		 * @protected
+		 * @readonly
+		 * @member {module:widget/widgetresize/resizer~SizeView} #_sizeUI
+		 */
+
+		/**
+		 * Options passed to the {@link #constructor}.
+		 *
+		 * @private
+		 * @type {module:widget/widgetresize~ResizerOptions}
+		 */
+		this._options = options;
+
+		/**
+		 * Container of the entire resize UI.
+		 *
+		 * Note that this property is initialized only after the element bound with the resizer is drawn
+		 * so it will be a `null` when uninitialized.
+		 *
+		 * @private
+		 * @type {HTMLElement|null}
+		 */
+		this._domResizerWrapper = null;
+
+		/**
+		 * @observable
+		 */
+		this.set( 'isEnabled', true );
+
+		this.decorate( 'begin' );
+		this.decorate( 'cancel' );
+		this.decorate( 'commit' );
+		this.decorate( 'updateSize' );
+	}
+
+	/**
+	 * Attaches the resizer to the DOM.
+	 */
+	attach() {
+		const that = this;
+		const widgetElement = this._options.viewElement;
+		const writer = this._options.downcastWriter;
+
+		const viewResizerWrapper = writer.createUIElement( 'div', {
+			class: 'ck ck-reset_all ck-widget__resizer'
+		}, function( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+
+			that._appendHandles( domElement );
+			that._appendSizeUI( domElement );
+
+			that._domResizerWrapper = domElement;
+
+			that.on( 'change:isEnabled', ( evt, propName, newValue ) => {
+				domElement.style.display = newValue ? '' : 'none';
+			} );
+
+			domElement.style.display = that.isEnabled ? '' : 'none';
+
+			return domElement;
+		} );
+
+		// Append resizer wrapper to the widget's wrapper.
+		writer.insert( writer.createPositionAt( widgetElement, 'end' ), viewResizerWrapper );
+		writer.addClass( 'ck-widget_with-resizer', widgetElement );
+	}
+
+	/**
+	 * Starts the resizing process.
+	 *
+	 * Creates a new {@link #state} for current process.
+	 *
+	 * @fires begin
+	 * @param {HTMLElement} domResizeHandle Clicked handle.
+	 */
+	begin( domResizeHandle ) {
+		this.state = new ResizeState( this._options );
+
+		this._sizeUI.bindToState( this._options, this.state );
+
+		this.state.begin( domResizeHandle, this._getHandleHost(), this._getResizeHost() );
+
+		this.redraw();
+	}
+
+	/**
+	 * Updates the proposed size based on `domEventData`.
+	 *
+	 * @fires updateSize
+	 * @param {Event} domEventData
+	 */
+	updateSize( domEventData ) {
+		const domHandleHost = this._getHandleHost();
+		const domResizeHost = this._getResizeHost();
+		const unit = this._options.unit;
+		const newSize = this._proposeNewSize( domEventData );
+
+		domResizeHost.style.width = ( unit === '%' ? newSize.widthPercents : newSize.width ) + this._options.unit;
+
+		const domHandleHostRect = new Rect( domHandleHost );
+
+		newSize.handleHostWidth = Math.round( domHandleHostRect.width );
+		newSize.handleHostHeight = Math.round( domHandleHostRect.height );
+
+		const domResizeHostRect = new Rect( domHandleHost );
+
+		newSize.width = Math.round( domResizeHostRect.width );
+		newSize.height = Math.round( domResizeHostRect.height );
+
+		this.state.update( newSize );
+
+		// Refresh values based on the real image. Real image might be limited by max-width, and thus fetching it
+		// here will reflect this limitation.
+		this._domResizerWrapper.style.width = newSize.handleHostWidth + 'px';
+		this._domResizerWrapper.style.height = newSize.handleHostHeight + 'px';
+	}
+
+	/**
+	 * Applies the geometry proposed with the resizer.
+	 *
+	 * @fires commit
+	 */
+	commit() {
+		const unit = this._options.unit;
+		const newValue = ( unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth ) + this._options.unit;
+
+		this._options.onCommit( newValue );
+
+		this._cleanup();
+	}
+
+	/**
+	 * Cancels and rejects proposed resize dimensions hiding all the UI.
+	 *
+	 * @fires cancel
+	 */
+	cancel() {
+		this._cleanup();
+	}
+
+	/**
+	 * Destroys the resizer.
+	 */
+	destroy() {
+		this.cancel();
+	}
+
+	/**
+	 * Redraws the resizer.
+	 */
+	redraw() {
+		// TODO review this
+		const domWrapper = this._domResizerWrapper;
+
+		if ( existsInDom( domWrapper ) ) {
+			// Refresh only if resizer exists in the DOM.
+			const widgetWrapper = domWrapper.parentElement;
+			const handleHost = this._getHandleHost();
+			const clientRect = new Rect( handleHost );
+
+			domWrapper.style.width = clientRect.width + 'px';
+			domWrapper.style.height = clientRect.height + 'px';
+
+			// In case a resizing host is not a widget wrapper, we need to compensate
+			// for any additional offsets the resize host might have. E.g. wrapper padding
+			// or simply another editable. By doing that the border and resizers are shown
+			// only around the resize host.
+			if ( !widgetWrapper.isSameNode( handleHost ) ) {
+				domWrapper.style.left = handleHost.offsetLeft + 'px';
+				domWrapper.style.top = handleHost.offsetTop + 'px';
+
+				domWrapper.style.height = handleHost.offsetHeight + 'px';
+				domWrapper.style.width = handleHost.offsetWidth + 'px';
+			}
+		}
+
+		function existsInDom( element ) {
+			return element && element.ownerDocument && element.ownerDocument.contains( element );
+		}
+	}
+
+	containsHandle( domElement ) {
+		return this._domResizerWrapper.contains( domElement );
+	}
+
+	static isResizeHandle( domElement ) {
+		return domElement.classList.contains( 'ck-widget__resizer__handle' );
+	}
+
+	/**
+	 * Cleans up the context state.
+	 *
+	 * @protected
+	 */
+	_cleanup() {
+		this._sizeUI.dismiss();
+		this._sizeUI.isVisible = false;
+	}
+
+	/**
+	 * Method used to calculate the proposed size as the resize handles are dragged.
+	 *
+	 * @private
+	 * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
+	 * @returns {Object} return
+	 * @returns {Number} return.width Proposed width.
+	 * @returns {Number} return.height Proposed height.
+	 */
+	_proposeNewSize( domEventData ) {
+		const state = this.state;
+		const currentCoordinates = extractCoordinates( domEventData );
+		const isCentered = this._options.isCentered ? this._options.isCentered( this ) : true;
+
+		// Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
+		// meaning that it has been shrunk.
+		//
+		// +----------------+--+
+		// |                |  |
+		// |       img      |  |
+		// |  /handle host  |  |
+		// +----------------+  | ^
+		// |                   | | - enlarge y
+		// +-------------------+ v
+		// 					<-->
+		// 					 enlarge x
+		const enlargement = {
+			x: state._referenceCoordinates.x - ( currentCoordinates.x + state.originalWidth ),
+			y: ( currentCoordinates.y - state.originalHeight ) - state._referenceCoordinates.y
+		};
+
+		if ( isCentered && state.activeHandlePosition.endsWith( '-right' ) ) {
+			enlargement.x = currentCoordinates.x - ( state._referenceCoordinates.x + state.originalWidth );
+		}
+
+		// Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
+		// one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
+		if ( isCentered ) {
+			enlargement.x *= 2;
+		}
+
+		// const resizeHost = this._getResizeHost();
+
+		// The size proposed by the user. It does not consider the aspect ratio.
+		const proposedSize = {
+			width: Math.abs( state.originalWidth + enlargement.x ),
+			height: Math.abs( state.originalHeight + enlargement.y )
+		};
+
+		// Dominant determination must take the ratio into account.
+		proposedSize.dominant = proposedSize.width / state.aspectRatio > proposedSize.height ? 'width' : 'height';
+		proposedSize.max = proposedSize[ proposedSize.dominant ];
+
+		// Proposed size, respecting the aspect ratio.
+		const targetSize = {
+			width: proposedSize.width,
+			height: proposedSize.height
+		};
+
+		if ( proposedSize.dominant == 'width' ) {
+			targetSize.height = targetSize.width / state.aspectRatio;
+		} else {
+			targetSize.width = targetSize.height * state.aspectRatio;
+		}
+
+		return {
+			width: Math.round( targetSize.width ),
+			height: Math.round( targetSize.height ),
+			widthPercents: Math.min( Math.round( state.originalWidthPercents / state.originalWidth * targetSize.width * 100 ) / 100, 100 )
+		};
+	}
+
+	/**
+	 * Method used to obtain the resize host.
+	 *
+	 * Resize host is an object that receives dimensions that are result of resizing.
+	 *
+	 * @protected
+	 * @returns {HTMLElement}
+	 */
+	_getResizeHost() {
+		const widgetWrapper = this._domResizerWrapper.parentElement;
+
+		return this._options.getResizeHost( widgetWrapper );
+	}
+
+	/**
+	 * Method used to obtain the handle host.
+	 *
+	 * Handle host is an object to which the handles are aligned to.
+	 *
+	 * Handle host will not always be an entire widget itself. Take an image as an example. Image widget
+	 * contains an image and a caption. Only image should be surrounded with handles.
+	 *
+	 * @protected
+	 * @returns {HTMLElement}
+	 */
+	_getHandleHost() {
+		const widgetWrapper = this._domResizerWrapper.parentElement;
+
+		return this._options.getHandleHost( widgetWrapper );
+	}
+
+	/**
+	 * Renders the resize handles in DOM.
+	 *
+	 * @private
+	 * @param {HTMLElement} domElement The resizer wrapper.
+	 */
+	_appendHandles( domElement ) {
+		const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
+
+		for ( const currentPosition of resizerPositions ) {
+			domElement.appendChild( ( new Template( {
+				tag: 'div',
+				attributes: {
+					class: `ck-widget__resizer__handle ${ getResizerClass( currentPosition ) }`
+				}
+			} ).render() ) );
+		}
+	}
+
+	/**
+	 * Sets up the {@link #_sizeUI} property and adds it to the passed `domElement`.
+	 *
+	 * @private
+	 * @param {HTMLElement} domElement
+	 */
+	_appendSizeUI( domElement ) {
+		const sizeUI = new SizeView();
+
+		// Make sure icon#element is rendered before passing to appendChild().
+		sizeUI.render();
+
+		this._sizeUI = sizeUI;
+
+		domElement.appendChild( sizeUI.element );
+	}
+
+	/**
+	 * Determines the position of a given resize handle.
+	 *
+	 * @private
+	 * @param {HTMLElement} domHandle Handler used to calculate reference point.
+	 * @returns {String|undefined} Returns a string like `"top-left"` or `undefined` if not matched.
+	 */
+	_getHandlePosition( domHandle ) {
+		const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
+
+		for ( const position of resizerPositions ) {
+			if ( domHandle.classList.contains( getResizerClass( position ) ) ) {
+				return position;
+			}
+		}
+	}
+
+	/**
+	 * @event begin
+	 */
+
+	/**
+	 * @event updateSize
+	 */
+
+	/**
+	 * @event commit
+	 */
+
+	/**
+	 * @event cancel
+	 */
+}
+
+mix( Resizer, ObservableMixin );
+
+/**
+ * A view displaying new proposed element's size during the resizing.
+ *
+ * @extends {module:ui/view~View}
+ */
+class SizeView extends View {
+	constructor() {
+		super();
+
+		const bind = this.bindTemplate;
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-size-view',
+					bind.to( 'activeHandlePosition', value => value ? `ck-orientation-${ value }` : '' )
+				],
+				style: {
+					display: bind.if( 'isVisible', 'none', visible => !visible )
+				}
+			},
+			children: [ {
+				text: bind.to( 'label' )
+			} ]
+		} );
+	}
+
+	bindToState( options, resizerState ) {
+		this.bind( 'isVisible' ).to( resizerState, 'proposedWidth', resizerState, 'proposedHeight', ( width, height ) =>
+			width !== null && height !== null );
+
+		this.bind( 'label' ).to(
+			resizerState, 'proposedHandleHostWidth',
+			resizerState, 'proposedHandleHostHeight',
+			resizerState, 'proposedWidthPercents',
+			( width, height, widthPercents ) => {
+				if ( options.unit === 'px' ) {
+					return `${ width }×${ height }`;
+				} else {
+					return `${ widthPercents }%`;
+				}
+			}
+		);
+
+		this.bind( 'activeHandlePosition' ).to( resizerState );
+	}
+
+	dismiss() {
+		this.unbind();
+		this.isVisible = false;
+	}
+}
+
+// @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
+// @returns {String} A prefixed HTML class name for the resizer element
+function getResizerClass( resizerPosition ) {
+	return `ck-widget__resizer__handle-${ resizerPosition }`;
+}
+
+function extractCoordinates( event ) {
+	return {
+		x: event.pageX,
+		y: event.pageY
+	};
+}

+ 217 - 0
packages/ckeditor5-widget/src/widgetresize/resizerstate.js

@@ -0,0 +1,217 @@
+/**
+ * @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 widget/widgetresize/resizerstate
+ */
+
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+
+/**
+ * Stores the internal state of a single resizable object.
+ *
+ */
+export default class ResizeState {
+	/**
+	 * @param {module:widget/widgetresize~ResizerOptions} options Resizer options.
+	 */
+	constructor( options ) {
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @member {Number} #originalWidth
+		 */
+
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @member {Number} #originalHeight
+		 */
+
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @member {Number} #originalWidthPercents
+		 */
+
+		/**
+		 * Position of the handle that has initiated the resizing. E.g. `"top-left"`, `"bottom-right"` etc or `null`
+		 * if unknown.
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {String|null} #activeHandlePosition
+		 */
+		this.set( 'activeHandlePosition', null );
+
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Number|null} #proposedWidthPercents
+		 */
+		this.set( 'proposedWidthPercents', null );
+
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Number|null} #proposedWidthPixels
+		 */
+		this.set( 'proposedWidth', null );
+
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Number|null} #proposedHeightPixels
+		 */
+		this.set( 'proposedHeight', null );
+
+		this.set( 'proposedHandleHostWidth', null );
+		this.set( 'proposedHandleHostHeight', null );
+
+		/**
+		 * TODO
+		 *
+		 * @readonly
+		 * @member {Number} #aspectRatio
+		 */
+
+		/**
+		 * @private
+		 * @type {module:widget/widgetresize~ResizerOptions}
+		 */
+		this._options = options;
+
+		/**
+		 * Reference point of resizer where the dragging started. It is used to measure the distance to user cursor
+		 * traveled, thus how much the image should be enlarged.
+		 * This information is only known after DOM was rendered, so it will be updated later.
+		 *
+		 * @private
+		 * @type {Object}
+		 */
+		this._referenceCoordinates = null;
+	}
+
+	/**
+	 *
+	 * @param {HTMLElement} domResizeHandle The handle used to calculate the reference point.
+	 * @param {HTMLElement} domHandleHost
+	 * @param {HTMLElement} domResizeHost
+	 */
+	begin( domResizeHandle, domHandleHost, domResizeHost ) {
+		const clientRect = new Rect( domHandleHost );
+
+		this.activeHandlePosition = getHandlePosition( domResizeHandle );
+
+		this._referenceCoordinates = getAbsoluteBoundaryPoint( domHandleHost, getOppositePosition( this.activeHandlePosition ) );
+
+		this.originalWidth = clientRect.width;
+		this.originalHeight = clientRect.height;
+
+		this.aspectRatio = clientRect.width / clientRect.height;
+
+		const widthStyle = domResizeHost.style.width;
+
+		if ( widthStyle ) {
+			if ( widthStyle.match( /^\d+\.?\d*%$/ ) ) {
+				this.originalWidthPercents = parseFloat( widthStyle );
+			} else {
+				// TODO we need to check it via comparing to the parent's width.
+				this.originalWidthPercents = 50;
+			}
+		} else {
+			this.originalWidthPercents = 100;
+		}
+	}
+
+	update( newSize ) {
+		this.proposedWidth = newSize.width;
+		this.proposedHeight = newSize.height;
+		this.proposedWidthPercents = newSize.widthPercents;
+
+		this.proposedHandleHostWidth = newSize.handleHostWidth;
+		this.proposedHandleHostHeight = newSize.handleHostHeight;
+	}
+}
+
+mix( ResizeState, ObservableMixin );
+
+/**
+ * Returns coordinates of top-left corner of a element, relative to the document's top-left corner.
+ *
+ * @private
+ * @param {HTMLElement} element
+ * @param {String} resizerPosition Position of the resize handler, e.g. `"top-left"`, `"bottom-right"`.
+ * @returns {Object} return
+ * @returns {Number} return.x
+ * @returns {Number} return.y
+ */
+function getAbsoluteBoundaryPoint( element, resizerPosition ) {
+	const elementRect = new Rect( element );
+	const positionParts = resizerPosition.split( '-' );
+	const ret = {
+		x: positionParts[ 1 ] == 'right' ? elementRect.right : elementRect.left,
+		y: positionParts[ 0 ] == 'bottom' ? elementRect.bottom : elementRect.top
+	};
+
+	ret.x += element.ownerDocument.defaultView.scrollX;
+	ret.y += element.ownerDocument.defaultView.scrollY;
+
+	return ret;
+}
+
+/**
+ * @private
+ * @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
+ * @returns {String} A prefixed HTML class name for the resizer element
+ */
+function getResizerHandleClass( resizerPosition ) {
+	return `ck-widget__resizer__handle-${ resizerPosition }`;
+}
+
+/**
+ * Determines the position of a given resize handle.
+ *
+ * @private
+ * @param {HTMLElement} domHandle Handler used to calculate reference point.
+ * @returns {String|undefined} Returns a string like `"top-left"` or `undefined` if not matched.
+ */
+function getHandlePosition( domHandle ) {
+	const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];
+
+	for ( const position of resizerPositions ) {
+		if ( domHandle.classList.contains( getResizerHandleClass( position ) ) ) {
+			return position;
+		}
+	}
+}
+
+/**
+ * @param {String} position Like `"top-left"`.
+ * @returns {String} Inverted `position`, e.g. returns `"bottom-right"` if `"top-left"` was given as `position`.
+ */
+function getOppositePosition( position ) {
+	const parts = position.split( '-' );
+	const replacements = {
+		top: 'bottom',
+		bottom: 'top',
+		left: 'right',
+		right: 'left'
+	};
+
+	return `${ replacements[ parts[ 0 ] ] }-${ replacements[ parts[ 1 ] ] }`;
+}

+ 121 - 12
packages/ckeditor5-widget/theme/widget.css

@@ -3,32 +3,141 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+:root {
+	--ck-color-resizer: var(--ck-color-focus-border);
+	--ck-resizer-size: 10px;
+	--ck-resizer-border-width: 1px;
+	--ck-resizer-border-radius: 2px;
+
+	/* Set resizer with 50% offset. */
+	--ck-resizer-offset: calc( ( var(--ck-resizer-size) / -2 ) - 2px);
+
+	--ck-resizer-tooltip-offset: 10px;
+	--ck-color-resizer-tooltip-background: hsl(0, 0%, 15%);
+	--ck-color-resizer-tooltip-text: hsl(0, 0%, 95%);
+}
+
+.ck .ck-widget_with-resizer {
+	/* Make the widget wrapper a relative positioning container for the drag handle. */
+	position: relative;
+}
+
+.ck .ck-widget__resizer {
+	display: none;
+	position: absolute;
+
+	/* The wrapper itself should not interfere with pointer device, only the handles. */
+	pointer-events: none;
+
+	left: 0;
+	top: 0;
+
+	outline: 1px solid var(--ck-color-resizer);
+}
+
+.ck-focused .ck-widget_with-resizer.ck-widget_selected {
+	& > .ck-widget__resizer {
+		display: block;
+	}
+}
+
+.ck .ck-widget__resizer__handle {
+	position: absolute;
+
+	/* Resizers are the only UI elements that should interfere with pointer device. */
+	pointer-events: all;
+
+	width: var(--ck-resizer-size);
+	height: var(--ck-resizer-size);
+	background: var(--ck-color-focus-border);
+	border: var(--ck-resizer-border-width) solid #fff;
+	border-radius: var(--ck-resizer-border-radius);
+
+	&.ck-widget__resizer__handle-top-left {
+		top: var( --ck-resizer-offset );
+		left: var( --ck-resizer-offset );
+		cursor: nwse-resize;
+	}
+
+	&.ck-widget__resizer__handle-top-right {
+		top: var( --ck-resizer-offset );
+		right: var( --ck-resizer-offset );
+		cursor: nesw-resize;
+	}
+
+	&.ck-widget__resizer__handle-bottom-right {
+		bottom: var( --ck-resizer-offset );
+		right: var( --ck-resizer-offset );
+		cursor: nwse-resize;
+	}
+
+	&.ck-widget__resizer__handle-bottom-left {
+		bottom: var( --ck-resizer-offset );
+		left: var( --ck-resizer-offset );
+		cursor: nesw-resize;
+	}
+}
+
 .ck .ck-widget.ck-widget_with-selection-handler {
 	/* Make the widget wrapper a relative positioning container for the drag handler. */
 	position: relative;
 
+	/* Show the selection handler on mouse hover over the widget. */
+	&:hover {
+		& .ck-widget__selection-handler {
+			visibility: visible;
+		}
+	}
+
 	& .ck-widget__selection-handler {
-		visibility: hidden;
 		position: absolute;
 
 		& .ck-icon {
-			/* Make sure the icon in not a subject to fon-size/line-height to avoid
+			/* Make sure the icon in not a subject to font-size/line-height to avoid
 			unnecessary spacing around it. */
 			display: block;
 		}
 	}
 
-	/* Show the selection handler on mouse hover over the widget. */
-	&:hover {
-		& .ck-widget__selection-handler {
-			visibility: visible;
-		}
+	/* Show the selection handler when the widget is selected. */
+	&.ck-widget_selected .ck-widget__selection-handler {
+		visibility: visible;
 	}
+}
 
-	/* Show the selection handler when the widget is selected. */
-	&.ck-widget_selected {
-		& .ck-widget__selection-handler {
-			visibility: visible;
-		}
+.ck .ck-size-view {
+	background: var(--ck-color-resizer-tooltip-background);
+	color: var(--ck-color-resizer-tooltip-text);
+	border: 1px solid var(--ck-color-resizer-tooltip-text);
+	border-radius: var(--ck-resizer-border-radius);
+	font-size: var(--ck-font-size-tiny);
+	display: block;
+	padding: var(--ck-spacing-small);
+
+	&.ck-orientation-top-left,
+	&.ck-orientation-top-right,
+	&.ck-orientation-bottom-right,
+	&.ck-orientation-bottom-left {
+		position: absolute;
+	}
+
+	&.ck-orientation-top-left {
+		top: var( --ck-resizer-tooltip-offset );
+		left: var( --ck-resizer-tooltip-offset );
+	}
+
+	&.ck-orientation-top-right {
+		top: var( --ck-resizer-tooltip-offset );
+		right: var( --ck-resizer-tooltip-offset );
+	}
+
+	&.ck-orientation-bottom-right {
+		bottom: var( --ck-resizer-tooltip-offset );
+		right: var( --ck-resizer-tooltip-offset );
+	}
+
+	&.ck-orientation-bottom-left {
+		bottom: var( --ck-resizer-tooltip-offset );
+		left: var( --ck-resizer-tooltip-offset );
 	}
 }