浏览代码

Merge branch 'master' into i/4721

Aleksander Nowodzinski 5 年之前
父节点
当前提交
1255276ca5

+ 0 - 1
package.json

@@ -146,7 +146,6 @@
     "postchangelog": "node ./scripts/release/update-utils-version.js",
     "release:bump-version": "node ./scripts/release/bump-versions.js",
     "release:publish": "node ./scripts/release/publish.js",
-    "release:stable-branches": "bash ./scripts/update-stable-branches.sh",
     "switch-to-dev-dev": "sh ./scripts/switch-to-dev-dev.sh",
     "clean-up-svg-icons": "sh ./scripts/clean-up-svg-icons.sh"
   },

+ 9 - 6
packages/ckeditor5-engine/src/model/model.js

@@ -551,24 +551,27 @@ export default class Model {
 	 * @param {module:engine/model/range~Range|module:engine/model/element~Element} rangeOrElement Range or element to check.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.ignoreWhitespaces] Whether text node with whitespaces only should be considered empty.
+	 * @param {Boolean} [options.ignoreMarkers] Whether markers should be ignored.
 	 * @returns {Boolean}
 	 */
-	hasContent( rangeOrElement, options ) {
+	hasContent( rangeOrElement, options = {} ) {
 		const range = rangeOrElement instanceof ModelElement ? ModelRange._createIn( rangeOrElement ) : rangeOrElement;
 
 		if ( range.isCollapsed ) {
 			return false;
 		}
 
+		const { ignoreWhitespaces = false, ignoreMarkers = false } = options;
+
 		// Check if there are any markers which affects data in this given range.
-		for ( const intersectingMarker of this.markers.getMarkersIntersectingRange( range ) ) {
-			if ( intersectingMarker.affectsData ) {
-				return true;
+		if ( !ignoreMarkers ) {
+			for ( const intersectingMarker of this.markers.getMarkersIntersectingRange( range ) ) {
+				if ( intersectingMarker.affectsData ) {
+					return true;
+				}
 			}
 		}
 
-		const { ignoreWhitespaces = false } = options || {};
-
 		for ( const item of range.getItems() ) {
 			if ( item.is( 'textProxy' ) ) {
 				if ( !ignoreWhitespaces ) {

+ 2 - 2
packages/ckeditor5-engine/src/model/utils/deletecontent.js

@@ -137,7 +137,7 @@ function getLivePositionsForSelectedBlocks( range ) {
 
 	// If the end of selection is at the start position of last block in the selection, then
 	// shrink it to not include that trailing block. Note that this should happen only for not empty selection.
-	if ( model.hasContent( range ) ) {
+	if ( model.hasContent( range, { ignoreMarkers: true } ) ) {
 		const endBlock = getParentBlock( endPosition );
 
 		if ( endBlock && endPosition.isTouching( model.createPositionAt( endBlock, 0 ) ) ) {
@@ -213,7 +213,7 @@ function mergeBranches( writer, startPosition, endPosition ) {
 	// Merging should not go deeper than common ancestor.
 	const [ startAncestor, endAncestor ] = getAncestorsJustBelowCommonAncestor( startPosition, endPosition );
 
-	if ( !model.hasContent( startAncestor ) && model.hasContent( endAncestor ) ) {
+	if ( !model.hasContent( startAncestor, { ignoreMarkers: true } ) && model.hasContent( endAncestor, { ignoreMarkers: true } ) ) {
 		mergeBranchesRight( writer, startPosition, endPosition, startAncestor.parent );
 	} else {
 		mergeBranchesLeft( writer, startPosition, endPosition, startAncestor.parent );

+ 1 - 0
packages/ckeditor5-engine/src/view/renderer.js

@@ -930,6 +930,7 @@ function addInlineFiller( domDocument, domParentOrArray, offset ) {
 function areSimilar( node1, node2 ) {
 	return isNode( node1 ) && isNode( node2 ) &&
 		!isText( node1 ) && !isText( node2 ) &&
+		node1.nodeType !== Node.COMMENT_NODE && node2.nodeType !== Node.COMMENT_NODE &&
 		node1.tagName.toLowerCase() === node2.tagName.toLowerCase();
 }
 

+ 13 - 0
packages/ckeditor5-engine/tests/model/model.js

@@ -659,6 +659,8 @@ describe( 'Model', () => {
 
 			expect( model.hasContent( pEmpty ) ).to.be.false;
 			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
 
 		it( 'should return false for empty element with marker (usingOperation=true, affectsData=false)', () => {
@@ -672,6 +674,8 @@ describe( 'Model', () => {
 
 			expect( model.hasContent( pEmpty ) ).to.be.false;
 			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
 
 		it( 'should return false (ignoreWhitespaces) for empty text with marker (usingOperation=false, affectsData=false)', () => {
@@ -688,6 +692,7 @@ describe( 'Model', () => {
 			} );
 
 			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true, ignoreMarkers: true } ) ).to.be.false;
 		} );
 
 		it( 'should return true for empty text with marker (usingOperation=false, affectsData=false)', () => {
@@ -704,6 +709,8 @@ describe( 'Model', () => {
 			} );
 
 			expect( model.hasContent( pEmpty ) ).to.be.true;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.true;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
 
 		it( 'should return false for empty element with marker (usingOperation=false, affectsData=true)', () => {
@@ -717,6 +724,8 @@ describe( 'Model', () => {
 
 			expect( model.hasContent( pEmpty ) ).to.be.false;
 			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
 
 		it( 'should return false for empty element with marker (usingOperation=true, affectsData=true)', () => {
@@ -730,6 +739,8 @@ describe( 'Model', () => {
 
 			expect( model.hasContent( pEmpty ) ).to.be.false;
 			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.false;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
 
 		it( 'should return true (ignoreWhitespaces) for empty text with marker (usingOperation=false, affectsData=true)', () => {
@@ -747,6 +758,8 @@ describe( 'Model', () => {
 
 			expect( model.hasContent( pEmpty ) ).to.be.true;
 			expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.true;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true } ) ).to.be.true;
+			expect( model.hasContent( pEmpty, { ignoreMarkers: true, ignoreWhitespaces: true } ) ).to.be.false;
 		} );
 	} );
 

+ 53 - 0
packages/ckeditor5-engine/tests/model/utils/deletecontent.js

@@ -661,6 +661,59 @@ describe( 'DataController utils', () => {
 				);
 			} );
 
+			describe( 'with markers', () => {
+				it( 'should merge left if the first element is not empty', () => {
+					setData( model, '<heading1>foo[</heading1><paragraph>]bar</paragraph>' );
+
+					model.enqueueChange( 'transparent', writer => {
+						const root = doc.getRoot( );
+						const range = writer.createRange(
+							writer.createPositionFromPath( root, [ 0, 3 ] ),
+							writer.createPositionFromPath( root, [ 1, 0 ] )
+						);
+						writer.addMarker( 'comment1', { range, usingOperation: true, affectsData: true } );
+					} );
+
+					deleteContent( model, doc.selection );
+
+					expect( getData( model ) ).to.equal( '<heading1>foo[]bar</heading1>' );
+				} );
+
+				it( 'should merge right if the first element is empty', () => {
+					setData( model, '<heading1>[</heading1><paragraph>]bar</paragraph>' );
+
+					model.enqueueChange( 'transparent', writer => {
+						const root = doc.getRoot( );
+						const range = writer.createRange(
+							writer.createPositionFromPath( root, [ 0, 0 ] ),
+							writer.createPositionFromPath( root, [ 1, 0 ] )
+						);
+						writer.addMarker( 'comment1', { range, usingOperation: true, affectsData: true } );
+					} );
+
+					deleteContent( model, doc.selection );
+
+					expect( getData( model ) ).to.equal( '<paragraph>[]bar</paragraph>' );
+				} );
+
+				it( 'should merge left if the last element is empty', () => {
+					setData( model, '<heading1>foo[</heading1><paragraph>]</paragraph>' );
+
+					model.enqueueChange( 'transparent', writer => {
+						const root = doc.getRoot( );
+						const range = writer.createRange(
+							writer.createPositionFromPath( root, [ 0, 3 ] ),
+							writer.createPositionFromPath( root, [ 1, 0 ] )
+						);
+						writer.addMarker( 'comment1', { range, usingOperation: true, affectsData: true } );
+					} );
+
+					deleteContent( model, doc.selection );
+
+					expect( getData( model ) ).to.equal( '<heading1>foo[]</heading1>' );
+				} );
+			} );
+
 			describe( 'filtering out', () => {
 				beforeEach( () => {
 					const schema = model.schema;

+ 100 - 1
packages/ckeditor5-engine/tests/view/renderer.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals document, window, NodeFilter, MutationObserver */
+/* globals document, window, NodeFilter, MutationObserver, HTMLImageElement */
 
 import View from '../../src/view/view';
 import ViewElement from '../../src/view/element';
@@ -305,6 +305,33 @@ describe( 'Renderer', () => {
 			expect( domRoot.childNodes[ 0 ] ).to.equal( domImg );
 		} );
 
+		it( 'should remove any comment from the DOM element', () => {
+			// This comment should be cleared during render.
+			const domComment = document.createComment( 'some comment from the user-land' );
+			domRoot.appendChild( domComment );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 0 );
+		} );
+
+		// https://github.com/ckeditor/ckeditor5/issues/5734
+		it( 'should remove the comment and add a child element', () => {
+			const viewImg = new ViewElement( viewDocument, 'img' );
+			viewRoot._appendChild( viewImg );
+
+			// This comment should be cleared during render.
+			const domComment = document.createComment( 'some comment from the user-land' );
+			domRoot.appendChild( domComment );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ] ).to.be.an.instanceof( HTMLImageElement );
+		} );
+
 		it( 'should change element if it is different', () => {
 			const viewImg = new ViewElement( viewDocument, 'img' );
 			viewRoot._appendChild( viewImg );
@@ -2706,6 +2733,78 @@ describe( 'Renderer', () => {
 				expect( mappings.get( domQULB ) ).to.equal( viewQ.getChild( 0 ).getChild( 0 ).getChild( 1 ) );
 			} );
 
+			// https://github.com/ckeditor/ckeditor5/issues/5734
+			it( 'should not rerender DOM when view replaced with the same structure without a comment', () => {
+				const domContent = '' +
+					'<h2>He<i>ading 1</i></h2>' +
+					'<p>Ph <strong>Bold</strong>' +
+						'<a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a>' +
+					'</p>' +
+					'<!-- A comment to be ignored -->' +
+					'<blockquote><ul><li>Quoted <strong>item 1</strong></li></ul></blockquote>';
+				const content = '' +
+					'<container:h2>He' +
+						'<attribute:i>ading 1</attribute:i>' +
+					'</container:h2>' +
+					'<container:p>Ph ' +
+						'<attribute:strong>Bold</attribute:strong>' +
+						'<attribute:a href="https://ckeditor.com">' +
+							'<attribute:strong>Lin<attribute:i>k</attribute:i></attribute:strong>' +
+						'</attribute:a>' +
+					'</container:p>' +
+					'<container:blockquote>' +
+						'<container:ul>' +
+							'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+						'</container:ul>' +
+					'</container:blockquote>';
+
+				domRoot.innerHTML = domContent;
+				viewRoot._appendChild( parse( content ) );
+
+				const viewH = viewRoot.getChild( 0 );
+				const viewP = viewRoot.getChild( 1 );
+				const viewQ = viewRoot.getChild( 2 );
+
+				const domH = domRoot.childNodes[ 0 ];
+				const domHI = domH.childNodes[ 1 ];
+				const domP = domRoot.childNodes[ 1 ];
+				const domPT = domP.childNodes[ 0 ];
+				const domPABI = domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ];
+				const domC = domRoot.childNodes[ 2 ];
+				const domQ = domRoot.childNodes[ 3 ];
+				const domQULB = domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				// Assert the comment is no longer in the content.
+				expect( domRoot.contains( domC ), 'domRoot should not contain the comment' ).to.be.false;
+
+				// Assert content, without the comment.
+				expect( domRoot.innerHTML ).to.equal( '<h2>He<i>ading 1</i></h2><p>Ph <strong>Bold</strong>' +
+					'<a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a></p><blockquote><ul><li>' +
+					'Quoted <strong>item 1</strong></li></ul></blockquote>' );
+
+				// Assert if other DOM elements did not change.
+				expect( domRoot.childNodes[ 0 ] ).to.equal( domH );
+				expect( domH.childNodes[ 1 ] ).to.equal( domHI );
+				expect( domRoot.childNodes[ 1 ] ).to.equal( domP );
+				expect( domP.childNodes[ 0 ] ).to.equal( domPT );
+				expect( domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domPABI );
+				// Note the shifted index of domQ, from 3 to 2.
+				expect( domRoot.childNodes[ 2 ] ).to.equal( domQ );
+				expect( domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domQULB );
+
+				// Assert mappings.
+				const mappings = renderer.domConverter._domToViewMapping;
+				expect( mappings.get( domH ) ).to.equal( viewH );
+				expect( mappings.get( domHI ) ).to.equal( viewH.getChild( 1 ) );
+				expect( mappings.get( domP ) ).to.equal( viewP );
+				expect( mappings.get( domPABI ) ).to.equal( viewP.getChild( 2 ).getChild( 0 ).getChild( 1 ) );
+				expect( mappings.get( domQ ) ).to.equal( viewQ );
+				expect( mappings.get( domQULB ) ).to.equal( viewQ.getChild( 0 ).getChild( 0 ).getChild( 1 ) );
+			} );
+
 			it( 'should not rerender DOM when view replaced with the same structure without first node', () => {
 				const content = '' +
 					'<container:h2>He' +

+ 2 - 2
packages/ckeditor5-link/package.json

@@ -13,6 +13,7 @@
     "@ckeditor/ckeditor5-core": "^19.0.1",
     "@ckeditor/ckeditor5-engine": "^19.0.1",
     "@ckeditor/ckeditor5-ui": "^19.0.1",
+    "@ckeditor/ckeditor5-utils": "^19.0.1",
     "lodash-es": "^4.17.15"
   },
   "devDependencies": {
@@ -25,8 +26,7 @@
     "@ckeditor/ckeditor5-paragraph": "^19.1.0",
     "@ckeditor/ckeditor5-theme-lark": "^19.1.0",
     "@ckeditor/ckeditor5-typing": "^19.0.1",
-    "@ckeditor/ckeditor5-undo": "^19.0.1",
-    "@ckeditor/ckeditor5-utils": "^19.0.1"
+    "@ckeditor/ckeditor5-undo": "^19.0.1"
   },
   "engines": {
     "node": ">=12.0.0",

+ 12 - 3
packages/ckeditor5-link/src/linkcommand.js

@@ -11,6 +11,7 @@ import Command from '@ckeditor/ckeditor5-core/src/command';
 import findLinkRange from './findlinkrange';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 /**
  * The link command. It is used by the {@link module:link/link~Link link feature}.
@@ -57,13 +58,21 @@ export default class LinkCommand extends Command {
 		const model = this.editor.model;
 		const doc = model.document;
 
-		this.value = doc.selection.getAttribute( 'linkHref' );
+		const selectedElement = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429.
+		if ( selectedElement && selectedElement.is( 'image' ) ) {
+			this.value = selectedElement.getAttribute( 'linkHref' );
+			this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' );
+		} else {
+			this.value = doc.selection.getAttribute( 'linkHref' );
+			this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
+		}
 
 		for ( const manualDecorator of this.manualDecorators ) {
 			manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
 		}
-
-		this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
 	}
 
 	/**

+ 13 - 1
packages/ckeditor5-link/src/unlinkcommand.js

@@ -9,6 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import findLinkRange from './findlinkrange';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 /**
  * The unlink command. It is used by the {@link module:link/link~Link link plugin}.
@@ -20,7 +21,18 @@ export default class UnlinkCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		this.isEnabled = this.editor.model.document.selection.hasAttribute( 'linkHref' );
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const selectedImage = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `LinkImage` plugin. If the selection contains an image element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429.
+		if ( selectedImage && selectedImage.name === 'image' ) {
+			this.isEnabled = model.schema.checkAttribute( selectedImage, 'linkHref' );
+		} else {
+			this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
+		}
 	}
 
 	/**

+ 101 - 18
packages/ckeditor5-link/tests/linkcommand.js

@@ -67,6 +67,56 @@ describe( 'LinkCommand', () => {
 				setData( model, '<x>[foo]</x>' );
 				expect( command.isEnabled ).to.be.false;
 			} );
+
+			describe( 'for images', () => {
+				beforeEach( () => {
+					model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				} );
+
+				it( 'should be true when an image is selected', () => {
+					setData( model, '[<image linkHref="foo"></image>]' );
+
+					expect( command.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be true when an image and a text are selected', () => {
+					setData( model, '[<image linkHref="foo"></image>Foo]' );
+
+					expect( command.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be true when a text and an image are selected', () => {
+					setData( model, '[Foo<image linkHref="foo"></image>]' );
+
+					expect( command.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be true when two images are selected', () => {
+					setData( model, '[<image linkHref="foo"></image><image linkHref="foo"></image>]' );
+
+					expect( command.isEnabled ).to.be.true;
+				} );
+
+				it( 'should be false when a fake image is selected', () => {
+					model.schema.register( 'fake', { isBlock: true, allowWhere: '$text' } );
+
+					setData( model, '[<fake></fake>]' );
+
+					expect( command.isEnabled ).to.be.false;
+				} );
+
+				it( 'should be false if an image does not accept the `linkHref` attribute in given context', () => {
+					model.schema.addAttributeCheck( ( ctx, attributeName ) => {
+						if ( ctx.endsWith( '$root image' ) && attributeName == 'linkHref' ) {
+							return false;
+						}
+					} );
+
+					setData( model, '[<image></image>]' );
+
+					expect( command.isEnabled ).to.be.false;
+				} );
+			} );
 		} );
 	} );
 
@@ -98,6 +148,38 @@ describe( 'LinkCommand', () => {
 				expect( command.value ).to.be.undefined;
 			} );
 		} );
+
+		describe( 'for images', () => {
+			beforeEach( () => {
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+			} );
+
+			it( 'should read the value from a selected image', () => {
+				setData( model, '[<image linkHref="foo"></image>]' );
+
+				expect( command.value ).to.be.equal( 'foo' );
+			} );
+
+			it( 'should read the value from a selected image and ignore a text node', () => {
+				setData( model, '[<image linkHref="foo"></image><p><$text linkHref="bar">bar</$text>]</p>' );
+
+				expect( command.value ).to.be.equal( 'foo' );
+			} );
+
+			it( 'should read the value from a selected text node and ignore an image', () => {
+				setData( model, '<p>[<$text linkHref="bar">bar</$text></p><image linkHref="foo"></image>]' );
+
+				expect( command.value ).to.be.equal( 'bar' );
+			} );
+
+			it( 'should be undefined when a fake image is selected', () => {
+				model.schema.register( 'fake', { isBlock: true, allowWhere: '$text' } );
+
+				setData( model, '[<fake></fake>]' );
+
+				expect( command.value ).to.be.undefined;
+			} );
+		} );
 	} );
 
 	describe( 'execute()', () => {
@@ -194,73 +276,74 @@ describe( 'LinkCommand', () => {
 			} );
 
 			it( 'should set `linkHref` attribute to allowed elements', () => {
-				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
 
-				setData( model, '<p>f[oo<img></img>ba]r</p>' );
+				setData( model, '<p>f[oo<image></image>ba]r</p>' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
-				expect( getData( model ) )
-					.to.equal( '<p>f[<$text linkHref="url">oo</$text><img linkHref="url"></img><$text linkHref="url">ba</$text>]r</p>' );
+				expect( getData( model ) ).to.equal(
+					'<p>f[<$text linkHref="url">oo</$text><image linkHref="url"></image><$text linkHref="url">ba</$text>]r</p>'
+				);
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should set `linkHref` attribute to nested allowed elements', () => {
-				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
 				model.schema.register( 'blockQuote', { allowWhere: '$block', allowContentOf: '$root' } );
 
-				setData( model, '<p>foo</p>[<blockQuote><img></img></blockQuote>]<p>bar</p>' );
+				setData( model, '<p>foo</p>[<blockQuote><image></image></blockQuote>]<p>bar</p>' );
 
 				command.execute( 'url' );
 
 				expect( getData( model ) )
-					.to.equal( '<p>foo</p>[<blockQuote><img linkHref="url"></img></blockQuote>]<p>bar</p>' );
+					.to.equal( '<p>foo</p>[<blockQuote><image linkHref="url"></image></blockQuote>]<p>bar</p>' );
 			} );
 
 			it( 'should set `linkHref` attribute to allowed elements on multi-selection', () => {
-				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
 
-				setData( model, '<p>[<img></img>][<img></img>]</p>' );
+				setData( model, '<p>[<image></image>][<image></image>]</p>' );
 
 				command.execute( 'url' );
 
 				expect( getData( model ) )
-					.to.equal( '<p>[<img linkHref="url"></img>][<img linkHref="url"></img>]</p>' );
+					.to.equal( '<p>[<image linkHref="url"></image>][<image linkHref="url"></image>]</p>' );
 			} );
 
 			it( 'should set `linkHref` attribute to allowed elements and omit disallowed', () => {
-				model.schema.register( 'img', { isBlock: true, allowWhere: '$text' } );
-				model.schema.register( 'caption', { allowIn: 'img' } );
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text' } );
+				model.schema.register( 'caption', { allowIn: 'image' } );
 				model.schema.extend( '$text', { allowIn: 'caption' } );
 
-				setData( model, '<p>f[oo<img><caption>xxx</caption></img>ba]r</p>' );
+				setData( model, '<p>f[oo<image><caption>xxx</caption></image>ba]r</p>' );
 
 				command.execute( 'url' );
 
 				expect( getData( model ) ).to.equal(
 					'<p>' +
 						'f[<$text linkHref="url">oo</$text>' +
-						'<img><caption><$text linkHref="url">xxx</$text></caption></img>' +
+						'<image><caption><$text linkHref="url">xxx</$text></caption></image>' +
 						'<$text linkHref="url">ba</$text>]r' +
 					'</p>'
 				);
 			} );
 
 			it( 'should set `linkHref` attribute to allowed elements and omit their children even if they accept the attribute', () => {
-				model.schema.register( 'img', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
-				model.schema.register( 'caption', { allowIn: 'img' } );
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+				model.schema.register( 'caption', { allowIn: 'image' } );
 				model.schema.extend( '$text', { allowIn: 'caption' } );
 
-				setData( model, '<p>f[oo<img><caption>xxx</caption></img>ba]r</p>' );
+				setData( model, '<p>f[oo<image><caption>xxx</caption></image>ba]r</p>' );
 
 				command.execute( 'url' );
 
 				expect( getData( model ) ).to.equal(
 					'<p>' +
 						'f[<$text linkHref="url">oo</$text>' +
-						'<img linkHref="url"><caption>xxx</caption></img>' +
+						'<image linkHref="url"><caption>xxx</caption></image>' +
 						'<$text linkHref="url">ba</$text>]r' +
 					'</p>'
 				);

+ 50 - 0
packages/ckeditor5-link/tests/unlinkcommand.js

@@ -51,6 +51,56 @@ describe( 'UnlinkCommand', () => {
 
 			expect( command.isEnabled ).to.false;
 		} );
+
+		describe( 'for images', () => {
+			beforeEach( () => {
+				model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } );
+			} );
+
+			it( 'should be true when an image is selected', () => {
+				setData( model, '[<image linkHref="foo"></image>]' );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be true when an image and a text are selected', () => {
+				setData( model, '[<image linkHref="foo"></image>Foo]' );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be true when a text and an image are selected', () => {
+				setData( model, '[Foo<image linkHref="foo"></image>]' );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be true when two images are selected', () => {
+				setData( model, '[<image linkHref="foo"></image><image linkHref="foo"></image>]' );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false when a fake image is selected', () => {
+				model.schema.register( 'fake', { isBlock: true, allowWhere: '$text' } );
+
+				setData( model, '[<fake></fake>]' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if an image does not accept the `linkHref` attribute in given context', () => {
+				model.schema.addAttributeCheck( ( ctx, attributeName ) => {
+					if ( ctx.endsWith( '$root image' ) && attributeName == 'linkHref' ) {
+						return false;
+					}
+				} );
+
+				setData( model, '[<image></image>]' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
 	} );
 
 	describe( 'execute()', () => {

+ 64 - 19
packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js

@@ -87,6 +87,7 @@ export default class WidgetTypeAround extends Plugin {
 		this._enableInsertingParagraphsOnTypingKeystroke();
 		this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows();
 		this._enableDeleteIntegration();
+		this._enableInsertContentIntegration();
 	}
 
 	/**
@@ -109,16 +110,9 @@ export default class WidgetTypeAround extends Plugin {
 	_insertParagraph( widgetModelElement, position ) {
 		const editor = this.editor;
 		const editingView = editor.editing.view;
-		let modelPosition;
-
-		if ( position === 'before' ) {
-			modelPosition = editor.model.createPositionBefore( widgetModelElement );
-		} else {
-			modelPosition = editor.model.createPositionAfter( widgetModelElement );
-		}
 
 		editor.execute( 'insertParagraph', {
-			position: modelPosition
+			position: editor.model.createPositionAt( widgetModelElement, position )
 		} );
 
 		editingView.focus();
@@ -235,14 +229,26 @@ export default class WidgetTypeAround extends Plugin {
 				return;
 			}
 
-			const typeAroundSelectionAttribute = modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE );
+			// Get rid of the widget type around attribute of the selection on every change:range.
+			// If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
+			editor.model.change( writer => {
+				writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE );
+			} );
+		} );
 
-			if ( !typeAroundSelectionAttribute ) {
-				return;
+		// Get rid of the widget type around attribute of the selection on every document change
+		// that makes widget not selected any more (i.e. widget was removed).
+		model.document.on( 'change:data', () => {
+			const selectedModelElement = modelSelection.getSelectedElement();
+
+			if ( selectedModelElement ) {
+				const selectedViewElement = editor.editing.mapper.toViewElement( selectedModelElement );
+
+				if ( isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) {
+					return;
+				}
 			}
 
-			// Get rid of the widget type around attribute of the selection on every change:range.
-			// If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode.
 			editor.model.change( writer => {
 				writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE );
 			} );
@@ -362,9 +368,8 @@ export default class WidgetTypeAround extends Plugin {
 		const model = editor.model;
 		const modelSelection = model.document.selection;
 		const typeAroundSelectionAttribute = modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE );
-		let shouldStopAndPreventDefault = false;
 
-		model.change( writer => {
+		return model.change( writer => {
 			// If the selection already has the attribute...
 			if ( typeAroundSelectionAttribute ) {
 				const isLeavingWidget = typeAroundSelectionAttribute === ( isForward ? 'after' : 'before' );
@@ -380,7 +385,7 @@ export default class WidgetTypeAround extends Plugin {
 				if ( !isLeavingWidget ) {
 					writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE );
 
-					shouldStopAndPreventDefault = true;
+					return true;
 				}
 			}
 			// If the selection didn't have the attribute, let's set it now according to the direction of the arrow
@@ -388,11 +393,11 @@ export default class WidgetTypeAround extends Plugin {
 			else {
 				writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before' );
 
-				shouldStopAndPreventDefault = true;
+				return true;
 			}
-		} );
 
-		return shouldStopAndPreventDefault;
+			return false;
+		} );
 	}
 
 	/**
@@ -628,6 +633,46 @@ export default class WidgetTypeAround extends Plugin {
 			evt.stop();
 		}, { priority: priorities.get( 'high' ) + 1 } );
 	}
+
+	/**
+	 * Attaches the {@link module:engine/model/model~Model#event:insertContent} event listener that, for instance, allows the user to paste
+	 * content near a widget when the "fake caret" was first activated using the arrow keys.
+	 *
+	 * The content is inserted according to the "widget-type-around" selection attribute (see {@link #_handleArrowKeyPress}).
+	 *
+	 * @private
+	 */
+	_enableInsertContentIntegration() {
+		const editor = this.editor;
+		const model = this.editor.model;
+		const documentSelection = model.document.selection;
+
+		this.listenTo( editor.model, 'insertContent', ( evt, [ content, selectable ] ) => {
+			if ( selectable && !selectable.is( 'documentSelection' ) ) {
+				return;
+			}
+
+			const typeAroundSelectionAttributeValue = documentSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE );
+
+			if ( !typeAroundSelectionAttributeValue ) {
+				return;
+			}
+
+			evt.stop();
+
+			return model.change( writer => {
+				const selectedElement = documentSelection.getSelectedElement();
+				const position = model.createPositionAt( selectedElement, typeAroundSelectionAttributeValue );
+				const selection = writer.createSelection( position );
+
+				const result = model.insertContent( content, selection );
+
+				writer.setSelection( selection );
+
+				return result;
+			} );
+		}, { priority: 'high' } );
+	}
 }
 
 // Injects the type around UI into a view widget instance.

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

@@ -734,6 +734,36 @@ describe( 'WidgetTypeAround', () => {
 			expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false;
 		} );
 
+		it( 'should quit the "fake caret" mode when model was changed (model.deleteContent())', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph>[<blockWidget></blockWidget>]<paragraph>baz</paragraph>' );
+
+			const selection = model.createSelection( modelSelection );
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'before' );
+				model.deleteContent( selection );
+			} );
+
+			const viewWidget = viewRoot.getChild( 1 );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph><paragraph></paragraph><paragraph>baz</paragraph>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+			expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false;
+			expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false;
+		} );
+
+		it( 'should quit the "fake caret" mode when model was changed (writer.remove())', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph>[<blockWidget></blockWidget>]<paragraph>baz</paragraph>' );
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'before' );
+				writer.remove( editor.model.document.getRoot().getChild( 1 ) );
+			} );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph><paragraph>baz</paragraph>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+		} );
+
 		describe( 'inserting a new paragraph', () => {
 			describe( 'on Enter key press when the "fake caret" is activated', () => {
 				it( 'should insert a paragraph before a widget if the caret was "before" it', () => {
@@ -1317,6 +1347,130 @@ describe( 'WidgetTypeAround', () => {
 		}
 	} );
 
+	describe( 'Model#insertContent() integration', () => {
+		let model, modelSelection;
+
+		beforeEach( () => {
+			model = editor.model;
+			modelSelection = model.document.selection;
+		} );
+
+		it( 'should not alter insertContent for the selection other than the document selection', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph>[<blockWidget></blockWidget>]<paragraph>baz</paragraph>' );
+
+			const batchSet = setupBatchWatch();
+			const selection = model.createSelection( modelSelection );
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'before' );
+				model.insertContent( createParagraph( 'bar' ), selection );
+			} );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]</paragraph><paragraph>bar</paragraph><paragraph>baz</paragraph>' );
+			expect( batchSet.size ).to.be.equal( 1 );
+		} );
+
+		it( 'should not alter insertContent when the "fake caret" is not active', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph>[<blockWidget></blockWidget>]<paragraph>baz</paragraph>' );
+
+			const batchSet = setupBatchWatch();
+
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+
+			model.insertContent( createParagraph( 'bar' ) );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar[]</paragraph><paragraph>baz</paragraph>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+			expect( batchSet.size ).to.be.equal( 1 );
+		} );
+
+		it( 'should handle insertContent before a widget when it\'s the first element of the root', () => {
+			setModelData( editor.model, '[<blockWidget></blockWidget>]' );
+
+			const batchSet = setupBatchWatch();
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'before' );
+			} );
+
+			model.insertContent( createParagraph( 'bar' ) );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>bar[]</paragraph><blockWidget></blockWidget>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+			expect( batchSet.size ).to.be.equal( 1 );
+		} );
+
+		it( 'should handle insertContent after a widget when it\'s the last element of the root', () => {
+			setModelData( editor.model, '[<blockWidget></blockWidget>]' );
+
+			const batchSet = setupBatchWatch();
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'after' );
+			} );
+
+			model.insertContent( createParagraph( 'bar' ) );
+
+			expect( getModelData( model ) ).to.equal( '<blockWidget></blockWidget><paragraph>bar[]</paragraph>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+			expect( batchSet.size ).to.be.equal( 1 );
+		} );
+
+		it( 'should handle insertContent before a widget when it\'s not the first element of the root', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph>[<blockWidget></blockWidget>]' );
+
+			const batchSet = setupBatchWatch();
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'before' );
+			} );
+
+			model.insertContent( createParagraph( 'bar' ) );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar[]</paragraph><blockWidget></blockWidget>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+			expect( batchSet.size ).to.be.equal( 1 );
+		} );
+
+		it( 'should handle insertContent after a widget when it\'s not the last element of the root', () => {
+			setModelData( editor.model, '[<blockWidget></blockWidget>]<paragraph>foo</paragraph>' );
+
+			const batchSet = setupBatchWatch();
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'widget-type-around', 'after' );
+			} );
+
+			model.insertContent( createParagraph( 'bar' ) );
+
+			expect( getModelData( model ) ).to.equal( '<blockWidget></blockWidget><paragraph>bar[]</paragraph><paragraph>foo</paragraph>' );
+			expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined;
+			expect( batchSet.size ).to.be.equal( 1 );
+		} );
+
+		function createParagraph( text ) {
+			return model.change( writer => {
+				const paragraph = writer.createElement( 'paragraph' );
+
+				writer.insertText( text, paragraph );
+
+				return paragraph;
+			} );
+		}
+
+		function setupBatchWatch() {
+			const createdBatches = new Set();
+
+			model.on( 'applyOperation', ( evt, [ operation ] ) => {
+				if ( operation.isDocumentOperation ) {
+					createdBatches.add( operation.batch );
+				}
+			} );
+
+			return createdBatches;
+		}
+	} );
+
 	function blockWidgetPlugin( editor ) {
 		editor.model.schema.register( 'blockWidget', {
 			inheritAllFrom: '$block',

+ 0 - 20
scripts/update-stable-branches.sh

@@ -1,20 +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
-
-set -e
-
-read -p "Are you sure? " -n 1 -r
-echo ""
-
-if [[ $REPLY =~ ^[Yy]$ ]]
-then
-	# Update the `stable` branch in the `ckeditor5` repository.
-	git checkout stable && git merge master && git checkout master
-
-	# Push the `#stable` branch.
-	git push origin stable master
-
-	echo "Success! 🎂"
-fi