浏览代码

Merge pull request #6794 from ckeditor/i/407-simple

Feature (widget): Brought the feature allowing users to type in tight spots around block widgets where web browsers do not allow the caret to be placed (see #407). Closes #6740. Closes #6688. Closes #6689. Closes #6695.

Internal (horizontal-line): Updated the styling of .ck-horizontal-line from overflow: hidden to display: flow-root to allow the typing around UI inside the widgets (see #407, #6795).

Tests (image): Updated various image test to take the widget typing around feature into account (see #407).

Fix (media-mebed): The media widget conversion should not discard widget internals (drag or resize handlers, buttons to insert paragraphs, etc.) injected by other features when converting the URL (see #407).

Feature (theme-lark): Brought styles for the feature allowing users to type in tight spots around block widgets (see #407).

MINOR BREAKING CHANGE: The new --ck-color-focus-border-coordinates CSS custom property has been added and the existing --ck-color-focus-border property now uses it internally. If your integration overrides the latter, we recommend you update the former to avoid compatibility issues with various editor UI features.
Piotrek Koszuliński 5 年之前
父节点
当前提交
dbf24a29ac
共有 24 个文件被更改,包括 1513 次插入104 次删除
  1. 1 1
      packages/ckeditor5-horizontal-line/theme/horizontalline.css
  2. 41 11
      packages/ckeditor5-image/tests/image.js
  3. 1 1
      packages/ckeditor5-image/tests/imageresize.js
  4. 4 2
      packages/ckeditor5-media-embed/src/converters.js
  5. 7 2
      packages/ckeditor5-media-embed/src/mediaregistry.js
  6. 42 0
      packages/ckeditor5-media-embed/tests/mediaembedediting.js
  7. 2 1
      packages/ckeditor5-theme-lark/theme/ckeditor5-ui/globals/_colors.css
  8. 146 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css
  9. 3 1
      packages/ckeditor5-widget/lang/contexts.json
  10. 2 0
      packages/ckeditor5-widget/package.json
  11. 8 0
      packages/ckeditor5-widget/src/widget.js
  12. 95 0
      packages/ckeditor5-widget/src/widgettypearound/utils.js
  13. 272 0
      packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js
  14. 二进制
      packages/ckeditor5-widget/tests/manual/sample.jpg
  15. 97 0
      packages/ckeditor5-widget/tests/manual/type-around.html
  16. 132 0
      packages/ckeditor5-widget/tests/manual/type-around.js
  17. 22 0
      packages/ckeditor5-widget/tests/manual/type-around.md
  18. 21 11
      packages/ckeditor5-widget/tests/widget-integration.js
  19. 179 73
      packages/ckeditor5-widget/tests/widget.js
  20. 345 0
      packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js
  21. 1 0
      packages/ckeditor5-widget/theme/icons/return-arrow.svg
  22. 5 0
      packages/ckeditor5-widget/theme/widget.css
  23. 77 0
      packages/ckeditor5-widget/theme/widgettypearound.css
  24. 10 1
      scripts/clean-up-svg-icons.sh

+ 1 - 1
packages/ckeditor5-horizontal-line/theme/horizontalline.css

@@ -6,7 +6,7 @@
 
 .ck-editor__editable .ck-horizontal-line {
 	/* Necessary to render properly next to floated objects, e.g. side image case. */
-	overflow: hidden;
+	display: flow-root;
 }
 
 .ck-content hr {

+ 41 - 11
packages/ckeditor5-image/tests/image.js

@@ -62,8 +62,13 @@ describe( 'Image', () => {
 			setModelData( model, '[<image alt="alt text" src="/assets/sample.png"></image>]' );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
 					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]'
 			);
 
@@ -75,8 +80,13 @@ describe( 'Image', () => {
 			setModelData( model, '[<image src="/assets/sample.png" alt=""></image>]' );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
-				'<img alt="" src="/assets/sample.png"></img>' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
+					'<img alt="" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]'
 			);
 
@@ -91,11 +101,21 @@ describe( 'Image', () => {
 			);
 
 			expect( getViewData( view ) ).to.equal(
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]' +
-				'<figure class="ck-widget image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>'
 			);
 
@@ -105,11 +125,21 @@ describe( 'Image', () => {
 			} );
 
 			expect( getViewData( view ) ).to.equal(
-				'<figure class="ck-widget image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>' +
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]'
 			);
 		} );

+ 1 - 1
packages/ckeditor5-image/tests/imageresize.js

@@ -221,7 +221,7 @@ describe( 'ImageResize', () => {
 			const resizerPosition = 'bottom-left';
 			const domParts = getWidgetDomParts( editor, widget, resizerPosition );
 			const initialPointerPosition = getHandleCenterPoint( domParts.widget, resizerPosition );
-			const resizeWrapperView = widget.getChild( 1 );
+			const resizeWrapperView = widget.getChild( 2 );
 
 			resizerMouseSimulator.down( editor, domParts.resizeHandle );
 

+ 4 - 2
packages/ckeditor5-media-embed/src/converters.js

@@ -46,9 +46,11 @@ export function modelToViewUrlAttributeConverter( registry, options ) {
 		const url = data.attributeNewValue;
 		const viewWriter = conversionApi.writer;
 		const figure = conversionApi.mapper.toViewElement( data.item );
+		const mediaContentElement = [ ...figure.getChildren() ]
+			.find( child => child.getCustomProperty( 'media-content' ) );
 
-		// TODO: removing it and creating it from scratch is a hack. We can do better than that.
-		viewWriter.remove( viewWriter.createRangeIn( figure ) );
+		// TODO: removing the wrapper and creating it from scratch is a hack. We can do better than that.
+		viewWriter.remove( mediaContentElement );
 
 		const mediaViewElement = registry.getMediaViewElement( viewWriter, url, options );
 

+ 7 - 2
packages/ckeditor5-media-embed/src/mediaregistry.js

@@ -221,6 +221,7 @@ class Media {
 	 */
 	getViewElement( writer, options ) {
 		const attributes = {};
+		let viewElement;
 
 		if ( options.renderForEditingView || ( options.renderMediaPreview && this.url && this._previewRenderer ) ) {
 			if ( this.url ) {
@@ -233,7 +234,7 @@ class Media {
 
 			const mediaHtml = this._getPreviewHtml( options );
 
-			return writer.createUIElement( 'div', attributes, function( domDocument ) {
+			viewElement = writer.createUIElement( 'div', attributes, function( domDocument ) {
 				const domElement = this.toDomElement( domDocument );
 
 				domElement.innerHTML = mediaHtml;
@@ -245,8 +246,12 @@ class Media {
 				attributes.url = this.url;
 			}
 
-			return writer.createEmptyElement( 'oembed', attributes );
+			viewElement = writer.createEmptyElement( 'oembed', attributes );
 		}
+
+		writer.setCustomProperty( 'media-content', true, viewElement );
+
+		return viewElement;
 	}
 
 	/**

+ 42 - 0
packages/ckeditor5-media-embed/tests/mediaembedediting.js

@@ -896,6 +896,48 @@ describe( 'MediaEmbedEditing', () => {
 							'</figure>'
 						);
 					} );
+
+					// Related to https://github.com/ckeditor/ckeditor5/issues/407.
+					it( 'should not discard internals (e.g. UI) injected by other features when converting the url attribute', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+						const media = doc.getRoot().getChild( 0 );
+
+						editor.editing.view.change( writer => {
+							const widgetViewElement = editor.editing.mapper.toViewElement( media );
+
+							const externalUIElement = writer.createUIElement( 'div', null, function( domDocument ) {
+								const domElement = this.toDomElement( domDocument );
+
+								domElement.innerHTML = 'external UI';
+
+								return domElement;
+							} );
+
+							writer.insert( writer.createPositionAt( widgetViewElement, 'end' ), externalUIElement );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
+									'allow-everything, id=https://ckeditor.com' +
+								'</div>' +
+								'<div>external UI</div>' +
+							'</figure>'
+						);
+
+						model.change( writer => {
+							writer.setAttribute( 'url', 'https://cksource.com', media );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://cksource.com">' +
+									'allow-everything, id=https://cksource.com' +
+								'</div>' +
+								'<div>external UI</div>' +
+							'</figure>'
+						);
+					} );
 				} );
 			}
 		} );

+ 2 - 1
packages/ckeditor5-theme-lark/theme/ckeditor5-ui/globals/_colors.css

@@ -16,7 +16,8 @@
 
 	/* -- Generic colors ------------------------------------------------------------------------ */
 
-	--ck-color-focus-border: 									hsl(208, 79%, 51%);
+	--ck-color-focus-border-coordinates: 						208, 79%, 51%;
+	--ck-color-focus-border: 									hsl(var(--ck-color-focus-border-coordinates));
 	--ck-color-focus-outer-shadow:								hsl(207, 89%, 86%);
 	--ck-color-focus-disabled-shadow:							hsla(209, 90%, 72%,.3);
 	--ck-color-focus-error-shadow:								hsla(9,100%,56%,.3);

+ 146 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css

@@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+:root {
+	--ck-widget-type-around-button-size: 20px;
+	--ck-color-widget-type-around-button-active: var(--ck-color-focus-border);
+	--ck-color-widget-type-around-button-hover: var(--ck-color-widget-hover-border);
+	--ck-color-widget-type-around-button-blurred-editable: var(--ck-color-widget-blurred-border);
+	--ck-color-widget-type-around-button-radar-start-alpha: 0;
+	--ck-color-widget-type-around-button-radar-end-alpha: .3;
+	--ck-color-widget-type-around-button-icon: var(--ck-color-base-background);
+}
+
+.ck .ck-widget {
+	/*
+	 * Styles of the type around buttons
+	 */
+	& .ck-widget__type-around__button {
+		cursor: pointer;
+		width: var(--ck-widget-type-around-button-size);
+		height: var(--ck-widget-type-around-button-size);
+		background: var(--ck-color-widget-type-around-button);
+		border-radius: 100px;
+		animation: fadein linear 300ms 1 normal forwards;
+		transition: opacity 100ms linear;
+
+		& svg {
+			width: 10px;
+			height: 8px;
+			transform: translate(-50%,-50%);
+			transition: transform .5s ease;
+			margin-top: 1px;
+
+			& * {
+				stroke-dasharray: 10;
+				stroke-dashoffset: 0;
+
+				fill: none;
+				stroke: var(--ck-color-widget-type-around-button-icon);
+				stroke-width: 1.5px;
+				stroke-linecap: round;
+				stroke-linejoin: round;
+			}
+
+			& line {
+				stroke-dasharray: 7;
+			}
+		}
+
+		&:hover {
+			/*
+			 * Display the "sonar" around the button when hovered.
+			 */
+			animation: ck-widget-type-around-button-sonar 1s ease infinite;
+
+			/*
+			 * Animate active button's icon.
+			 */
+			& svg {
+				& polyline {
+					animation: ck-widget-type-around-arrow-dash 2s linear;
+				}
+
+				& line {
+					animation: ck-widget-type-around-arrow-tip-dash 2s linear;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Styles for the buttons when the widget is NOT selected (but the buttons are visible
+	 * and still can be hovered).
+	 */
+	&:not(.ck-widget_selected) > .ck-widget__type-around > .ck-widget__type-around__button {
+		background: var(--ck-color-widget-type-around-button-hover);
+	}
+
+	/*
+	 * Styles for the buttons when:
+	 * - the widget is selected,
+	 * - or the button is being hovered (regardless of the widget state).
+	 */
+	&.ck-widget_selected > .ck-widget__type-around > .ck-widget__type-around__button,
+	& > .ck-widget__type-around > .ck-widget__type-around__button:hover {
+		background: var(--ck-color-widget-type-around-button-active);
+
+		&::after {
+			width: calc(var(--ck-widget-type-around-button-size) - 2px);
+			height: calc(var(--ck-widget-type-around-button-size) - 2px);
+			border-radius: 100px;
+			background: linear-gradient(135deg, hsla(0,0%,100%,0) 0%, hsla(0,0%,100%,.3) 100%);
+		}
+	}
+
+	/*
+	 * Styles for the "before" button when the widget has a selection handle. Because some space
+	 * is consumed by the handle, the button must be moved slightly to the right to let it breathe.
+	 */
+	&.ck-widget_with-selection-handle > .ck-widget__type-around > .ck-widget__type-around__button_before {
+		margin-left: 20px;
+	}
+}
+
+/*
+ * Styles for the buttons when the widget is selected but the user clicked outside of the editor (blurred the editor).
+ */
+.ck-editor__editable.ck-blurred .ck-widget.ck-widget_selected > .ck-widget__type-around > .ck-widget__type-around__button:not(:hover) {
+	background: var(--ck-color-widget-type-around-button-blurred-editable);
+
+	& svg * {
+		stroke: hsl(0,0%,60%);
+	}
+}
+
+@keyframes ck-widget-type-around-arrow-dash {
+	0% {
+		stroke-dashoffset: 10;
+	}
+	20%, 100% {
+		stroke-dashoffset: 0;
+	}
+}
+
+@keyframes ck-widget-type-around-arrow-tip-dash {
+	0%, 20% {
+		stroke-dashoffset: 7;
+	}
+	40%, 100% {
+		stroke-dashoffset: 0;
+	}
+}
+
+@keyframes ck-widget-type-around-button-sonar {
+	0% {
+		box-shadow: 0 0 0 0 hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-start-alpha));
+	}
+	50% {
+		box-shadow: 0 0 0 5px hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-end-alpha));
+	}
+	100% {
+		box-shadow: 0 0 0 5px hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-start-alpha));
+	}
+}

+ 3 - 1
packages/ckeditor5-widget/lang/contexts.json

@@ -1,3 +1,5 @@
 {
-	"Widget toolbar": "The label used by assistive technologies describing a toolbar attached to a widget."
+	"Widget toolbar": "The label used by assistive technologies describing a toolbar attached to a widget.",
+	"Insert paragraph before block": "The title displayed when a mouse is over a button that inserts a paragraph before a block.",
+	"Insert paragraph after block": "The title displayed when a mouse is over a button that inserts a paragraph after a block."
 }

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

@@ -24,6 +24,8 @@
     "@ckeditor/ckeditor5-enter": "^19.0.0",
     "@ckeditor/ckeditor5-essentials": "^19.0.0",
     "@ckeditor/ckeditor5-heading": "^19.0.0",
+    "@ckeditor/ckeditor5-horizontal-line": "^19.0.0",
+    "@ckeditor/ckeditor5-media-embed": "^19.0.0",
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
     "@ckeditor/ckeditor5-table": "^19.0.0",
     "@ckeditor/ckeditor5-typing": "^19.0.0",

+ 8 - 0
packages/ckeditor5-widget/src/widget.js

@@ -9,6 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
+import WidgetTypeAround from './widgettypearound/widgettypearound';
 import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import env from '@ckeditor/ckeditor5-utils/src/env';
@@ -41,6 +42,13 @@ export default class Widget extends Plugin {
 	/**
 	 * @inheritDoc
 	 */
+	static get requires() {
+		return [ WidgetTypeAround ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
 	init() {
 		const view = this.editor.editing.view;
 		const viewDocument = view.document;

+ 95 - 0
packages/ckeditor5-widget/src/widgettypearound/utils.js

@@ -0,0 +1,95 @@
+/**
+ * @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 widget/widgettypearound/utils
+ */
+
+import { isWidget } from '../utils';
+
+/**
+ * Checks if an element is a widget that qualifies to get the type around UI.
+ *
+ * @param {module:engine/view/element~Element} viewElement
+ * @param {module:engine/model/element~Element} modelElement
+ * @param {module:engine/model/schema~Schema} schema
+ * @returns {Boolean}
+ */
+export function isTypeAroundWidget( viewElement, modelElement, schema ) {
+	return viewElement && isWidget( viewElement ) && !schema.isInline( modelElement );
+}
+
+/**
+ * For the passed HTML element, this helper finds the closest type around button ancestor.
+ *
+ * @param {HTMLElement} domElement
+ * @returns {HTMLElement|null}
+ */
+export function getClosestTypeAroundDomButton( domElement ) {
+	return domElement.closest( '.ck-widget__type-around__button' );
+}
+
+/**
+ * For the passed type around button element, this helper determines at which position
+ * the paragraph would be inserted into the content if, for instance, the button was
+ * clicked by the user.
+ *
+ * @param {HTMLElement} domElement
+ * @returns {String} Either `'before'` or `'after'`.
+ */
+export function getTypeAroundButtonPosition( domElement ) {
+	return domElement.classList.contains( 'ck-widget__type-around__button_before' ) ? 'before' : 'after';
+}
+
+/**
+ * For the passed HTML element, this helper returns the closest view widget ancestor.
+ *
+ * @param {HTMLElement} domElement
+ * @param {module:engine/view/domconverter~DomConverter} domConverter
+ * @returns {module:engine/view/element~Element}
+ */
+export function getClosestWidgetViewElement( domElement, domConverter ) {
+	const widgetDomElement = domElement.closest( '.ck-widget' );
+
+	return domConverter.mapDomToView( widgetDomElement );
+}
+
+/**
+ * For the passed widget view element, this helper returns an array of positions which
+ * correspond to the "tight spots" around the widget which cannot be accessed due to
+ * limitations of selection rendering in web browsers.
+ *
+ * @param {module:engine/view/element~Element} widgetViewElement
+ * @returns {Array.<String>}
+ */
+export function getWidgetTypeAroundPositions( widgetViewElement ) {
+	const positions = [];
+
+	if ( isFirstChild( widgetViewElement ) || hasPreviousWidgetSibling( widgetViewElement ) ) {
+		positions.push( 'before' );
+	}
+
+	if ( isLastChild( widgetViewElement ) || hasNextWidgetSibling( widgetViewElement ) ) {
+		positions.push( 'after' );
+	}
+
+	return positions;
+}
+
+function isFirstChild( widget ) {
+	return !widget.previousSibling;
+}
+
+function isLastChild( widget ) {
+	return !widget.nextSibling;
+}
+
+function hasPreviousWidgetSibling( widget ) {
+	return widget.previousSibling && isWidget( widget.previousSibling );
+}
+
+function hasNextWidgetSibling( widget ) {
+	return widget.nextSibling && isWidget( widget.nextSibling );
+}

+ 272 - 0
packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js

@@ -0,0 +1,272 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global DOMParser */
+
+/**
+ * @module widget/widgettypearound
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Template from '@ckeditor/ckeditor5-ui/src/template';
+
+import {
+	isTypeAroundWidget,
+	getWidgetTypeAroundPositions,
+	getClosestTypeAroundDomButton,
+	getTypeAroundButtonPosition,
+	getClosestWidgetViewElement
+} from './utils';
+
+import returnIcon from '../../theme/icons/return-arrow.svg';
+import '../../theme/widgettypearound.css';
+
+const POSSIBLE_INSERTION_POSITIONS = [ 'before', 'after' ];
+
+// Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
+const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString( returnIcon, 'image/svg+xml' ).firstChild;
+
+/**
+ * A plugin that allows users to type around widgets where normally it is impossible to place the caret due
+ * to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
+ * the first (or last) child of its parent or between two block widgets.
+ *
+ * This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects a user interface
+ * with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
+ * user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
+ * in it so that users can type (or insert content, paste, etc.) straight away.
+ *
+ * @extends module:core/plugin~Plugin
+ * @private
+ */
+export default class WidgetTypeAround extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Paragraph ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'WidgetTypeAround';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A set containing all widgets in all editor roots that have the type around UI injected in
+		 * {@link #_enableTypeAroundUIInjection}.
+		 *
+		 * Keeping track of them saves time, for instance, when updating their CSS classes.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {Set} #_widgetsWithTypeAroundUI
+		 */
+		this._widgetsWithTypeAroundUI = new Set();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this._widgetsWithTypeAroundUI.clear();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this._enableTypeAroundUIInjection();
+		this._enableDetectionOfTypeAroundWidgets();
+		this._enableInsertingParagraphsOnButtonClick();
+	}
+
+	/**
+	 * Inserts a new paragraph next to a widget element with the selection anchored in it.
+	 *
+	 * **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
+	 * the viewport to the selection in the inserted paragraph.
+	 *
+	 * @protected
+	 * @param {module:engine/view/element~Element} widgetViewElement The view widget element next to which a paragraph is inserted.
+	 * @param {'before'|'after'} position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
+	 */
+	_insertParagraph( widgetViewElement, position ) {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+		let viewPosition;
+
+		if ( position === 'before' ) {
+			viewPosition = editingView.createPositionBefore( widgetViewElement );
+		} else {
+			viewPosition = editingView.createPositionAfter( widgetViewElement );
+		}
+
+		const modelPosition = editor.editing.mapper.toModelPosition( viewPosition );
+
+		editor.model.change( writer => {
+			const paragraph = writer.createElement( 'paragraph' );
+
+			writer.insert( paragraph, modelPosition );
+			writer.setSelection( paragraph, 0 );
+		} );
+
+		editingView.focus();
+		editingView.scrollToTheSelection();
+	}
+
+	/**
+	 * Creates a listener in the editing conversion pipeline that injects the type around
+	 * UI into every single widget instance created in the editor.
+	 *
+	 * The UI is delivered as a {@link module:engine/view/uielement~UIElement}
+	 * wrapper which renders DOM buttons that users can use to insert paragraphs.
+	 *
+	 * @private
+	 */
+	_enableTypeAroundUIInjection() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const t = editor.locale.t;
+		const buttonTitles = {
+			before: t( 'Insert paragraph before block' ),
+			after: t( 'Insert paragraph after block' )
+		};
+
+		editor.editing.downcastDispatcher.on( 'insert', ( evt, data, conversionApi ) => {
+			const viewElement = conversionApi.mapper.toViewElement( data.item );
+
+			// Filter out non-widgets and inline widgets.
+			if ( isTypeAroundWidget( viewElement, data.item, schema ) ) {
+				injectUIIntoWidget( conversionApi.writer, buttonTitles, viewElement );
+
+				// Keep track of widgets that have the type around UI injected.
+				// In the #_enableDetectionOfTypeAroundWidgets() we will iterate only over these
+				// widgets instead of all children of the root. This should improve the performance.
+				this._widgetsWithTypeAroundUI.add( viewElement );
+			}
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * Registers an editing view post-fixer which checks all block widgets in the content
+	 * and adds CSS classes to these which should have the typing around (UI) enabled
+	 * and visible for the users.
+	 *
+	 * @private
+	 */
+	_enableDetectionOfTypeAroundWidgets() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		function positionToWidgetCssClass( position ) {
+			return `ck-widget_can-type-around_${ position }`;
+		}
+
+		editingView.document.registerPostFixer( writer => {
+			for ( const widgetViewElement of this._widgetsWithTypeAroundUI ) {
+				// If the widget is no longer attached to the root (for instance, because it was removed),
+				// there is no need to update its classes and we can safely forget about it.
+				if ( !widgetViewElement.isAttached() ) {
+					this._widgetsWithTypeAroundUI.delete( widgetViewElement );
+				} else {
+					// Update widgets' classes depending on possible positions for paragraph insertion.
+					const positions = getWidgetTypeAroundPositions( widgetViewElement );
+
+					// Remove all classes. In theory we could remove only these that will not be added a few lines later,
+					// but since there are only two... KISS.
+					writer.removeClass( POSSIBLE_INSERTION_POSITIONS.map( positionToWidgetCssClass ), widgetViewElement );
+
+					// Set CSS classes related to possible positions. They are used so the UI knows which buttons to display.
+					writer.addClass( positions.map( positionToWidgetCssClass ), widgetViewElement );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Registers a `mousedown` listener for the view document which intercepts events
+	 * coming from the type around UI, which happens when a user clicks one of the buttons
+	 * that insert a paragraph next to a widget.
+	 *
+	 * @private
+	 */
+	_enableInsertingParagraphsOnButtonClick() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		editingView.document.on( 'mousedown', ( evt, domEventData ) => {
+			const button = getClosestTypeAroundDomButton( domEventData.domTarget );
+
+			if ( !button ) {
+				return;
+			}
+
+			const buttonPosition = getTypeAroundButtonPosition( button );
+			const widgetViewElement = getClosestWidgetViewElement( button, editingView.domConverter );
+
+			this._insertParagraph( widgetViewElement, buttonPosition );
+
+			domEventData.preventDefault();
+			evt.stop();
+		} );
+	}
+}
+
+// Injects the type around UI into a view widget instance.
+//
+// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
+// @param {Object.<String,String>} buttonTitles
+// @param {module:engine/view/element~Element} widgetViewElement
+function injectUIIntoWidget( viewWriter, buttonTitles, widgetViewElement ) {
+	const typeAroundWrapper = viewWriter.createUIElement( 'div', {
+		class: 'ck ck-reset_all ck-widget__type-around'
+	}, function( domDocument ) {
+		const wrapperDomElement = this.toDomElement( domDocument );
+
+		injectButtons( wrapperDomElement, buttonTitles );
+
+		return wrapperDomElement;
+	} );
+
+	// Inject the type around wrapper into the widget's wrapper.
+	viewWriter.insert( viewWriter.createPositionAt( widgetViewElement, 'end' ), typeAroundWrapper );
+}
+
+// FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
+// and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
+// <svg> here.
+//
+// @param {HTMLElement} wrapperDomElement
+// @param {Object.<String,String>} buttonTitles
+function injectButtons( wrapperDomElement, buttonTitles ) {
+	for ( const position of POSSIBLE_INSERTION_POSITIONS ) {
+		const buttonTemplate = new Template( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-widget__type-around__button',
+					`ck-widget__type-around__button_${ position }`
+				],
+				title: buttonTitles[ position ]
+			},
+			children: [
+				wrapperDomElement.ownerDocument.importNode( RETURN_ARROW_ICON_ELEMENT, true )
+			]
+		} );
+
+		wrapperDomElement.appendChild( buttonTemplate.render() );
+	}
+}

二进制
packages/ckeditor5-widget/tests/manual/sample.jpg


+ 97 - 0
packages/ckeditor5-widget/tests/manual/type-around.html

@@ -0,0 +1,97 @@
+<head>
+	<style>
+		body {
+			max-width: 800px;
+			margin: 20px auto;
+		}
+
+		/* stylelint-disable-next-line */
+		placeholder {
+			display: inline-block;
+			background: hsla(100,100%,50%);
+			padding: 4px 2px;
+			outline-offset: -2px;
+			margin-right: 1px;
+			margin-left: 3px;
+		}
+
+		/* stylelint-disable-next-line */
+		placeholder::selection {
+			display: none;
+		}
+	</style>
+</head>
+<div id="editor">
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+	<h2>Heading 1</h2>
+	<hr>
+	<hr>
+	<p><placeholder></placeholder> some text <placeholder></placeholder></p>
+	<figure class="table">
+		<table>
+			<tbody>
+				<tr>
+					<td>&nbsp;</td>
+					<td>&nbsp;</td>
+					<td>
+						<figure class="media">
+							<div data-oembed-url="https://www.youtube.com/watch?v=ZVv7UMQPEWk"><div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;"><iframe src="https://www.youtube.com/embed/ZVv7UMQPEWk" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen=""></iframe></div></div>
+						</figure>
+					</td>
+				</tr>
+				<tr>
+					<td>&nbsp;</td>
+					<td>
+						<p>&nbsp;</p>
+						<figure class="image"><img src="sample.jpg" alt="bar">
+							<figcaption>Caption</figcaption>
+						</figure>
+					</td>
+					<td>
+						<figure class="image"><img src="sample.jpg" alt="bar">
+							<figcaption>Caption</figcaption>
+						</figure>
+					</td>
+				</tr>
+				<tr>
+					<td>&nbsp;</td>
+					<td>e&nbsp;</td>
+					<td>
+						<figure class="image"><img src="sample.jpg" alt="bar">
+							<figcaption>Caption</figcaption>
+						</figure>
+					</td>
+				</tr>
+			</tbody>
+		</table>
+	</figure>
+	<hr>
+	<p><strong>Bold</strong> <i>Italic</i> <a href="https://ckeditor.com">Link</a></p>
+	<ul>
+		<li>UL List <placeholder></placeholder> item 1</li>
+		<li>UL List item 2</li>
+	</ul>
+	<ol>
+		<li>OL List item 1</li>
+		<li>OL List item 2</li>
+	</ol>
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+	<blockquote>
+		<p>Quote</p>
+		<ul>
+			<li>Quoted UL List item 1</li>
+			<li>Quoted UL List item 2</li>
+		</ul>
+		<p>Quote</p>
+	</blockquote>
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+</div>

+ 132 - 0
packages/ckeditor5-widget/tests/manual/type-around.js

@@ -0,0 +1,132 @@
+/**
+ * @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 console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
+import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { toWidget, viewToModelPositionOutsideModelElement } from '../../src/utils';
+
+class InlineWidget extends Plugin {
+	constructor( editor ) {
+		super( editor );
+
+		editor.model.schema.register( 'placeholder', {
+			allowWhere: '$text',
+			isObject: true,
+			isInline: true
+		} );
+
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: ( modelItem, viewWriter ) => {
+				const widgetElement = createPlaceholderView( modelItem, viewWriter );
+
+				return toWidget( widgetElement, viewWriter );
+			}
+		} );
+
+		editor.conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: createPlaceholderView
+		} );
+
+		editor.conversion.for( 'upcast' ).elementToElement( {
+			view: 'placeholder',
+			model: 'placeholder'
+		} );
+
+		editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.name == 'placeholder' )
+		);
+
+		this._createToolbarButton();
+
+		function createPlaceholderView( modelItem, viewWriter ) {
+			const widgetElement = viewWriter.createContainerElement( 'placeholder' );
+			const viewText = viewWriter.createText( '{inline-widget}' );
+
+			viewWriter.insert( viewWriter.createPositionAt( widgetElement, 0 ), viewText );
+
+			return widgetElement;
+		}
+	}
+
+	_createToolbarButton() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'placeholder', locale => {
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				label: t( 'Insert placeholder' ),
+				tooltip: true,
+				withText: true
+			} );
+
+			this.listenTo( buttonView, 'execute', () => {
+				const model = editor.model;
+
+				model.change( writer => {
+					const placeholder = writer.createElement( 'placeholder' );
+
+					model.insertContent( placeholder );
+
+					writer.setSelection( placeholder, 'on' );
+				} );
+			} );
+
+			return buttonView;
+		} );
+	}
+}
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, HorizontalLine, InlineWidget, MediaEmbed ],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'link',
+			'bulletedList',
+			'numberedList',
+			'|',
+			'outdent',
+			'indent',
+			'|',
+			'blockQuote',
+			'horizontalLine',
+			'insertTable',
+			'mediaEmbed',
+			'undo',
+			'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		},
+		table: {
+			contentToolbar: [
+				'tableColumn',
+				'tableRow',
+				'mergeTableCells'
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+

+ 22 - 0
packages/ckeditor5-widget/tests/manual/type-around.md

@@ -0,0 +1,22 @@
+# Typing around block widgets
+
+## General idea
+
+1. Move the mouse cursor over various block widgets.
+2. There should be buttons appearing at the top/bottom boundaries of a widget when there is no way to put the caret before/after.
+3. Clicking an individual button should insert a paragraph before/after the widget.
+
+## States
+
+1. Check the look of the buttons when widgets are in a different states:
+	* Not selected but hovered by the cursor.
+	* Selected and hovered by the cursor.
+	* Selected but the editor is blurred (use dev tools console to test that) and hovered by the cursor.
+2. In each of these states, the buttons that insert paragraphs around widgets should blend in with the style of the widget.
+
+## Exceptions
+
+1. Inline widgets should **never** display the UI (buttons) regardless of their position in the document and their surroundings.
+2. Blocks widgets should display the UI only when there's a "tight spot" next to them. If you can type freely before/after a widget, the UI (buttons) related to that position should never appear.
+	* If you can type before the widget (e.g. preceded by a paragraph), the top button should never appear.
+	* If you can type after the widget (e.g. followed by a paragraph), the bottom button should never appear.

+ 21 - 11
packages/ckeditor5-widget/tests/widget-integration.js

@@ -42,10 +42,6 @@ describe( 'Widget - integration', () => {
 					inheritAllFrom: '$block',
 					isObject: true
 				} );
-				model.schema.register( 'paragraph', {
-					inheritAllFrom: '$block',
-					allowIn: '$root'
-				} );
 				model.schema.register( 'nested', {
 					allowIn: 'widget',
 					isLimit: true
@@ -63,7 +59,6 @@ describe( 'Widget - integration', () => {
 				} );
 
 				editor.conversion.for( 'downcast' )
-					.elementToElement( { model: 'paragraph', view: 'p' } )
 					.elementToElement( { model: 'inline', view: 'figure' } )
 					.elementToElement( { model: 'image', view: 'img' } )
 					.elementToElement( {
@@ -117,7 +112,11 @@ describe( 'Widget - integration', () => {
 		sinon.assert.notCalled( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>[]</p><div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">foo bar</figcaption></div>'
+			'<p>[]</p>' +
+			'<div class="ck-widget ck-widget_can-type-around_after" contenteditable="false">' +
+				'<figcaption contenteditable="true">foo bar</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 
 		expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );
@@ -140,7 +139,10 @@ describe( 'Widget - integration', () => {
 		sinon.assert.called( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">{foo bar}</figcaption></div>'
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before" contenteditable="false">' +
+				'<figcaption contenteditable="true">{foo bar}</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 		expect( getModelData( model ) ).to.equal( '<widget><nested>[foo bar]</nested></widget>' );
 	} );
@@ -162,9 +164,10 @@ describe( 'Widget - integration', () => {
 		sinon.assert.called( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false">' +
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before" contenteditable="false">' +
 				'<figcaption contenteditable="true">foo</figcaption>' +
 				'<figcaption contenteditable="true">{bar}</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 			'</div>'
 		);
 
@@ -188,7 +191,10 @@ describe( 'Widget - integration', () => {
 		sinon.assert.called( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">{foo bar}</figcaption></div>'
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before" contenteditable="false">' +
+				'<figcaption contenteditable="true">{foo bar}</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 
 		expect( getModelData( model ) ).to.equal( '<widget><nested>[foo bar]</nested></widget>' );
@@ -217,7 +223,7 @@ describe( 'Widget - integration', () => {
 		expect( getModelData( model ) ).to.equal( '<paragraph>Foo[<inline-widget>foo bar</inline-widget>]Bar</paragraph>' );
 	} );
 
-	it( 'should does nothing for non-Safari browser', () => {
+	it( 'should do nothing for non-Safari browser', () => {
 		testUtils.sinon.stub( env, 'isSafari' ).get( () => false );
 
 		setModelData( model, '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );
@@ -236,7 +242,11 @@ describe( 'Widget - integration', () => {
 		sinon.assert.notCalled( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>[]</p><div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">foo bar</figcaption></div>'
+			'<p>[]</p>' +
+			'<div class="ck-widget ck-widget_can-type-around_after" contenteditable="false">' +
+				'<figcaption contenteditable="true">foo bar</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 
 		expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );

+ 179 - 73
packages/ckeditor5-widget/tests/widget.js

@@ -3,8 +3,11 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Widget from '../src/widget';
+import WidgetTypeAround from '../src/widgettypearound/widgettypearound';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
 import { toWidget } from '../src/utils';
@@ -14,15 +17,19 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
-/* global document */
-
 describe( 'Widget', () => {
-	let editor, model, view, viewDocument;
+	let element, editor, model, view, viewDocument;
 
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		return VirtualTestEditor.create( { plugins: [ Widget, Typing ] } )
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ Widget, Typing ]
+			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
@@ -33,8 +40,7 @@ describe( 'Widget', () => {
 					inheritAllFrom: '$block',
 					isObject: true
 				} );
-				model.schema.register( 'paragraph', {
-					inheritAllFrom: '$block',
+				model.schema.extend( 'paragraph', {
 					allowIn: 'div'
 				} );
 				model.schema.register( 'inline', {
@@ -76,7 +82,6 @@ describe( 'Widget', () => {
 				} );
 
 				editor.conversion.for( 'downcast' )
-					.elementToElement( { model: 'paragraph', view: 'p' } )
 					.elementToElement( { model: 'inline', view: 'figure' } )
 					.elementToElement( { model: 'image', view: 'img' } )
 					.elementToElement( { model: 'blockQuote', view: 'blockquote' } )
@@ -110,6 +115,12 @@ describe( 'Widget', () => {
 			} );
 	} );
 
+	afterEach( () => {
+		element.remove();
+
+		return editor.destroy();
+	} );
+
 	it( 'should be loaded', () => {
 		expect( editor.plugins.get( Widget ) ).to.be.instanceOf( Widget );
 	} );
@@ -118,63 +129,67 @@ describe( 'Widget', () => {
 		expect( view.getObserver( MouseObserver ) ).to.be.instanceof( MouseObserver );
 	} );
 
+	it( 'should require the WidgetTypeAround plugin', () => {
+		expect( Widget.requires ).to.have.members( [ WidgetTypeAround ] );
+	} );
+
 	it( 'should create selection over clicked widget', () => {
 		setModelData( model, '[]<widget></widget>' );
 		const viewDiv = viewDocument.getRoot().getChild( 0 );
-		const domEventDataMock = {
-			target: viewDiv,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewDiv ),
 			preventDefault: sinon.spy()
-		};
+		} );
 
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
 		expect( getModelData( model ) ).to.equal( '[<widget></widget>]' );
-		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.calledOnce( domEventDataMock.domEvent.preventDefault );
 	} );
 
 	it( 'should create selection when clicked in nested element', () => {
 		setModelData( model, '[]<widget></widget>' );
 		const viewDiv = viewDocument.getRoot().getChild( 0 );
 		const viewB = viewDiv.getChild( 0 );
-		const domEventDataMock = {
-			target: viewB,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewB ),
 			preventDefault: sinon.spy()
-		};
+		} );
 
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
 		expect( getModelData( model ) ).to.equal( '[<widget></widget>]' );
-		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.calledOnce( domEventDataMock.domEvent.preventDefault );
 	} );
 
 	it( 'should do nothing if clicked in non-widget element', () => {
 		setModelData( model, '<paragraph>[]foo bar</paragraph><widget></widget>' );
 		const viewP = viewDocument.getRoot().getChild( 0 );
-		const domEventDataMock = {
-			target: viewP,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewP ),
 			preventDefault: sinon.spy()
-		};
+		} );
 
 		view.focus();
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
 		expect( getModelData( model ) ).to.equal( '<paragraph>[]foo bar</paragraph><widget></widget>' );
-		sinon.assert.notCalled( domEventDataMock.preventDefault );
+		sinon.assert.notCalled( domEventDataMock.domEvent.preventDefault );
 	} );
 
 	it( 'should not focus editable if already is focused', () => {
 		setModelData( model, '<widget></widget>' );
 		const widget = viewDocument.getRoot().getChild( 0 );
-		const domEventDataMock = {
-			target: widget,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( widget ),
 			preventDefault: sinon.spy()
-		};
+		} );
 		const focusSpy = sinon.spy( view, 'focus' );
 
 		viewDocument.isFocused = true;
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
-		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.calledOnce( domEventDataMock.domEvent.preventDefault );
 		sinon.assert.notCalled( focusSpy );
 		expect( getModelData( model ) ).to.equal( '[<widget></widget>]' );
 	} );
@@ -183,7 +198,12 @@ describe( 'Widget', () => {
 		setModelData( model, '[<widget>foo bar</widget>]' );
 
 		expect( getViewData( view ) ).to.equal(
-			'[<div class="ck-widget ck-widget_selected" contenteditable="false">foo bar<b></b></div>]'
+			'[<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_selected" ' +
+			'contenteditable="false">' +
+				'foo bar' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>]'
 		);
 		expect( viewDocument.selection.isFake ).to.be.true;
 	} );
@@ -201,9 +221,15 @@ describe( 'Widget', () => {
 		expect( getViewData( view ) ).to.equal(
 
 			'<p>{foo</p>' +
-			'<div class="ck-widget ck-widget_selected" contenteditable="false"><b></b></div>' +
-			'<div class="ck-widget ck-widget_selected" contenteditable="false"><b></b></div>' +
-			']'
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_selected" contenteditable="false">' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>' +
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_selected" ' +
+			'contenteditable="false">' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>]'
 		);
 	} );
 
@@ -217,7 +243,12 @@ describe( 'Widget', () => {
 		setModelData( model, '<paragraph>foo</paragraph>[<widget>foo</widget>]' );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>foo</p>[<div class="ck-widget ck-widget_selected" contenteditable="false">foo<b></b></div>]'
+			'<p>foo</p>' +
+			'[<div class="ck-widget ck-widget_can-type-around_after ck-widget_selected" contenteditable="false">' +
+				'foo' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>]'
 		);
 
 		model.change( writer => {
@@ -225,7 +256,12 @@ describe( 'Widget', () => {
 		} );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>{}foo</p><div class="ck-widget" contenteditable="false">foo<b></b></div>'
+			'<p>{}foo</p>' +
+			'<div class="ck-widget ck-widget_can-type-around_after" contenteditable="false">' +
+				'foo' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 	} );
 
@@ -233,9 +269,10 @@ describe( 'Widget', () => {
 		setModelData( model, '<widget><editable>foo bar</editable></widget><editable>[baz]</editable>' );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false">' +
+			'<div class="ck-widget ck-widget_can-type-around_before" contenteditable="false">' +
 				'<figcaption contenteditable="true">foo bar</figcaption>' +
 				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 			'</div>' +
 			'<figcaption contenteditable="true">{baz}</figcaption>'
 		);
@@ -1229,8 +1266,16 @@ describe( 'Widget', () => {
 	} );
 
 	describe( 'selection handle', () => {
+		let element, editor;
+
 		beforeEach( () => {
-			return VirtualTestEditor.create( { plugins: [ Widget, Typing ] } )
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Widget, Typing ]
+				} )
 				.then( newEditor => {
 					editor = newEditor;
 					model = editor.model;
@@ -1242,9 +1287,6 @@ describe( 'Widget', () => {
 						allowIn: 'widget',
 						isObject: true
 					} );
-					model.schema.register( 'paragraph', {
-						inheritAllFrom: '$block'
-					} );
 					model.schema.register( 'nested', {
 						allowIn: 'widget',
 						isLimit: true
@@ -1270,15 +1312,21 @@ describe( 'Widget', () => {
 				} );
 		} );
 
+		afterEach( () => {
+			element.remove();
+
+			return editor.destroy();
+		} );
+
 		it( 'should select a widget on mouse click', () => {
 			setModelData( model, '<paragraph>bar</paragraph><widget></widget><paragraph>foo[]</paragraph>' );
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 1 ).getChild( 0 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
@@ -1291,22 +1339,33 @@ describe( 'Widget', () => {
 			// The top-outer widget.
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 0 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+				'[<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<div class="' +
+						'ck-widget ' +
+						'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+						'ck-widget_with-selection-handle" contenteditable="false"' +
+					'>' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+					'<div class="ck-widget ck-widget_can-type-around_before ck-widget_with-selection-handle" contenteditable="false">' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>]'
 			);
 		} );
@@ -1323,28 +1382,43 @@ describe( 'Widget', () => {
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 1 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+				'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+				'contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>' +
-				'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-						'<div class="ck ck-widget__selection-handle"></div>' +
-					'</div>' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-						'<div class="ck ck-widget__selection-handle"></div>' +
-					'</div>' +
+				'[<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<div ' +
+					'class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+					'contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+				'</div>' +
+					'<div class="ck-widget ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+					'contenteditable="false">' +
+					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+				'</div>' +
+					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>]' +
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+					'<div ' +
+					'class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+					'contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>'
 			);
 		} );
@@ -1359,20 +1433,26 @@ describe( 'Widget', () => {
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 0 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
+				'[<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
 					'<figcaption contenteditable="true">foo bar</figcaption>' +
 					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>]'
 			);
 		} );
@@ -1389,25 +1469,41 @@ describe( 'Widget', () => {
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 0 ).getChild( 1 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+				'<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<div class="' +
+						'ck-widget ' +
+						'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+						'ck-widget_with-selection-handle" contenteditable="false"' +
+					'>' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
-					'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
-						'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+					'[<div class="' +
+						'ck-widget ' +
+						'ck-widget_can-type-around_before ' +
+						'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+					'>' +
+						'<div class="ck-widget ck-widget_can-type-around_before ck-widget_with-selection-handle" contenteditable="false">' +
 							'<div class="ck ck-widget__selection-handle"></div>' +
+							'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 						'</div>' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>]' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>'
 			);
 		} );
@@ -1419,21 +1515,31 @@ describe( 'Widget', () => {
 
 			const widgetInEditable = viewDocument.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 );
 
-			const domEventDataMock = {
-				target: widgetInEditable,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( widgetInEditable ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-					'<figcaption contenteditable="true">' +
-						'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
+				'<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<figcaption contenteditable="true">[' +
+						'<div class="' +
+							'ck-widget ' +
+							'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+							'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+						'>' +
 							'<div class="ck ck-widget__selection-handle"></div>' +
+							'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 						'</div>]' +
 					'</figcaption>' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>'
 			);
 		} );

+ 345 - 0
packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js

@@ -0,0 +1,345 @@
+/**
+ * @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 ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import Widget from '../../src/widget';
+import WidgetTypeAround from '../../src/widgettypearound/widgettypearound';
+import { toWidget } from '../../src/utils';
+
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'WidgetTypeAround', () => {
+	let element, plugin, editor, editingView, viewDocument, viewRoot;
+
+	beforeEach( async () => {
+		element = global.document.createElement( 'div' );
+		global.document.body.appendChild( element );
+
+		editor = await ClassicEditor.create( element, {
+			plugins: [
+				ArticlePluginSet, Widget,
+
+				blockWidgetPlugin, inlineWidgetPlugin
+			]
+		} );
+
+		editingView = editor.editing.view;
+		viewDocument = editingView.document;
+		viewRoot = viewDocument.getRoot();
+		plugin = editor.plugins.get( WidgetTypeAround );
+	} );
+
+	afterEach( async () => {
+		element.remove();
+
+		await editor.destroy();
+	} );
+
+	describe( 'plugin', () => {
+		it( 'is loaded', () => {
+			expect( editor.plugins.get( WidgetTypeAround ) ).to.be.instanceOf( WidgetTypeAround );
+		} );
+
+		it( 'requires the Paragraph plugin', () => {
+			expect( WidgetTypeAround.requires ).to.deep.equal( [ Paragraph ] );
+		} );
+	} );
+
+	describe( '_insertParagraph()', () => {
+		it( 'should insert a paragraph with a selection before a widget when position is "before"', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'before' );
+
+			expect( getModelData( editor.model ) ).to.equal( '<paragraph>[]</paragraph><blockWidget></blockWidget>' );
+		} );
+
+		it( 'should insert a paragraph with a selection after a widget when position is "after"', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' );
+
+			expect( getModelData( editor.model ) ).to.equal( '<blockWidget></blockWidget><paragraph>[]</paragraph>' );
+		} );
+
+		it( 'should focus the editing view', () => {
+			const spy = sinon.spy( editor.editing.view, 'focus' );
+
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should scroll the editing view to the selection in an inserted paragraph', () => {
+			const spy = sinon.spy( editor.editing.view, 'scrollToTheSelection' );
+
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'UI to type around view widgets', () => {
+		it( 'should be injected in block widgets', () => {
+			setModelData( editor.model,
+				'<paragraph>foo</paragraph>' +
+				'<blockWidget></blockWidget>' +
+				'<paragraph>bar</paragraph>' +
+				'<blockWidget></blockWidget>'
+			);
+
+			const firstViewWidget = viewRoot.getChild( 1 );
+			const lastViewWidget = viewRoot.getChild( 3 );
+
+			expect( firstViewWidget.childCount ).to.equal( 2 );
+			expect( firstViewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true;
+
+			expect( lastViewWidget.childCount ).to.equal( 2 );
+			expect( lastViewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true;
+		} );
+
+		it( 'should not be injected in inline widgets', () => {
+			setModelData( editor.model,
+				'<paragraph>foo<inlineWidget></inlineWidget></paragraph>' +
+				'<paragraph><inlineWidget></inlineWidget>bar</paragraph>'
+			);
+
+			const firstViewWidget = viewRoot.getChild( 0 ).getChild( 1 );
+			const lastViewWidget = viewRoot.getChild( 1 ).getChild( 0 );
+
+			expect( firstViewWidget.childCount ).to.equal( 1 );
+			expect( firstViewWidget.getChild( 0 ).is( 'text' ) ).to.be.true;
+			expect( lastViewWidget.childCount ).to.equal( 1 );
+			expect( lastViewWidget.getChild( 0 ).is( 'text' ) ).to.be.true;
+		} );
+
+		it( 'should inject buttons into the wrapper', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			const viewWidget = viewRoot.getChild( 0 );
+
+			expect( viewWidget.getChild( 1 ).is( 'uiElement' ) ).to.be.true;
+			expect( viewWidget.getChild( 1 ).hasClass( 'ck' ) ).to.be.true;
+			expect( viewWidget.getChild( 1 ).hasClass( 'ck-reset_all' ) ).to.be.true;
+			expect( viewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true;
+
+			const domWrapper = editingView.domConverter.viewToDom( viewWidget.getChild( 1 ) );
+
+			expect( domWrapper.querySelectorAll( '.ck-widget__type-around__button' ) ).to.have.length( 2 );
+		} );
+
+		describe( 'UI button to type around', () => {
+			let buttonBefore, buttonAfter;
+
+			beforeEach( () => {
+				setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+				const viewWidget = viewRoot.getChild( 0 );
+				const domWrapper = editingView.domConverter.viewToDom( viewWidget.getChild( 1 ) );
+
+				buttonBefore = domWrapper.firstChild;
+				buttonAfter = domWrapper.lastChild;
+			} );
+
+			it( 'should have proper CSS classes', () => {
+				expect( buttonBefore.classList.contains( 'ck' ) ).to.be.true;
+				expect( buttonBefore.classList.contains( 'ck-widget__type-around__button' ) ).to.be.true;
+
+				expect( buttonAfter.classList.contains( 'ck' ) ).to.be.true;
+				expect( buttonAfter.classList.contains( 'ck-widget__type-around__button' ) ).to.be.true;
+			} );
+
+			describe( 'button to type "before" a widget', () => {
+				it( 'should have a specific class', () => {
+					expect( buttonBefore.classList.contains( 'ck-widget__type-around__button_before' ) ).to.be.true;
+				} );
+
+				it( 'should have a specific "title"', () => {
+					expect( buttonBefore.getAttribute( 'title' ) ).to.equal( 'Insert paragraph before block' );
+				} );
+
+				it( 'should execute WidgetTypeAround#_insertParagraph() when clicked', () => {
+					const preventDefaultSpy = sinon.spy();
+					const typeAroundSpy = sinon.spy( plugin, '_insertParagraph' );
+
+					const eventInfo = new EventInfo( viewDocument, 'mousedown' );
+					const stopSpy = sinon.stub( eventInfo, 'stop' );
+					const domEventDataMock = new DomEventData( editingView, {
+						target: buttonBefore,
+						preventDefault: preventDefaultSpy
+					} );
+
+					viewDocument.fire( eventInfo, domEventDataMock );
+
+					sinon.assert.calledOnce( typeAroundSpy );
+					sinon.assert.calledWithExactly( typeAroundSpy, viewRoot.getChild( 1 ), 'before' );
+					sinon.assert.calledOnce( preventDefaultSpy );
+					sinon.assert.calledOnce( stopSpy );
+				} );
+			} );
+
+			describe( 'button to type "after" a widget', () => {
+				it( 'should have a specific class', () => {
+					expect( buttonAfter.classList.contains( 'ck-widget__type-around__button_after' ) ).to.be.true;
+				} );
+
+				it( 'should have a specific "title"', () => {
+					expect( buttonAfter.getAttribute( 'title' ) ).to.equal( 'Insert paragraph after block' );
+				} );
+
+				it( 'should execute WidgetTypeAround#_insertParagraph() when clicked', () => {
+					const preventDefaultSpy = sinon.spy();
+					const typeAroundSpy = sinon.spy( plugin, '_insertParagraph' );
+
+					const eventInfo = new EventInfo( viewDocument, 'mousedown' );
+					const stopSpy = sinon.stub( eventInfo, 'stop' );
+					const domEventDataMock = new DomEventData( editingView, {
+						target: buttonAfter,
+						preventDefault: preventDefaultSpy
+					} );
+
+					viewDocument.fire( eventInfo, domEventDataMock );
+
+					sinon.assert.calledOnce( typeAroundSpy );
+					sinon.assert.calledWithExactly( typeAroundSpy, viewRoot.getChild( 0 ), 'after' );
+					sinon.assert.calledOnce( preventDefaultSpy );
+					sinon.assert.calledOnce( stopSpy );
+				} );
+			} );
+
+			it( 'should have an icon', () => {
+				const icon = buttonBefore.firstChild;
+
+				expect( icon.tagName.toLowerCase() ).to.equal( 'svg' );
+				expect( icon.getAttribute( 'viewBox' ) ).to.equal( '0 0 10 8' );
+			} );
+		} );
+	} );
+
+	describe( 'detection and CSS classes of widgets needing the typing around support', () => {
+		it( 'should detect widgets that are a first child of the parent container', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget><paragraph>foo</paragraph>' );
+
+			const viewWidget = viewRoot.getChild( 0 );
+
+			assertIsTypeAroundBefore( viewWidget, true );
+			assertIsTypeAroundAfter( viewWidget, false );
+		} );
+
+		it( 'should detect widgets that are a last child of the parent container', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph><blockWidget></blockWidget>' );
+
+			const viewWidget = viewRoot.getChild( 1 );
+
+			assertIsTypeAroundBefore( viewWidget, false );
+			assertIsTypeAroundAfter( viewWidget, true );
+		} );
+
+		it( 'should not detect widgets that are surrounded by sibling which allow the selection', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph><blockWidget></blockWidget><paragraph>bar</paragraph>' );
+
+			const viewWidget = viewRoot.getChild( 1 );
+
+			assertIsTypeAroundBefore( viewWidget, false );
+			assertIsTypeAroundAfter( viewWidget, false );
+		} );
+
+		it( 'should detect widgets that have another block widget as a next sibling', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget><blockWidget></blockWidget>' );
+
+			const firstViewWidget = viewRoot.getChild( 0 );
+
+			assertIsTypeAroundBefore( firstViewWidget, true );
+			assertIsTypeAroundAfter( firstViewWidget, true );
+		} );
+
+		it( 'should detect widgets that have another block widget as a previous sibling', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget><blockWidget></blockWidget>' );
+
+			const lastViewWidget = viewRoot.getChild( 1 );
+
+			assertIsTypeAroundBefore( lastViewWidget, true );
+			assertIsTypeAroundAfter( lastViewWidget, true );
+		} );
+
+		it( 'should not detect inline widgets even if they fall in previous categories', () => {
+			setModelData( editor.model,
+				'<paragraph><inlineWidget></inlineWidget><inlineWidget></inlineWidget></paragraph>'
+			);
+
+			const firstViewWidget = viewRoot.getChild( 0 ).getChild( 0 );
+			const lastViewWidget = viewRoot.getChild( 0 ).getChild( 1 );
+
+			assertIsTypeAroundBefore( firstViewWidget, false );
+			assertIsTypeAroundAfter( firstViewWidget, false );
+
+			assertIsTypeAroundBefore( lastViewWidget, false );
+			assertIsTypeAroundAfter( lastViewWidget, false );
+		} );
+	} );
+
+	function assertIsTypeAroundBefore( viewWidget, expected ) {
+		expect( viewWidget.hasClass( 'ck-widget_can-type-around_before' ) ).to.equal( expected );
+	}
+
+	function assertIsTypeAroundAfter( viewWidget, expected ) {
+		expect( viewWidget.hasClass( 'ck-widget_can-type-around_after' ) ).to.equal( expected );
+	}
+
+	function blockWidgetPlugin( editor ) {
+		editor.model.schema.register( 'blockWidget', {
+			inheritAllFrom: '$block',
+			isObject: true
+		} );
+
+		editor.conversion.for( 'downcast' )
+			.elementToElement( {
+				model: 'blockWidget',
+				view: ( modelItem, viewWriter ) => {
+					const container = viewWriter.createContainerElement( 'div' );
+					const viewText = viewWriter.createText( 'block-widget' );
+
+					viewWriter.insert( viewWriter.createPositionAt( container, 0 ), viewText );
+
+					return toWidget( container, viewWriter, {
+						label: 'block widget'
+					} );
+				}
+			} );
+	}
+
+	function inlineWidgetPlugin( editor ) {
+		editor.model.schema.register( 'inlineWidget', {
+			allowWhere: '$text',
+			isObject: true,
+			isInline: true
+		} );
+
+		editor.conversion.for( 'downcast' )
+			.elementToElement( {
+				model: 'inlineWidget',
+				view: ( modelItem, viewWriter ) => {
+					const container = viewWriter.createContainerElement( 'inlineWidget' );
+					const viewText = viewWriter.createText( 'inline-widget' );
+
+					viewWriter.insert( viewWriter.createPositionAt( container, 0 ), viewText );
+
+					return toWidget( container, viewWriter, {
+						label: 'inline widget'
+					} );
+				}
+			} );
+	}
+} );

+ 1 - 0
packages/ckeditor5-widget/theme/icons/return-arrow.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 10 8" xmlns="http://www.w3.org/2000/svg"><polyline points="8.05541992 0.263427734 8.05541992 4.23461914 1.28417969 4.23461914" transform="translate(1,0)"></polyline><line x1="0" y1="4.21581031" x2="2" y2="2.17810059" transform="translate(1, 0)"></line><line x1="0" y1="6.21581031" x2="2" y2="4.17810059" transform="translate(2, 5.196955) scale(1, -1) translate(-1, -5.196955)"></line></svg>

+ 5 - 0
packages/ckeditor5-widget/theme/widget.css

@@ -17,6 +17,11 @@
 	--ck-color-resizer-tooltip-text: hsl(0, 0%, 95%);
 }
 
+.ck .ck-widget {
+	/* This is neccessary for type around UI to be positioned properly. */
+	position: relative;
+}
+
 .ck .ck-widget.ck-widget_with-selection-handle {
 	/* Make the widget wrapper a relative positioning container for the drag handle. */
 	position: relative;

+ 77 - 0
packages/ckeditor5-widget/theme/widgettypearound.css

@@ -0,0 +1,77 @@
+/*
+ * 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-widget {
+	/*
+	 * Styles of the type around buttons
+	 */
+	& .ck-widget__type-around__button {
+		display: none;
+		position: absolute;
+		overflow: hidden;
+		z-index: var(--ck-z-default);
+
+		& svg {
+			position: absolute;
+			top: 50%;
+			left: 50%;
+			z-index: calc(var(--ck-z-default) + 2);
+		}
+
+		&.ck-widget__type-around__button_before {
+			/* Place it in the middle of the outline */
+			top: calc(-0.5 * var(--ck-widget-outline-thickness));
+			left: min(10%, 30px);
+
+			transform: translateY(-50%);
+		}
+
+		&.ck-widget__type-around__button_after {
+			/* Place it in the middle of the outline */
+			bottom: calc(-0.5 * var(--ck-widget-outline-thickness));
+			right: min(10%, 30px);
+
+			transform: translateY(50%);
+		}
+	}
+
+	/*
+	 * Show type around buttons when the widget gets selected or being hovered.
+	 */
+	&.ck-widget_selected,
+	&:hover {
+		& > .ck-widget__type-around > .ck-widget__type-around__button {
+			display: block;
+		}
+	}
+
+	/*
+	 * Hide the type around buttons depending on which directions the widget supports.
+	 */
+	&:not(.ck-widget_can-type-around_before) > .ck-widget__type-around > .ck-widget__type-around__button_before {
+		display: none;
+	}
+
+	&:not(.ck-widget_can-type-around_after) > .ck-widget__type-around > .ck-widget__type-around__button_after {
+		display: none;
+	}
+
+	/*
+	 * Styles for the buttons when:
+	 * - the widget is selected,
+	 * - or the button is being hovered (regardless of the widget state).
+	 */
+	&.ck-widget_selected > .ck-widget__type-around > .ck-widget__type-around__button,
+	& > .ck-widget__type-around > .ck-widget__type-around__button:hover {
+		&::after {
+			content: "";
+			display: block;
+			position: absolute;
+			top: 1px;
+			left: 1px;
+			z-index: calc(var(--ck-z-default) + 1);
+		}
+	}
+}

+ 10 - 1
scripts/clean-up-svg-icons.sh

@@ -12,7 +12,16 @@
 # To optimize the entire project:
 #	yarn clean-up-svg-icons packages/**/theme/icons
 
+# A list of icons that should not NOT be cleaned up. Their internal structure should not be changed
+# because, for instance, CSS animations may depend on it.
+BLACKLIST=("return-arrow.svg")
+
 for i in "$@"
 do
-	svgo --config=./scripts/svgo.config.json -i $i
+	if [[ " ${BLACKLIST[@]} " =~ " $(basename $i) " ]]
+	then
+		echo "\x1B[33m[clean-up-svg-icons]\x1B[0m Note: \"$i\" is blacklisted, skipping."
+	else
+		svgo --config=./scripts/svgo.config.json -i $i
+	fi
 done