Bladeren bron

Merge pull request #115 from ckeditor/i/4614

Tests: Extracted relevant parts of widget resize plugin from the image back to widget plugin. Closes ckeditor/ckeditor5#4614.
Piotrek Koszuliński 5 jaren geleden
bovenliggende
commit
4fc8420bd3

+ 44 - 25
packages/ckeditor5-widget/src/widgetresize.js

@@ -70,33 +70,11 @@ export default class WidgetResize extends Plugin {
 
 		this._observer = Object.create( DomEmitterMixin );
 
-		this._observer.listenTo( domDocument, 'mousedown', ( event, domEventData ) => {
-			if ( !Resizer.isResizeHandle( domEventData.target ) ) {
-				return;
-			}
+		this._observer.listenTo( domDocument, 'mousedown', this._mouseDownListener.bind( this ) );
 
-			const resizeHandle = domEventData.target;
+		this._observer.listenTo( domDocument, 'mousemove', this._mouseMoveListener.bind( this ) );
 
-			this._activeResizer = this._getResizerByHandle( resizeHandle );
-
-			if ( this._activeResizer ) {
-				this._activeResizer.begin( resizeHandle );
-			}
-		} );
-
-		this._observer.listenTo( domDocument, 'mousemove', ( event, domEventData ) => {
-			if ( this._activeResizer ) {
-				this._activeResizer.updateSize( domEventData );
-			}
-		} );
-
-		this._observer.listenTo( domDocument, 'mouseup', () => {
-			if ( this._activeResizer ) {
-				this._activeResizer.commit();
-
-				this._activeResizer = null;
-			}
-		} );
+		this._observer.listenTo( domDocument, 'mouseup', this._mouseUpListener.bind( this ) );
 
 		const redrawFocusedResizer = () => {
 			if ( this._visibleResizer ) {
@@ -127,6 +105,10 @@ export default class WidgetResize extends Plugin {
 
 	destroy() {
 		this._observer.stopListening();
+
+		for ( const resizer of this._resizers.values() ) {
+			resizer.destroy();
+		}
 	}
 
 	/**
@@ -187,6 +169,43 @@ export default class WidgetResize extends Plugin {
 	_getResizerByViewElement( viewElement ) {
 		return this._resizers.get( viewElement );
 	}
+
+	/**
+	 * @protected
+	 * @param {module:utils/eventinfo~EventInfo} event
+	 * @param {Event} domEventData Native DOM event.
+	 */
+	_mouseDownListener( event, domEventData ) {
+		if ( !Resizer.isResizeHandle( domEventData.target ) ) {
+			return;
+		}
+		const resizeHandle = domEventData.target;
+		this._activeResizer = this._getResizerByHandle( resizeHandle );
+		if ( this._activeResizer ) {
+			this._activeResizer.begin( resizeHandle );
+		}
+	}
+
+	/**
+	 * @protected
+	 * @param {module:utils/eventinfo~EventInfo} event
+	 * @param {Event} domEventData Native DOM event.
+	 */
+	_mouseMoveListener( event, domEventData ) {
+		if ( this._activeResizer ) {
+			this._activeResizer.updateSize( domEventData );
+		}
+	}
+
+	/**
+	 * @protected
+	 */
+	_mouseUpListener() {
+		if ( this._activeResizer ) {
+			this._activeResizer.commit();
+			this._activeResizer = null;
+		}
+	}
 }
 
 mix( WidgetResize, ObservableMixin );

+ 4 - 21
packages/ckeditor5-widget/src/widgetresize/resizer.js

@@ -83,7 +83,7 @@ export default class Resizer {
 		this.on( 'commit', event => {
 			// State might not be initialized yet. In this case, prevent further handling and make sure that the resizer is
 			// cleaned up (#5195).
-			if ( !this.state.proposedWidth ) {
+			if ( !this.state.proposedWidth && !this.state.proposedWidthPercents ) {
 				this._cleanup();
 				event.stop();
 			}
@@ -153,7 +153,7 @@ export default class Resizer {
 		const editingView = this._options.editor.editing.view;
 
 		editingView.change( writer => {
-			const unit = this._options.unit;
+			const unit = this._options.unit || '%';
 			const newWidth = ( unit === '%' ? newSize.widthPercents : newSize.width ) + unit;
 
 			writer.setStyle( 'width', newWidth, this._options.viewElement );
@@ -185,8 +185,8 @@ export default class Resizer {
 	 * @fires commit
 	 */
 	commit() {
-		const unit = this._options.unit;
-		const newValue = ( unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth ) + this._options.unit;
+		const unit = this._options.unit || '%';
+		const newValue = ( unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth ) + unit;
 
 		this._options.onCommit( newValue );
 
@@ -410,23 +410,6 @@ export default class Resizer {
 		domElement.appendChild( sizeUI.element );
 	}
 
-	/**
-	 * Determines the position of a given resize handle.
-	 *
-	 * @private
-	 * @param {HTMLElement} domHandle Handle used to calculate the 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
 	 */

+ 461 - 0
packages/ckeditor5-widget/tests/widgetresize.js

@@ -0,0 +1,461 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, Event */
+
+import WidgetResize from '../src/widgetresize';
+
+// ClassicTestEditor can't be used, as it doesn't handle the focus, which is needed to test resizer visual cues.
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+
+import { toWidget } from '../src/utils';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { resizerMouseSimulator, focusEditor, getHandleCenterPoint, getWidgetDomParts } from './widgetresize/_utils/utils';
+
+describe( 'WidgetResize', () => {
+	let editor, editorElement, widget, mouseListenerSpies, commitStub;
+
+	beforeEach( async () => {
+		editorElement = createEditorElement();
+		editor = await createEditor( editorElement );
+
+		setModelData( editor.model, '[<widget></widget>]' );
+
+		focusEditor( editor );
+
+		widget = editor.editing.view.document.getRoot().getChild( 0 );
+
+		// It's crucial to have a precisely defined editor size for this test suite.
+		editor.editing.view.change( writer => {
+			const viewEditableRoot = editor.editing.view.document.getRoot();
+			writer.setAttribute( 'style', 'width: 400px; padding: 0px; overflow: hidden', viewEditableRoot );
+		} );
+
+		commitStub = sinon.stub();
+
+		mouseListenerSpies = {
+			down: sinon.spy( WidgetResize.prototype, '_mouseDownListener' ),
+			move: sinon.spy( WidgetResize.prototype, '_mouseMoveListener' ),
+			up: sinon.spy( WidgetResize.prototype, '_mouseUpListener' )
+		};
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		for ( const stub of Object.values( mouseListenerSpies ) ) {
+			stub.restore();
+		}
+
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	describe( 'plugin', () => {
+		it( 'is loaded', () => {
+			expect( editor.plugins.get( WidgetResize ) ).to.be.instanceOf( WidgetResize );
+		} );
+	} );
+
+	describe( 'mouse listeners', () => {
+		beforeEach( () => {
+			createResizer();
+		} );
+
+		it( 'don\'t break when called with unexpected element', () => {
+			const unrelatedElement = document.createElement( 'div' );
+
+			editor.plugins.get( WidgetResize )._mouseDownListener( {}, {
+				target: unrelatedElement
+			} );
+		} );
+
+		it( 'passes new width to the options.onCommit()', () => {
+			const usedResizer = 'top-right';
+			const domParts = getWidgetDomParts( editor, widget, usedResizer );
+			const initialPointerPosition = getHandleCenterPoint( domParts.widget, usedResizer );
+			const finalPointerPosition = initialPointerPosition.clone().moveBy( 20, 0 );
+
+			resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, finalPointerPosition );
+
+			expect( commitStub.callCount ).to.be.equal( 1 );
+			sinon.assert.calledWithExactly( commitStub, '120px' );
+		} );
+
+		it( 'are detached when plugin is destroyed', async () => {
+			await editor.destroy();
+			const plugin = editor.plugins.get( WidgetResize );
+			editor = null;
+
+			const event = new Event( 'mousedown', { bubbles: true } );
+			document.body.dispatchEvent( event );
+
+			// Ensure nothing got called.
+			expect( plugin._mouseDownListener.callCount ).to.be.equal( 0 );
+		} );
+
+		it( 'nothing bad happens if activeResizer got unset', () => {
+			createResizer( {
+				isCentered: () => true
+			} );
+
+			const usedResizer = 'top-right';
+			const domParts = getWidgetDomParts( editor, widget, usedResizer );
+			const initialPointerPosition = getHandleCenterPoint( domParts.widget, usedResizer );
+
+			editor.plugins.get( WidgetResize )._getResizerByHandle = sinon.stub().returns( null );
+
+			resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, initialPointerPosition );
+			// No exception should be thrown.
+		} );
+	} );
+
+	describe( 'visibility', () => {
+		beforeEach( () => {
+			createResizer();
+		} );
+
+		it( 'it\'s hidden when no widget is focused', () => {
+			// This particular test needs a paragraph, so that widget is no longer focused.
+			setModelData( editor.model, '<widget></widget><paragraph>[]</paragraph>' );
+
+			const allResizers = editor.ui.getEditableElement().querySelectorAll( '.ck-widget__resizer__handle' );
+
+			for ( const resizer of allResizers ) {
+				expect( isVisible( resizer ) ).to.be.false;
+			}
+		} );
+
+		it( 'it\'s visible once the widget is focused', () => {
+			// Widget is focused by default.
+			const allResizers = editor.ui.getEditableElement().querySelectorAll( '.ck-widget__resizer__handle' );
+
+			for ( const resizer of allResizers ) {
+				expect( isVisible( resizer ) ).to.be.true;
+			}
+		} );
+
+		function isVisible( element ) {
+			// Checks if the DOM element is visible to the end user.
+			return element.offsetParent !== null && !element.classList.contains( 'ck-hidden' );
+		}
+	} );
+
+	describe( 'integration (pixels)', () => {
+		describe( 'aligned widget', () => {
+			beforeEach( () => {
+				createResizer();
+			} );
+
+			it( 'properly sets the state for subsequent resizes', () => {
+				const usedResizer = 'top-right';
+				const domParts = getWidgetDomParts( editor, widget, usedResizer );
+				const initialPointerPosition = getHandleCenterPoint( domParts.widget, usedResizer );
+
+				const intermediatePointerPosition = initialPointerPosition.clone().moveBy( 50, 0 );
+				resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, intermediatePointerPosition );
+				sinon.assert.calledWithExactly( commitStub.firstCall, '150px' );
+
+				const finalPointerPosition = intermediatePointerPosition.clone().moveBy( 50, 0 );
+				resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, finalPointerPosition );
+				sinon.assert.calledWithExactly( commitStub.secondCall, '200px' );
+
+				expect( commitStub.callCount ).to.be.equal( 2 );
+			} );
+
+			it( 'shrinks correctly with left-bottom handler', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: 20, y: -10 },
+				expectedWidth: '80px'
+			} ) );
+
+			it( 'shrinks correctly with right-bottom handler', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: -20, y: -10 },
+				expectedWidth: '80px'
+			} ) );
+
+			it( 'shrinks correctly with left-top handler', generateResizeTest( {
+				usedHandle: 'top-left',
+				movePointerBy: { x: 20, y: 10 },
+				expectedWidth: '80px'
+			} ) );
+
+			it( 'shrinks correctly with right-top handler', generateResizeTest( {
+				usedHandle: 'top-right',
+				movePointerBy: { x: -20, y: 10 },
+				expectedWidth: '80px'
+			} ) );
+
+			it( 'enlarges correctly with left-bottom handler', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: -10, y: 10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with right-bottom handler', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: 10, y: 10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with right-bottom handler, y axis only', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: 0, y: 20 },
+				expectedWidth: '140px'
+			} ) );
+
+			it( 'enlarges correctly with right-bottom handler, x axis only', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: 40, y: 0 },
+				expectedWidth: '140px'
+			} ) );
+
+			it( 'enlarges correctly with left-top handler', generateResizeTest( {
+				usedHandle: 'top-left',
+				movePointerBy: { x: -20, y: -10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with right-top handler', generateResizeTest( {
+				usedHandle: 'top-right',
+				movePointerBy: { x: 20, y: 10 },
+				expectedWidth: '120px'
+			} ) );
+		} );
+
+		describe( 'centered widget', () => {
+			beforeEach( () => {
+				createResizer( {
+					isCentered: () => true
+				} );
+			} );
+
+			it( 'shrinks correctly with left-bottom handler', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: 10, y: -10 },
+				expectedWidth: '80px'
+			} ) );
+
+			it( 'shrinks correctly with right-bottom handler', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: -10, y: -10 },
+				expectedWidth: '80px'
+			} ) );
+
+			it( 'enlarges correctly with right-bottom handler, x axis only', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: 10, y: 0 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with right-bottom handler, y axis only', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: 0, y: 10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with left-bottom handler, x axis only', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: -10, y: 0 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with left-bottom handler, y axis only', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: 0, y: 10 },
+				expectedWidth: '120px'
+			} ) );
+
+			// --- top handlers ---
+
+			it( 'enlarges correctly with left-top handler', generateResizeTest( {
+				usedHandle: 'top-left',
+				movePointerBy: { x: -10, y: -10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with left-top handler, y axis only', generateResizeTest( {
+				usedHandle: 'top-left',
+				movePointerBy: { x: 0, y: -10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with right-top handler', generateResizeTest( {
+				usedHandle: 'top-right',
+				movePointerBy: { x: 10, y: -10 },
+				expectedWidth: '120px'
+			} ) );
+
+			it( 'enlarges correctly with right-top handler, y axis only', generateResizeTest( {
+				usedHandle: 'top-right',
+				movePointerBy: { x: 0, y: -10 },
+				expectedWidth: '120px'
+			} ) );
+		} );
+	} );
+
+	describe( 'integration (percents)', () => {
+		describe( 'side aligned widget', () => {
+			beforeEach( () => {
+				createResizer( {
+					unit: undefined
+				} );
+			} );
+
+			it( 'properly sets the state for subsequent resizes', () => {
+				const usedResizer = 'top-right';
+				const domParts = getWidgetDomParts( editor, widget, usedResizer );
+				const initialPointerPosition = getHandleCenterPoint( domParts.widget, usedResizer );
+
+				const intermediatePointerPosition = initialPointerPosition.clone().moveBy( 100, 0 );
+				resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, intermediatePointerPosition );
+				sinon.assert.calledWithExactly( commitStub.firstCall, '50%' );
+
+				const finalPointerPosition = intermediatePointerPosition.clone().moveBy( 100, 0 );
+				resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, finalPointerPosition );
+				sinon.assert.calledWithExactly( commitStub.secondCall, '75%' );
+
+				expect( commitStub.callCount ).to.be.equal( 2 );
+			} );
+
+			it( 'shrinks correctly with bottom-left handler', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: 10, y: -10 },
+				expectedWidth: '22.5%'
+			} ) );
+		} );
+
+		describe( 'centered widget', () => {
+			beforeEach( () => {
+				createResizer( {
+					isCentered: () => true,
+					unit: undefined
+				} );
+			} );
+
+			it( 'shrinks correctly with bottom-left handler', generateResizeTest( {
+				usedHandle: 'bottom-left',
+				movePointerBy: { x: 10, y: -10 },
+				expectedWidth: '20%'
+			} ) );
+
+			it( 'enlarges correctly with bottom-right handler', generateResizeTest( {
+				usedHandle: 'bottom-right',
+				movePointerBy: { x: 0, y: 5 },
+				expectedWidth: '27.5%'
+			} ) );
+
+			it( 'enlarges correctly an image with unsupported width unit', () => {
+				editor.editing.view.change( writer => {
+					writer.setStyle( 'width', '100pt', widget );
+				} );
+
+				generateResizeTest( {
+					usedHandle: 'bottom-right',
+					movePointerBy: { x: 0, y: 5 },
+					expectedWidth: '36.67%'
+				} )();
+			} );
+		} );
+	} );
+
+	describe( '_getResizerByHandle()', () => {
+		it( 'returns properly in case of invalid handle element', () => {
+			const randomElement = document.createElement( 'span' );
+			const plugin = editor.plugins.get( WidgetResize );
+
+			createResizer();
+
+			expect( plugin._getResizerByHandle( randomElement ) ).to.be.undefined;
+		} );
+	} );
+
+	/**
+	 * @param {Object} options
+	 * @param {String} options.usedHandle Handle that should be used for resize, e.g. 'bottom-right'.
+	 * @param {Object} options.movePointerBy How much should the pointer move during the drag compared to the initial position.
+	 * @param {String} options.expectedWidth
+	 */
+	function generateResizeTest( options ) {
+		return () => {
+			options = options || {};
+
+			const usedHandle = options.usedHandle;
+			const domParts = getWidgetDomParts( editor, widget, usedHandle );
+			const initialPointerPosition = getHandleCenterPoint( domParts.widget, usedHandle );
+			const pointerDifference = options.movePointerBy;
+			const finalPointerPosition = initialPointerPosition.moveBy( pointerDifference.x, pointerDifference.y );
+
+			resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, finalPointerPosition );
+			expect( commitStub.callCount, 'call count' ).to.be.eql( 1 );
+			expect( commitStub.args[ 0 ][ 0 ], 'width' ).to.be.equal( options.expectedWidth );
+			sinon.assert.calledOnce( commitStub );
+		};
+	}
+
+	function createEditor( element ) {
+		return ClassicEditor
+			.create( element, {
+				plugins: [
+					ArticlePluginSet, WidgetResize, simpleWidgetPlugin
+				]
+			} );
+
+		function simpleWidgetPlugin( editor ) {
+			editor.model.schema.register( 'widget', {
+				inheritAllFrom: '$block',
+				isObject: true
+			} );
+
+			editor.conversion.for( 'downcast' )
+				.elementToElement( {
+					model: 'widget',
+					view: ( modelItem, viewWriter ) => {
+						const div = viewWriter.createContainerElement( 'div' );
+						viewWriter.setStyle( 'height', '50px', div );
+						viewWriter.setStyle( 'width', '25%', div ); // It evaluates to 100px.
+
+						return toWidget( div, viewWriter, {
+							label: 'element label'
+						} );
+					}
+				} );
+		}
+	}
+
+	function createEditorElement() {
+		const element = document.createElement( 'div' );
+		document.body.appendChild( element );
+		return element;
+	}
+
+	function createResizer( resizerOptions ) {
+		const widgetModel = editor.model.document.getRoot().getChild( 0 );
+
+		const defaultOptions = {
+			unit: 'px',
+
+			modelElement: widgetModel,
+			viewElement: widget,
+			editor,
+
+			isCentered: () => false,
+			getHandleHost( domWidgetElement ) {
+				return domWidgetElement;
+			},
+			getResizeHost( domWidgetElement ) {
+				return domWidgetElement;
+			},
+
+			onCommit: commitStub
+		};
+
+		return editor.plugins.get( WidgetResize ).attachTo( Object.assign( defaultOptions, resizerOptions ) );
+	}
+} );

+ 156 - 0
packages/ckeditor5-widget/tests/widgetresize/_utils/utils.js

@@ -0,0 +1,156 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import WidgetResize from '../../../src/widgetresize';
+
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+export const resizerMouseSimulator = {
+	down( editor, domTarget ) {
+		this._getPlugin( editor )._mouseDownListener( {}, {
+			target: domTarget
+		} );
+	},
+
+	/**
+	 * Calls the resizer `mousemove` handler with given parameters.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor
+	 * @param {HTMLElement} domTarget
+	 * @param {Object} [eventData]
+	 * @param {Point} [targetPoint] Place where the pointer should be moved to, overrides `eventData.pageX` and `eventData.pageY`.
+	 */
+	move( editor, domTarget, eventData, targetPoint ) {
+		const combinedEventData = Object.assign( {}, eventData, {
+			target: domTarget
+		} );
+
+		if ( targetPoint ) {
+			combinedEventData.pageX = targetPoint.x;
+			combinedEventData.pageY = targetPoint.y;
+		}
+
+		this._getPlugin( editor )._mouseMoveListener( {}, combinedEventData );
+	},
+	up( editor ) {
+		this._getPlugin( editor )._mouseUpListener();
+	},
+
+	/**
+	 * Emulates mouse drag gesture by triggering:
+	 *
+	 * * the `mousedown` event on the `domTarget`,
+	 * * the `mousemove` event on `domTarget`, with the pointer coordinates at `position.from`,
+	 * * the `mousemove` event on `domTarget`, with the pointer coordinates at `position.to` (fired
+	 *	only if `position.to` differs from `position.from`),
+	 * * the `mouseup` event.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor
+	 * @param {HTMLElement} domTarget
+	 * @param {Object/Point} position If a `Point` instance is given, the drag is performed without acutal pointer move.
+	 * @param {Point} position.from Coordinates of where the drag begins.
+	 * @param {Point} position.to Coordinates of where the drag ends.
+	 */
+	dragTo( editor, domTarget, position ) {
+		const fromPosition = position.from || position;
+		const finalPosition = position.to || position;
+
+		this.down( editor, domTarget );
+
+		this.move( editor, domTarget, {
+			pageX: fromPosition.x,
+			pageY: fromPosition.y
+		} );
+
+		if ( !finalPosition.equals( fromPosition ) ) {
+			this.move( editor, domTarget, {
+				pageX: finalPosition.x,
+				pageY: finalPosition.y
+			} );
+		}
+
+		this.up( editor );
+	},
+
+	_getPlugin( editor ) {
+		return editor.plugins.get( WidgetResize );
+	}
+};
+
+export function getWidgetDomParts( editor, widget, resizerPosition ) {
+	const view = editor.editing.view;
+	const domWidget = view.domConverter.mapViewToDom( widget );
+
+	return {
+		resizeWrapper: domWidget.querySelector( '.ck-widget__resizer' ),
+		resizeHandle: domWidget.querySelector( `.ck-widget__resizer__handle-${ resizerPosition }` ),
+		widget: domWidget
+	};
+}
+
+export class Point {
+	constructor( x, y ) {
+		this.x = x;
+		this.y = y;
+	}
+
+	/**
+	 * Moves the point by a given `changeX` and `changeY`.
+	 *
+	 * @param {Number} changeX
+	 * @param {Number} changeY
+	 * @returns {Point} Returns current instance.
+	 */
+	moveBy( changeX, changeY ) {
+		this.x += changeX;
+		this.y += changeY;
+		return this;
+	}
+
+	/**
+	 * @returns {Point}
+	 */
+	clone() {
+		return new Point( this.x, this.y );
+	}
+
+	/**
+	 * Checks if two points are equal.
+	 *
+	 * @param {Point} pointB
+	 * @returns {Boolean}
+	 */
+	equals( pointB ) {
+		return pointB.x == this.x && pointB.y == this.y;
+	}
+}
+
+/**
+ * Returns a center point for a given handle.
+ *
+ * @param {HTMLElement} domWrapper Wrapper of an element that contains the resizer.
+ * @param {String} [handlePosition='top-left']
+ * @returns {Point}
+ */
+export function getHandleCenterPoint( domWrapper, handlePosition ) {
+	const wrapperRect = new Rect( domWrapper );
+	const returnValue = new Point( wrapperRect.left, wrapperRect.top );
+	const cornerPositionParts = handlePosition.split( '-' );
+
+	if ( cornerPositionParts.includes( 'right' ) ) {
+		returnValue.x = wrapperRect.right;
+	}
+
+	if ( cornerPositionParts.includes( 'bottom' ) ) {
+		returnValue.y = wrapperRect.bottom;
+	}
+
+	return returnValue;
+}
+
+export function focusEditor( editor ) {
+	editor.editing.view.focus();
+	editor.ui.focusTracker.isFocused = true;
+}

+ 152 - 0
packages/ckeditor5-widget/tests/widgetresize/resizer.js

@@ -0,0 +1,152 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import Resizer from '../../src/widgetresize/resizer';
+
+import Element from '@ckeditor/ckeditor5-engine/src/model/element';
+import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+
+describe( 'Resizer', () => {
+	let editor, editorElement;
+
+	before( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.append( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [
+					ArticlePluginSet
+				]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	after( () => {
+		editorElement.remove();
+		return editor.destroy();
+	} );
+
+	it( 'constructs properly', () => {
+		const resizerInstance = createResizer();
+
+		expect( resizerInstance.isEnabled ).to.be.true;
+	} );
+
+	describe( 'markup', () => {
+		let resizerInstance, renderedElement;
+
+		before( () => {
+			resizerInstance = createResizer();
+			resizerInstance.attach();
+
+			renderedElement = resizerInstance._viewResizerWrapper.render( document );
+		} );
+
+		after( () => {
+			renderedElement.remove();
+		} );
+
+		it( 'root element contains proper classes', () => {
+			const rootElementClasses = Array.from( renderedElement.classList );
+
+			expect( rootElementClasses ).to.include( 'ck' );
+			expect( rootElementClasses ).to.include( 'ck-reset_all' );
+			expect( rootElementClasses ).to.include( 'ck-widget__resizer' );
+		} );
+
+		it( 'includes handle for each corner', () => {
+			const handleSelectors = [
+				'.ck-widget__resizer__handle.ck-widget__resizer__handle-bottom-right',
+				'.ck-widget__resizer__handle.ck-widget__resizer__handle-bottom-left',
+				'.ck-widget__resizer__handle.ck-widget__resizer__handle-top-left',
+				'.ck-widget__resizer__handle.ck-widget__resizer__handle-top-right'
+			];
+
+			for ( const selector of handleSelectors ) {
+				expect( renderedElement.querySelectorAll( selector ).length, `Selector "${ selector }" matches` ).to.be.equal( 1 );
+			}
+		} );
+
+		it( 'renders sizeUi', () => {
+			expect( renderedElement.querySelectorAll( '.ck-size-view' ).length ).to.be.equal( 1 );
+		} );
+	} );
+
+	describe( '_proposeNewSize()', () => {
+		let resizer;
+
+		beforeEach( () => {
+			const state = {
+				originalWidth: 40,
+				originalHeight: 40,
+				originalWidthPercents: 10,
+				aspectRatio: 1,
+				_referenceCoordinates: {
+					x: 0,
+					y: 0
+				},
+				activeHandlePosition: 'bottom-right'
+			};
+
+			resizer = createResizer();
+			resizer.state = state;
+		} );
+
+		it( 'enlarges center-aligned objects correctly', () => {
+			// Note that center-aligned objects needs to be enlarged twice as much
+			// as your x-axis mouse distance, since it expands towards both directions.
+			const proposedSize = resizer._proposeNewSize( {
+				pageX: 50,
+				pageY: 50
+			} );
+
+			expect( proposedSize.width, 'width' ).to.be.equal( 60 );
+			expect( proposedSize.height, 'height' ).to.be.equal( 60 );
+			expect( proposedSize.widthPercents, 'widthPercents' ).to.be.equal( 15 );
+		} );
+
+		it( 'enlarges objects correctly', () => {
+			resizer._options.isCentered = () => false;
+
+			const proposedSize = resizer._proposeNewSize( {
+				pageX: 50,
+				pageY: 50
+			} );
+
+			expect( proposedSize.width, 'width' ).to.be.equal( 50 );
+			expect( proposedSize.height, 'height' ).to.be.equal( 50 );
+			expect( proposedSize.widthPercents, 'widthPercents' ).to.be.equal( 12.5 );
+		} );
+
+		it( 'rounds returned width and height properties', () => {
+			const proposedSize = resizer._proposeNewSize( {
+				pageX: 50.000000000002,
+				pageY: 50.000000000002
+			} );
+
+			expect( proposedSize.width, 'width' ).to.be.equal( 60 );
+			expect( proposedSize.height, 'height' ).to.be.equal( 60 );
+		} );
+	} );
+
+	function createResizer() {
+		const model = new Element( 'resizable' );
+		const viewElement = new ContainerElement( 'div' );
+
+		return new Resizer( {
+			modelElement: model,
+			viewElement,
+			editor
+		} );
+	}
+} );

+ 100 - 0
packages/ckeditor5-widget/tests/widgetresize/resizerstate.js

@@ -0,0 +1,100 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ResizerState from '../../src/widgetresize/resizerstate';
+
+describe( 'ResizerState', () => {
+	describe( 'constructor', () => {
+		it( 'sets up proper default values', () => {
+			const state = new ResizerState();
+
+			expect( state.activeHandlePosition, 'activeHandlePosition' ).to.be.null;
+			expect( state.proposedWidthPercents, 'proposedWidthPercents' ).to.be.null;
+			expect( state.proposedWidth, 'proposedWidth' ).to.be.null;
+			expect( state.proposedHeight, 'proposedHeight' ).to.be.null;
+			expect( state.proposedHandleHostWidth, 'proposedHandleHostWidth' ).to.be.null;
+			expect( state.proposedHandleHostHeight, 'proposedHandleHostHeight' ).to.be.null;
+		} );
+
+		it( 'sets up observable properties', () => {
+			const state = new ResizerState();
+
+			expect( isObservable( 'activeHandlePosition' ), 'activeHandlePosition' ).to.be.true;
+			expect( isObservable( 'proposedWidthPercents' ), 'proposedWidthPercents' ).to.be.true;
+			expect( isObservable( 'proposedWidth' ), 'proposedWidth' ).to.be.true;
+			expect( isObservable( 'proposedHeight' ), 'proposedHeight' ).to.be.true;
+			expect( isObservable( 'proposedHandleHostWidth' ), 'proposedHandleHostWidth' ).to.be.true;
+			expect( isObservable( 'proposedHandleHostHeight' ), 'proposedHandleHostHeight' ).to.be.true;
+
+			function isObservable( propertyName ) {
+				const listener = sinon.stub();
+				state.on( `change:${ propertyName }`, listener );
+				state[ propertyName ] = true;
+
+				return listener.calledOnce;
+			}
+		} );
+	} );
+
+	describe( 'begin()', () => {
+		const domContentWrapper = document.createElement( 'div' );
+
+		before( () => {
+			const htmlMockup = `<div class="dom-element" style="width: 25%;">
+				<div class="ck ck-reset_all ck-widget__resizer" style="width: 400px; height: 200px;">
+					<div class="ck-widget__resizer__handle ck-widget__resizer__handle-bottom-right"></div>
+				</div>
+			</div>`;
+
+			domContentWrapper.style.width = '1600px';
+			domContentWrapper.innerHTML = htmlMockup;
+			document.body.append( domContentWrapper );
+		} );
+
+		after( () => {
+			domContentWrapper.remove();
+		} );
+
+		it( 'fetches sizes of real DOM elements', () => {
+			const domResizeHandle = domContentWrapper.querySelector( '.ck-widget__resizer__handle' );
+			const domHandleHost = domContentWrapper.querySelector( '.dom-element' );
+			const domResizeHost = domHandleHost;
+
+			const state = new ResizerState();
+			state.begin( domResizeHandle, domHandleHost, domResizeHost );
+
+			expect( state.activeHandlePosition, 'activeHandlePosition' ).to.be.equal( 'bottom-right' );
+
+			expect( state.originalWidth, 'originalWidth' ).to.be.equal( 400 );
+			expect( state.originalHeight, 'originalHeight' ).to.be.equal( 200 );
+
+			expect( state.aspectRatio, 'aspectRatio' ).to.be.equal( 2 );
+
+			expect( state.originalWidthPercents, 'originalWidthPercents' ).to.be.equal( 25 );
+		} );
+	} );
+
+	describe( 'update()', () => {
+		it( 'changes the properties', () => {
+			const state = new ResizerState();
+
+			state.update( {
+				width: 100,
+				height: 200,
+				widthPercents: 25,
+				handleHostWidth: 80,
+				handleHostHeight: 160,
+			} );
+
+			expect( state.proposedWidthPercents, 'proposedWidthPercents' ).to.be.equal( 25 );
+			expect( state.proposedWidth, 'proposedWidth' ).to.be.equal( 100 );
+			expect( state.proposedHeight, 'proposedHeight' ).to.be.equal( 200 );
+			expect( state.proposedHandleHostWidth, 'proposedHandleHostWidth' ).to.be.equal( 80 );
+			expect( state.proposedHandleHostHeight, 'proposedHandleHostHeight' ).to.be.equal( 160 );
+		} );
+	} );
+} );