Jelajahi Sumber

Merge branch 'master' into i/6190

Aleksander Nowodzinski 5 tahun lalu
induk
melakukan
f060ccede0
37 mengubah file dengan 1934 tambahan dan 261 penghapusan
  1. 1 0
      .gitignore
  2. 3 1
      .travis.yml
  3. 1 0
      docs/framework/guides/contributing/testing-environment.md
  4. 1 0
      docs/umberto.json
  5. 7 3
      package.json
  6. 1 1
      packages/ckeditor5-horizontal-line/theme/horizontalline.css
  7. 41 11
      packages/ckeditor5-image/tests/image.js
  8. 1 1
      packages/ckeditor5-image/tests/imageresize.js
  9. 4 2
      packages/ckeditor5-media-embed/src/converters.js
  10. 7 2
      packages/ckeditor5-media-embed/src/mediaregistry.js
  11. 42 0
      packages/ckeditor5-media-embed/tests/mediaembedediting.js
  12. 1 1
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  13. 52 21
      packages/ckeditor5-table/src/tableutils.js
  14. 54 0
      packages/ckeditor5-table/tests/commands/insertrowcommand.js
  15. 96 0
      packages/ckeditor5-table/tests/tableutils.js
  16. 2 1
      packages/ckeditor5-theme-lark/theme/ckeditor5-ui/globals/_colors.css
  17. 146 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css
  18. 3 1
      packages/ckeditor5-widget/lang/contexts.json
  19. 2 0
      packages/ckeditor5-widget/package.json
  20. 8 0
      packages/ckeditor5-widget/src/widget.js
  21. 95 0
      packages/ckeditor5-widget/src/widgettypearound/utils.js
  22. 272 0
      packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js
  23. TEMPAT SAMPAH
      packages/ckeditor5-widget/tests/manual/sample.jpg
  24. 97 0
      packages/ckeditor5-widget/tests/manual/type-around.html
  25. 132 0
      packages/ckeditor5-widget/tests/manual/type-around.js
  26. 22 0
      packages/ckeditor5-widget/tests/manual/type-around.md
  27. 21 11
      packages/ckeditor5-widget/tests/widget-integration.js
  28. 179 73
      packages/ckeditor5-widget/tests/widget.js
  29. 345 0
      packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js
  30. 1 0
      packages/ckeditor5-widget/theme/icons/return-arrow.svg
  31. 5 0
      packages/ckeditor5-widget/theme/widget.css
  32. 77 0
      packages/ckeditor5-widget/theme/widgettypearound.css
  33. 10 1
      scripts/clean-up-svg-icons.sh
  34. 0 102
      scripts/continuous-integration-run-tests.sh
  35. 152 0
      scripts/continuous-integration-script.js
  36. 4 1
      scripts/docs/buildapi.js
  37. 49 28
      yarn.lock

+ 1 - 0
.gitignore

@@ -3,6 +3,7 @@ build/
 coverage/
 packages/*/node_modules/
 docs/api/output.json
+external/
 lerna-debug.log
 .idea
 .vscode

+ 3 - 1
.travis.yml

@@ -17,7 +17,9 @@ before_install:
 install:
 - yarn install
 script:
-- ./scripts/continuous-integration-run-tests.sh
+- node ./scripts/continuous-integration-script.js
+- yarn run lint
+- yarn run stylelint
 - yarn run docs:api --validate-only
 - 'if [ $TRAVIS_TEST_RESULT -eq 0 ]; then
     travis_wait 30 yarn run docs:build-and-publish-nightly;

+ 1 - 0
docs/framework/guides/contributing/testing-environment.md

@@ -26,6 +26,7 @@ It accepts the following arguments that must be passed after the `--` option:
 * `--files` – Specifies test files to run. Accepts a package name or a glob. For example `--files=engine` will run tests from `ckeditor5-engine` package. Read more about the [rules for converting the `--files` option to a glob pattern](https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-tests#rules-for-converting---files-option-to-glob-pattern).
 * `--browsers` – Browsers that will be used to run the tests. Defaults to `Chrome`.
 * `--debug` (alias `-d`) – Allows specifying custom debug flags. For example, the `--debug engine` option uncomments the `// @if CK_DEBUG_ENGINE //` lines in the code. Note that by default `--debug` is set to `true` even if you did not specify it. This enables the base set of debug logs (`// @if CK_DEBUG //`) which should always be enabled in the testing environment. You can completely turn off the debug mode by setting the `--debug false` option.
+* `--port` – Specifies the port for the server to use. Defaults to `9876`.
 
 ### Examples
 

+ 1 - 0
docs/umberto.json

@@ -3,6 +3,7 @@
 	"slug": "ckeditor5",
 	"hexo-config": "docs/hexo-custom-config.json",
 	"extraStyles": "assets/styles.css",
+	"packagesDir": ["packages", "external/*/packages"],
 	"path": "docs",
 	"observablesdocs": "module:utils/observablemixin~ObservableMixin",
 	"redirects": {

+ 7 - 3
package.json

@@ -79,7 +79,7 @@
     "@ckeditor/ckeditor5-comments": "^19.0.1",
     "@ckeditor/ckeditor5-dev-docs": "^11.1.0",
     "@ckeditor/ckeditor5-dev-env": "^18.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^19.1.0",
+    "@ckeditor/ckeditor5-dev-tests": "^19.2.0",
     "@ckeditor/ckeditor5-dev-utils": "^13.0.0",
     "@ckeditor/ckeditor5-dev-webpack-plugin": "^9.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.0.0",
@@ -88,6 +88,7 @@
     "@ckeditor/ckeditor5-track-changes": "^19.0.1",
     "@wiris/mathtype-ckeditor5": "^7.19.0",
     "babel-standalone": "^6.26.0",
+    "coveralls": "^3.1.0",
     "css-loader": "^1.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^2.0.0",
@@ -108,7 +109,7 @@
     "stylelint-config-ckeditor5": "^1.0.0",
     "svgo": "^1.1.0",
     "terser-webpack-plugin": "^2.2.1",
-    "umberto": "^1.2.0",
+    "umberto": "^1.3.0",
     "webpack": "^4.39.1"
   },
   "engines": {
@@ -125,7 +126,7 @@
   },
   "scripts": {
     "lint": "eslint --quiet '**/*.js'",
-    "stylelint": "stylelint --quiet --allow-empty-input 'packages/**/*.css' 'docs/**/*.css'",
+    "stylelint": "stylelint --quiet --allow-empty-input 'packages/**/*.css' 'external/*/packages/**/*.css' 'docs/**/*.css'",
     "test": "node --max_old_space_size=4096 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test.js",
     "manual": "node --max_old_space_size=4096 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test-manual.js",
     "bootstrap": "yarn install",
@@ -159,11 +160,14 @@
   },
   "eslintIgnore": [
     "build/**",
+    "packages/*/build/**",
+    "packages/*/src/lib/**",
     "coverage/**"
   ],
   "workspaces": {
     "packages": [
       "packages/*",
+      "external/*/packages/*",
       "."
     ],
     "nohoist": [

+ 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>'
+						);
+					} );
 				} );
 			}
 		} );

+ 1 - 1
packages/ckeditor5-table/src/commands/insertrowcommand.js

@@ -78,6 +78,6 @@ export default class InsertRowCommand extends Command {
 		const row = insertAbove ? rowIndexes.first : rowIndexes.last;
 		const table = findAncestor( 'table', affectedTableCells[ 0 ] );
 
-		tableUtils.insertRows( table, { rows: 1, at: insertAbove ? row : row + 1 } );
+		tableUtils.insertRows( table, { at: insertAbove ? row : row + 1, copyStructureFromAbove: !insertAbove } );
 	}
 }

+ 52 - 21
packages/ckeditor5-table/src/tableutils.js

@@ -116,12 +116,19 @@ export default class TableUtils extends Plugin {
 	 * @param {Object} options
 	 * @param {Number} [options.at=0] The row index at which the rows will be inserted.
 	 * @param {Number} [options.rows=1] The number of rows to insert.
+	 * @param {Boolean|undefined} [options.copyStructureFromAbove] The flag for copying row structure. Note that
+	 * the row structure will not be copied if this option is not provided.
 	 */
 	insertRows( table, options = {} ) {
 		const model = this.editor.model;
 
 		const insertAt = options.at || 0;
 		const rowsToInsert = options.rows || 1;
+		const isCopyStructure = options.copyStructureFromAbove !== undefined;
+		const copyStructureFrom = options.copyStructureFromAbove ? insertAt - 1 : insertAt;
+
+		const rows = this.getRows( table );
+		const columns = this.getColumns( table );
 
 		model.change( writer => {
 			const headingRows = table.getAttribute( 'headingRows' ) || 0;
@@ -131,38 +138,58 @@ export default class TableUtils extends Plugin {
 				writer.setAttribute( 'headingRows', headingRows + rowsToInsert, table );
 			}
 
-			// Inserting at the end and at the beginning of a table doesn't require to calculate anything special.
-			if ( insertAt === 0 || insertAt === table.childCount ) {
-				createEmptyRows( writer, table, insertAt, rowsToInsert, this.getColumns( table ) );
+			// Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
+			if ( !isCopyStructure && ( insertAt === 0 || insertAt === rows ) ) {
+				createEmptyRows( writer, table, insertAt, rowsToInsert, columns );
 
 				return;
 			}
 
-			// Iterate over all rows above inserted rows in order to check for rowspanned cells.
-			const tableIterator = new TableWalker( table, { endRow: insertAt } );
+			// Iterate over all the rows above the inserted rows in order to check for the row-spanned cells.
+			const walkerEndRow = isCopyStructure ? Math.max( insertAt, copyStructureFrom ) : insertAt;
+			const tableIterator = new TableWalker( table, { endRow: walkerEndRow } );
 
-			// Will hold number of cells needed to insert in created rows.
-			// The number might be different then table cell width when there are rowspanned cells.
-			let cellsToInsert = 0;
+			// Store spans of the reference row to reproduce it's structure. This array is column number indexed.
+			const rowColSpansMap = new Array( columns ).fill( 1 );
 
-			for ( const { row, rowspan, colspan, cell } of tableIterator ) {
-				const isBeforeInsertedRow = row < insertAt;
-				const overlapsInsertedRow = row + rowspan > insertAt;
+			for ( const { row, column, rowspan, colspan, cell } of tableIterator ) {
+				const lastCellRow = row + rowspan - 1;
 
-				if ( isBeforeInsertedRow && overlapsInsertedRow ) {
-					// This cell overlaps inserted rows so we need to expand it further.
+				const isOverlappingInsertedRow = row < insertAt && insertAt <= lastCellRow;
+				const isReferenceRow = row <= copyStructureFrom && copyStructureFrom <= lastCellRow;
+
+				// If the cell is row-spanned and overlaps the inserted row, then reserve space for it in the row map.
+				if ( isOverlappingInsertedRow ) {
+					// This cell overlaps the inserted rows so we need to expand it further.
 					writer.setAttribute( 'rowspan', rowspan + rowsToInsert, cell );
-				}
 
-				// Calculate how many cells to insert based on the width of cells in a row at insert position.
-				// It might be lower then table width as some cells might overlaps inserted row.
-				// In the table above the cell 'a' overlaps inserted row so only two empty cells are need to be created.
-				if ( row === insertAt ) {
-					cellsToInsert += colspan;
+					// Mark this cell with negative number to indicate how many cells should be skipped when adding the new cells.
+					rowColSpansMap[ column ] = -colspan;
+				}
+				// Store the colspan from reference row.
+				else if ( isCopyStructure && isReferenceRow ) {
+					rowColSpansMap[ column ] = colspan;
 				}
 			}
 
-			createEmptyRows( writer, table, insertAt, rowsToInsert, cellsToInsert );
+			for ( let rowIndex = 0; rowIndex < rowsToInsert; rowIndex++ ) {
+				const tableRow = writer.createElement( 'tableRow' );
+
+				writer.insert( tableRow, table, insertAt );
+
+				for ( let cellIndex = 0; cellIndex < rowColSpansMap.length; cellIndex++ ) {
+					const colspan = rowColSpansMap[ cellIndex ];
+					const insertPosition = writer.createPositionAt( tableRow, 'end' );
+
+					// Insert the empty cell only if this slot is not row-spanned from any other cell.
+					if ( colspan > 0 ) {
+						createEmptyTableCell( writer, insertPosition, colspan > 1 ? { colspan } : null );
+					}
+
+					// Skip the col-spanned slots, there won't be any cells.
+					cellIndex += Math.abs( colspan ) - 1;
+				}
+			}
 		} );
 	}
 
@@ -358,6 +385,8 @@ export default class TableUtils extends Plugin {
 		model.change( writer => {
 			adjustHeadingColumns( table, { first, last }, writer );
 
+			const emptyRowsIndexes = [];
+
 			for ( let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex-- ) {
 				for ( const { cell, column, colspan } of [ ...new TableWalker( table ) ] ) {
 					// If colspaned cell overlaps removed column decrease its span.
@@ -372,11 +401,13 @@ export default class TableUtils extends Plugin {
 						// If the cell was the last one in the row, get rid of the entire row.
 						// https://github.com/ckeditor/ckeditor5/issues/6429
 						if ( !cellRow.childCount ) {
-							this.removeRows( table, { at: cellRow.index } );
+							emptyRowsIndexes.push( cellRow.index );
 						}
 					}
 				}
 			}
+
+			emptyRowsIndexes.reverse().forEach( row => this.removeRows( table, { at: row, batch: writer.batch } ) );
 		} );
 	}
 

+ 54 - 0
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -232,6 +232,33 @@ describe( 'InsertRowCommand', () => {
 					[ '', '' ]
 				] ) );
 			} );
+
+			it( 'should copy the row structure from the selected row', () => {
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				setData( model, modelTable( [
+					[ '[]00', { contents: '01', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+
+				command.execute();
+
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// |    |         |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2 } ],
+					[ '', { contents: '', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+			} );
 		} );
 	} );
 
@@ -367,6 +394,33 @@ describe( 'InsertRowCommand', () => {
 					[ 0, 0 ]
 				] );
 			} );
+
+			it( 'should copy the row structure from the selected row', () => {
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				setData( model, modelTable( [
+					[ '[]00', { contents: '01', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+
+				command.execute();
+
+				// +----+----+----+
+				// |    |         |
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '', { contents: '', colspan: 2 } ],
+					[ '00', { contents: '01', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+			} );
 		} );
 	} );
 } );

+ 96 - 0
packages/ckeditor5-table/tests/tableutils.js

@@ -219,6 +219,88 @@ describe( 'TableUtils', () => {
 				[ '', '' ]
 			] ) );
 		} );
+
+		describe( 'with copyStructureFrom enabled', () => {
+			beforeEach( () => {
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				setData( model, modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '03', rowspan: 2 }, '04', '05' ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the first row', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 0, rows: 1, copyStructureFromAbove: false } );
+
+				// +----+----+----+----+----+----+
+				// |    |         |    |    |    |
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '', { contents: '', colspan: 2 }, '', '', '' ],
+					[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '03', rowspan: 2 }, '04', '05' ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the first row and properly handle row-spanned cells', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1, rows: 1, copyStructureFromAbove: true } );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// |    |         |    |    |    |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 3 }, { contents: '03', rowspan: 3 }, '04', '05' ],
+					[ '', '', '' ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the last row', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 1, copyStructureFromAbove: true } );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				// |    |         |    |         |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '03', rowspan: 2 }, '04', '05' ],
+					[ '10', { contents: '14', colspan: 2 } ],
+					[ '', { contents: '', colspan: 2 }, '', { contents: '', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the last row and properly handle row-spanned cells', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1, rows: 1, copyStructureFromAbove: false } );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// |    |         |    |         |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 3 }, { contents: '03', rowspan: 3 }, '04', '05' ],
+					[ '', { contents: '', colspan: 2 } ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+		} );
 	} );
 
 	describe( 'insertColumns()', () => {
@@ -1368,6 +1450,20 @@ describe( 'TableUtils', () => {
 					[ '21', '22' ]
 				] ) );
 			} );
+
+			it( 'should remove the column properly when multiple rows should be removed (because of to row-spans)', () => {
+				setData( model, modelTable( [
+					[ '00', { contents: '01', rowspan: 3 }, { contents: '02', rowspan: 3 } ],
+					[ '10' ],
+					[ '20' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01', '02' ]
+				] ) );
+			} );
 		} );
 
 		describe( 'multiple columns', () => {

+ 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';
@@ -38,6 +39,13 @@ export default class Widget extends Plugin {
 		return 'Widget';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ WidgetTypeAround ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */

+ 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() );
+	}
+}

TEMPAT SAMPAH
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

+ 0 - 102
scripts/continuous-integration-run-tests.sh

@@ -1,102 +0,0 @@
-#!/bin/bash
-
-# @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
-# For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
-
-packages=$(ls packages -1 | sed -e 's#^ckeditor5\?-\(.\+\)$#\1#')
-
-errorOccured=0
-
-rm -r -f .nyc_output
-mkdir .nyc_output
-
-failedTestsPackages=""
-failedCoveragePackages=""
-
-RED='\033[0;31m'
-NC='\033[0m'
-
-# Travis functions inspired by https://github.com/travis-ci/travis-rubies/blob/a10ba31e3f508650204017332a608ef9bce2c733/build.sh.
-function travis_nanoseconds() {
-  local cmd="date"
-  local format="+%s%N"
-  local os=$(uname)
-
-  if hash gdate > /dev/null 2>&1; then
-    cmd="gdate" # use gdate if available
-  elif [[ "$os" = Darwin ]]; then
-    format="+%s000000000" # fallback to second precision on darwin (does not support %N)
-  fi
-
-  $cmd -u $format
-}
-
-travis_time_start() {
-  travis_timer_id=$(printf %08x $(( RANDOM * RANDOM )))
-  travis_start_time=$(travis_nanoseconds)
-  echo -en "travis_time:start:$travis_timer_id\r${ANSI_CLEAR}"
-}
-
-travis_time_finish() {
-  local result=$?
-  travis_end_time=$(travis_nanoseconds)
-  local duration=$(($travis_end_time-$travis_start_time))
-  echo -en "\ntravis_time:end:$travis_timer_id:start=$travis_start_time,finish=$travis_end_time,duration=$duration\r${ANSI_CLEAR}"
-  return $result
-}
-
-
-fold_start() {
-  echo -e "travis_fold:start:$1\033[33;1m$2\033[0m"
-  travis_time_start
-}
-
-fold_end() {
-  travis_time_finish
-  echo -e "\ntravis_fold:end:$1\n"
-
-}
-
-for package in $packages; do
-
-  fold_start "pkg-$package" "Testing $package${NC}"
-
-  yarn run test -f $package --reporter=dots --production --coverage
-
-  if [ "$?" -ne "0" ]; then
-    echo
-
-    echo -e "💥 ${RED}$package${NC} failed to pass unit tests 💥"
-    failedTestsPackages="$failedTestsPackages $package"
-    errorOccured=1
-  fi
-
-  cp coverage/*/coverage-final.json .nyc_output
-
-  npx nyc check-coverage --branches 100 --functions 100 --lines 100 --statements 100
-
-  if [ "$?" -ne "0" ]; then
-    echo -e "💥 ${RED}$package${NC} doesn't have required code coverage 💥"
-    failedCoveragePackages="$failedCoveragePackages $package"
-    errorOccured=1
-  fi
-
-  fold_end "pkg-$package"
-done;
-
-if [ "$errorOccured" -eq "1" ]; then
-  echo
-  echo "---"
-  echo
-
-  if ! [[ -z $failedTestsPackages ]]; then
-    echo -e "Following packages did not pass unit tests:${RED}$failedTestsPackages${NC}"
-  fi
-
-  if ! [[ -z $failedCoveragePackages ]]; then
-    echo -e "Following packages did not provide required code coverage:${RED}$failedCoveragePackages${NC}"
-  fi
-
-  echo
-  exit 1 # Will break the CI build
-fi

+ 152 - 0
scripts/continuous-integration-script.js

@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* eslint-env node */
+
+'use strict';
+
+const childProcess = require( 'child_process' );
+const crypto = require( 'crypto' );
+const fs = require( 'fs' );
+const path = require( 'path' );
+const glob = require( 'glob' );
+
+const failedChecks = {
+	dependency: new Set(),
+	unitTests: new Set(),
+	codeCoverage: new Set()
+};
+
+const RED = '\x1B[0;31m';
+const YELLOW = '\x1B[33;1m';
+const NO_COLOR = '\x1B[0m';
+
+const travis = {
+	_lastTimerId: null,
+	_lastStartTime: null,
+
+	foldStart( packageName, foldLabel ) {
+		console.log( `travis_fold:start:${ packageName }${ YELLOW }${ foldLabel }${ NO_COLOR }` );
+		this._timeStart();
+	},
+
+	foldEnd( packageName ) {
+		this._timeFinish();
+		console.log( `\ntravis_fold:end:${ packageName }\n` );
+	},
+
+	_timeStart() {
+		const nanoSeconds = process.hrtime.bigint();
+
+		this._lastTimerId = crypto.createHash( 'md5' ).update( nanoSeconds.toString() ).digest( 'hex' );
+		this._lastStartTime = nanoSeconds;
+
+		// Intentional direct write to stdout, to manually control EOL.
+		process.stdout.write( `travis_time:start:${ this._lastTimerId }\r\n` );
+	},
+
+	_timeFinish() {
+		const travisEndTime = process.hrtime.bigint();
+		const duration = travisEndTime - this._lastStartTime;
+
+		// Intentional direct write to stdout, to manually control EOL.
+		process.stdout.write( `\ntravis_time:end:${ this._lastTimerId }:start=${ this._lastStartTime },` +
+			`finish=${ travisEndTime },duration=${ duration }\r\n` );
+	}
+};
+
+childProcess.execSync( 'rm -r -f .nyc_output' );
+childProcess.execSync( 'mkdir .nyc_output' );
+childProcess.execSync( 'rm -r -f .out' );
+childProcess.execSync( 'mkdir .out' );
+
+const packages = childProcess.execSync( 'ls packages -1', {
+	encoding: 'utf8'
+} ).toString().trim().split( '\n' );
+
+for ( const fullPackageName of packages ) {
+	const simplePackageName = fullPackageName.replace( /^ckeditor5?-/, '' );
+	const foldLabelName = 'pkg-' + simplePackageName;
+
+	travis.foldStart( foldLabelName, `Testing ${ fullPackageName }${ NO_COLOR }` );
+
+	appendCoverageReport();
+
+	runSubprocess( 'npx', [ 'ckeditor5-dev-tests-check-dependencies', `packages/${ fullPackageName }` ], simplePackageName, 'dependency',
+		'have a dependency problem' );
+
+	const testArguments = [ 'run', 'test', '-f', simplePackageName, '--reporter=dots', '--production', '--coverage' ];
+	runSubprocess( 'yarn', testArguments, simplePackageName, 'unitTests', 'failed to pass unit tests' );
+
+	childProcess.execSync( 'cp coverage/*/coverage-final.json .nyc_output' );
+
+	const nyc = [ 'nyc', 'check-coverage', '--branches', '100', '--functions', '100', '--lines', '100', '--statements', '100' ];
+	runSubprocess( 'npx', nyc, simplePackageName, 'codeCoverage', 'doesn\'t have required code coverage' );
+
+	travis.foldEnd( foldLabelName );
+}
+
+console.log( 'Uploading combined code coverage report…' );
+childProcess.execSync( 'npx coveralls < .out/combined_lcov.info' );
+console.log( 'Done' );
+
+if ( Object.values( failedChecks ).some( checksSet => checksSet.size > 0 ) ) {
+	console.log( '\n---\n' );
+
+	showFailedCheck( 'dependency', 'The following packages have dependencies that are not included in its package.json' );
+	showFailedCheck( 'unitTests', 'The following packages did not pass unit tests' );
+	showFailedCheck( 'codeCoverage', 'The following packages did not provide required code coverage' );
+
+	process.exit( 1 ); // Exit code 1 will break the CI build.
+}
+
+/*
+ * @param {String} binaryName - Name of a CLI binary to be called.
+ * @param {String[]} cliArguments - An array of arguments to be passed to the `binaryName`.
+ * @param {String} packageName - Checked package name.
+ * @param {String} checkName - A key associated with the problem in the `failedChecks` dictionary.
+ * @param {String} failMessage - Message to be shown if check failed.
+ */
+function runSubprocess( binaryName, cliArguments, packageName, checkName, failMessage ) {
+	const subprocess = childProcess.spawnSync( binaryName, cliArguments, {
+		encoding: 'utf8',
+		shell: true
+	} );
+
+	console.log( subprocess.stdout );
+
+	if ( subprocess.stderr ) {
+		console.log( subprocess.stderr );
+	}
+
+	if ( subprocess.status !== 0 ) {
+		failedChecks.unitTests.add( packageName );
+		console.log( `💥 ${ RED }${ packageName }${ NO_COLOR } ` + failMessage + ' 💥' );
+	}
+}
+
+function showFailedCheck( checkKey, errorMessage ) {
+	const failedPackages = failedChecks[ checkKey ];
+
+	if ( failedPackages.size ) {
+		console.log( `${ errorMessage }: ${ RED }${ Array.from( failedPackages.values() ).join( ', ' ) }${ NO_COLOR }` );
+	}
+}
+
+function appendCoverageReport() {
+	// Appends coverage data to the combined code coverage info file. It's used because all the results
+	// needs to be uploaded at once (#6742).
+	const matches = glob.sync( 'coverage/*/lcov.info' );
+
+	matches.forEach( filePath => {
+		const buffer = fs.readFileSync( filePath );
+
+		fs.writeFileSync( [ '.out', 'combined_lcov.info' ].join( path.sep ), buffer, {
+			flag: 'as'
+		} );
+	} );
+}

+ 4 - 1
scripts/docs/buildapi.js

@@ -18,7 +18,10 @@ module.exports = function buildApiDocs() {
 			sourceFiles: [
 				process.cwd() + '/packages/@(ckeditor|ckeditor5)-*/src/**/*.@(js|jsdoc)',
 				'!' + process.cwd() + '/packages/@(ckeditor|ckeditor5)-*/src/lib/**/*.js',
-				'!' + process.cwd() + '/packages/ckeditor5-build-*/src/**/*.js'
+				'!' + process.cwd() + '/packages/ckeditor5-build-*/src/**/*.js',
+				process.cwd() + '/external/*/packages/@(ckeditor|ckeditor5)-*/src/**/*.@(js|jsdoc)',
+				'!' + process.cwd() + '/external/*/packages/@(ckeditor|ckeditor5)-*/src/lib/**/*.js',
+				'!' + process.cwd() + '/external/*/packages/ckeditor5-build-*/src/**/*.js'
 			],
 			validateOnly: process.argv.includes( '--validate-only' )
 		} );

+ 49 - 28
yarn.lock

@@ -827,10 +827,10 @@
     request "^2.83.0"
     semver "^6.2.0"
 
-"@ckeditor/ckeditor5-dev-tests@^19.1.0":
-  version "19.1.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-tests/-/ckeditor5-dev-tests-19.1.0.tgz#0ab130a89c7e9c3c37a87ee4e138db096c9021ae"
-  integrity sha512-J/M58BefbA2GuYtj6U/gZVqNKbvh1YWZeyEeEmPFOeiVhudLry54XgRgw5wtZTLXjXabl37RilLZ/URXifXSFQ==
+"@ckeditor/ckeditor5-dev-tests@^19.2.0":
+  version "19.2.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-tests/-/ckeditor5-dev-tests-19.2.0.tgz#8cf0dda0ba1982834e3c09fa275db6f0805d6faf"
+  integrity sha512-7wPA9XvQCAXGs/kKtXQLgIw/+eVlVj//5zvql1DGStHRnFdZY3sSJCBBpZ5tPfxVUHgv4/cftaBOs3xgQQ+Tgw==
   dependencies:
     "@babel/core" "^7.0.0"
     "@ckeditor/ckeditor5-dev-utils" "^13.0.1"
@@ -2607,9 +2607,9 @@ caniuse-api@^3.0.0:
     lodash.uniq "^4.5.0"
 
 caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001043:
-  version "1.0.30001055"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001055.tgz#7b52c3537f7a8c0408aca867e83d2b04268b54cd"
-  integrity sha512-MbwsBmKrBSKIWldfdIagO5OJWZclpJtS4h0Jrk/4HFrXJxTdVdH23Fd+xCiHriVGvYcWyW8mR/CPsYajlH8Iuw==
+  version "1.0.30001058"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001058.tgz#9f8a318389e28f060272274ac93a661d17f8bf0d"
+  integrity sha512-UiRZmBYd1HdVVdFKy7PuLVx9e2NS7SMyx7QpWvFjiklYrLJKpLd19cRnRNqlw4zYa7vVejS3c8JUVobX241zHQ==
 
 caseless@~0.12.0:
   version "0.12.0"
@@ -3365,6 +3365,17 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.0.2, cosmiconfig@^5.0.7, cosmiconfig@^5.2.0:
     js-yaml "^3.13.1"
     parse-json "^4.0.0"
 
+coveralls@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.1.0.tgz#13c754d5e7a2dd8b44fe5269e21ca394fb4d615b"
+  integrity sha512-sHxOu2ELzW8/NC1UP5XVLbZDzO4S3VxfFye3XYCznopHy02YjNkHcj5bKaVw2O7hVaBdBjEdQGpie4II1mWhuQ==
+  dependencies:
+    js-yaml "^3.13.1"
+    lcov-parse "^1.0.0"
+    log-driver "^1.2.7"
+    minimist "^1.2.5"
+    request "^2.88.2"
+
 cpx@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/cpx/-/cpx-1.5.0.tgz#185be018511d87270dedccc293171e37655ab88f"
@@ -4127,9 +4138,9 @@ ee-first@1.1.1:
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
 electron-to-chromium@^1.3.413:
-  version "1.3.435"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.435.tgz#22a7008e8f5a317a6d2d80802bddacebb19ae025"
-  integrity sha512-BVXnq+NCefidU7GOFPx4CPBfPcccLCRBKZYSbvBJMSn2kwGD7ML+eUA9tqfHAumRqy3oX5zaeTI1Bpt7qVat0Q==
+  version "1.3.437"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.437.tgz#110f1cd407e5d09b43d5585e5f237b71063412cf"
+  integrity sha512-PBQn2q68ErqMyBUABh9Gh8R6DunGky8aB5y3N5lPM7OVpldwyUbAK5AX9WcwE/5F6ceqvQ+iQLYkJYRysAs6Bg==
 
 elegant-spinner@^1.0.1:
   version "1.0.1"
@@ -6839,7 +6850,7 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-"jsdoc@github:ckeditor/jsdoc#node12":
+jsdoc@ckeditor/jsdoc#node12:
   version "3.4.3"
   resolved "https://codeload.github.com/ckeditor/jsdoc/tar.gz/e6c213ab53c20457d2778953a6d3fc54311f9a67"
   dependencies:
@@ -7129,6 +7140,11 @@ lcid@^2.0.0:
   dependencies:
     invert-kv "^2.0.0"
 
+lcov-parse@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0"
+  integrity sha1-6w1GtUER68VhrLTECO+TY73I9+A=
+
 lead@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42"
@@ -7446,6 +7462,11 @@ lodash@^4.0.0, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
   integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
 
+log-driver@^1.2.7:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8"
+  integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==
+
 log-symbols@2.2.0, log-symbols@^2.1.0, log-symbols@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
@@ -8028,9 +8049,9 @@ minipass-flush@^1.0.5:
     minipass "^3.0.0"
 
 minipass-pipeline@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.2.tgz#3dcb6bb4a546e32969c7ad710f2c79a86abba93a"
-  integrity sha512-3JS5A2DKhD2g0Gg8x3yamO0pj7YeKGwVlDS90pF++kxptwx/F+B//roxf9SqYil5tQo65bijy+dAuAFZmYOouA==
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.3.tgz#55f7839307d74859d6e8ada9c3ebe72cec216a34"
+  integrity sha512-cFOknTvng5vqnwOpDsZTWhNll6Jf8o2x+/diplafmxpuIymAjzoOolZG0VvQf3V2HgqzJNhnuKHYp2BqDgz8IQ==
   dependencies:
     minipass "^3.0.0"
 
@@ -10303,7 +10324,7 @@ replace-ext@^1.0.0:
   resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
   integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
 
-request@^2.51.0, request@^2.83.0, request@^2.87.0, request@^2.88.0:
+request@^2.51.0, request@^2.83.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
   version "2.88.2"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
   integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -10992,9 +11013,9 @@ spdx-exceptions@^2.1.0:
   integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 spdx-expression-parse@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
-  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
   dependencies:
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
@@ -11812,9 +11833,9 @@ trough@^1.0.0:
     glob "^7.1.2"
 
 tslib@^1.9.0:
-  version "1.12.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.12.0.tgz#d1fc9cacd06a1456c62f2902b361573e83d66473"
-  integrity sha512-5rxCQkP0kytf4H1T4xz1imjxaUUPMvc5aWp0rJ/VMIN7ClRiH1FwFvBt8wOeMasp/epeUnmSW6CixSIePtiLqA==
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
 tty-browserify@0.0.0:
   version "0.0.0"
@@ -11921,9 +11942,9 @@ uglify-js@^2.6.1:
     uglify-to-browserify "~1.0.0"
 
 uglify-js@^3.1.4:
-  version "3.9.2"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.9.2.tgz#012b74fb6a2e440d9ba1f79110a479d3b1f2d48d"
-  integrity sha512-zGVwKslUAD/EeqOrD1nQaBmXIHl1Vw371we8cvS8I6mYK9rmgX5tv8AAeJdfsQ3Kk5mGax2SVV/AizxdNGhl7Q==
+  version "3.9.3"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.9.3.tgz#4a285d1658b8a2ebaef9e51366b3a0f7acd79ec2"
+  integrity sha512-r5ImcL6QyzQGVimQoov3aL2ZScywrOgBXGndbWrdehKoSvGe/RmiE5Jpw/v+GvxODt6l2tpBXwA7n+qZVlHBMA==
   dependencies:
     commander "~2.20.3"
 
@@ -11937,10 +11958,10 @@ ultron@~1.1.0:
   resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
   integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==
 
-umberto@^1.2.0:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.2.4.tgz#7a540cb9bed77af3b96ae87ff45c11ecde85d0b4"
-  integrity sha512-ntdxZ2ADjca9sTpvNJuvKSzEr1u4Yhv6Rghtg86UC+k4x9zWCZCNi+Lvt6XglJBSVDPwvznWd7t1qxTXlhrp3w==
+umberto@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.3.0.tgz#36ab30cc683dc36967e674576ffad1177ce63ecd"
+  integrity sha512-M4nDYX3qqxIik04DGgU0lZAgD+nTJs46GrX3PIaaYyNm8UGDOA2BPHIRQv5ICr8eu55h6o0SG1aEXyaL2dvB8w==
   dependencies:
     "@babel/core" "^7.1.2"
     "@babel/polyfill" "^7.0.0"