瀏覽代碼

Merge branch 'master' into i/7559

Aleksander Nowodzinski 5 年之前
父節點
當前提交
166ef084f9

+ 1 - 1
CHANGELOG.md

@@ -19,7 +19,7 @@ New features were also accompanied by a set of bug fixes, to name a few:
 * [Autoformatting will no longer change formatting when typing in an inline code](https://github.com/ckeditor/ckeditor5/issues/1239).
 * Editor will no longer [crash if there's a HTML comment in the source data](https://github.com/ckeditor/ckeditor5/issues/5734).
 
-TODO: Add a link to the blog post.
+Read more in the blog post: https://ckeditor.com/blog/CKEditor-5-v20.0.0-with-linking-images-and-multi-cell-comments-released/
 
 ### Collaboration features
 

+ 10 - 2
docs/features/keyboard-support.md

@@ -67,14 +67,22 @@ Below is a list of the most important keystrokes supported by CKEditor 5 and its
 			<th colspan="3">When a widget is selected (for example: image, table, horizontal line, etc.)</th>
 		</tr>
 		<tr>
-            <td>Insert a new paragraph directly after a widget</td>
+			<td>Insert a new paragraph directly after a widget</td>
 			<td colspan="2"><kbd>Enter</kbd></td>
-		</tr> 
+		</tr>
 		<tr>
 			<td>Insert a new paragraph directly before a widget</td>
 			<td colspan="2"><kbd>Shift</kbd> + <kbd>Enter</kbd></td>
 		</tr>
 		<tr>
+			<td>Display the caret to allow typing directly before a widget</td>
+			<td colspan="2"><kbd>↑</kbd> / <kbd>←</kbd></td>
+		</tr>
+		<tr>
+			<td>Display the caret to allow typing directly after a widget</td>
+			<td colspan="2"><kbd>↓</kbd> / <kbd>→</kbd></td>
+		</tr>
+		<tr>
 			<th colspan="3">In a table cell</th>
 		</tr>
 		<tr>

+ 1 - 0
package.json

@@ -98,6 +98,7 @@
     "lint-staged": "^10.2.6",
     "mini-css-extract-plugin": "^0.9.0",
     "minimatch": "^3.0.4",
+    "mkdirp": "^1.0.4",
     "nyc": "^15.0.1",
     "postcss-loader": "^3.0.0",
     "progress-bar-webpack-plugin": "^2.1.0",

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

@@ -10,6 +10,7 @@
     "ckeditor5-plugin"
   ],
   "dependencies": {
+    "@ckeditor/ckeditor5-clipboard": "^20.0.0",
     "@ckeditor/ckeditor5-core": "^20.0.0",
     "@ckeditor/ckeditor5-engine": "^20.0.0",
     "@ckeditor/ckeditor5-image": "^20.0.0",
@@ -21,7 +22,6 @@
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^20.0.0",
     "@ckeditor/ckeditor5-block-quote": "^20.0.0",
-    "@ckeditor/ckeditor5-clipboard": "^20.0.0",
     "@ckeditor/ckeditor5-code-block": "^20.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^20.0.0",
     "@ckeditor/ckeditor5-enter": "^20.0.0",

+ 135 - 1
packages/ckeditor5-link/src/linkediting.js

@@ -10,6 +10,8 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
 import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
+import Input from '@ckeditor/ckeditor5-typing/src/input';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
 import AutomaticDecorators from './utils/automaticdecorators';
@@ -44,7 +46,8 @@ export default class LinkEditing extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TwoStepCaretMovement ];
+		// Clipboard is required for handling cut and paste events while typing over the link.
+		return [ TwoStepCaretMovement, Input, Clipboard ];
 	}
 
 	/**
@@ -110,6 +113,9 @@ export default class LinkEditing extends Plugin {
 
 		// Handle a click at the beginning/end of a link element.
 		this._enableClickingAfterLink();
+
+		// Handle typing over the link.
+		this._enableTypingOverLink();
 	}
 
 	/**
@@ -414,4 +420,132 @@ export default class LinkEditing extends Plugin {
 			}
 		} );
 	}
+
+	/**
+	 * Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent}
+	 * and checks whether typing over the link. If so, attributes of removed text are preserved and applied to the inserted text.
+	 *
+	 * The purpose of this action is to allow modifying a text without loosing the `linkHref` attribute (and other).
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/4762.
+	 *
+	 * @private
+	 */
+	_enableTypingOverLink() {
+		const editor = this.editor;
+		const view = editor.editing.view;
+
+		// Selection attributes when started typing over the link.
+		let selectionAttributes;
+
+		// Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved.
+		let deletedContent;
+
+		// Detect pressing `Backspace` / `Delete`.
+		this.listenTo( view.document, 'delete', () => {
+			deletedContent = true;
+		}, { priority: 'high' } );
+
+		// Listening to `model#deleteContent` allows detecting whether selected content was a link.
+		// If so, before removing the element, we will copy its attributes.
+		this.listenTo( editor.model, 'deleteContent', () => {
+			const selection = editor.model.document.selection;
+
+			// Copy attributes only if anything is selected.
+			if ( selection.isCollapsed ) {
+				return;
+			}
+
+			// When the content was deleted, do not preserve attributes.
+			if ( deletedContent ) {
+				deletedContent = false;
+
+				return;
+			}
+
+			// Enabled only when typing.
+			if ( !isTyping( editor ) ) {
+				return;
+			}
+
+			if ( shouldCopyAttributes( editor.model ) ) {
+				selectionAttributes = selection.getAttributes();
+			}
+		}, { priority: 'high' } );
+
+		// Listening to `model#insertContent` allows detecting the content insertion.
+		// We want to apply attributes that were removed while typing over the link.
+		this.listenTo( editor.model, 'insertContent', ( evt, [ element ] ) => {
+			deletedContent = false;
+
+			// Enabled only when typing.
+			if ( !isTyping( editor ) ) {
+				return;
+			}
+
+			if ( !selectionAttributes ) {
+				return;
+			}
+
+			editor.model.change( writer => {
+				for ( const [ attribute, value ] of selectionAttributes ) {
+					writer.setAttribute( attribute, value, element );
+				}
+			} );
+
+			selectionAttributes = null;
+		}, { priority: 'high' } );
+	}
+}
+
+// Checks whether selection's attributes should be copied to the new inserted text.
+//
+// @param {module:engine/model/model~Model} model
+// @returns {Boolean}
+function shouldCopyAttributes( model ) {
+	const selection = model.document.selection;
+	const firstPosition = selection.getFirstPosition();
+	const lastPosition = selection.getLastPosition();
+	const nodeAtFirstPosition = firstPosition.nodeAfter;
+
+	// The text link node does not exist...
+	if ( !nodeAtFirstPosition ) {
+		return false;
+	}
+
+	// ...or it isn't the text node...
+	if ( !nodeAtFirstPosition.is( 'text' ) ) {
+		return false;
+	}
+
+	// ...or isn't the link.
+	if ( !nodeAtFirstPosition.hasAttribute( 'linkHref' ) ) {
+		return false;
+	}
+
+	// `textNode` = the position is inside the link element.
+	// `nodeBefore` = the position is at the end of the link element.
+	const nodeAtLastPosition = lastPosition.textNode || lastPosition.nodeBefore;
+
+	// If both references the same node selection contains a single text node.
+	if ( nodeAtFirstPosition === nodeAtLastPosition ) {
+		return true;
+	}
+
+	// If nodes are not equal, maybe the link nodes has defined additional attributes inside.
+	// First, we need to find the entire link range.
+	const linkRange = findLinkRange( firstPosition, nodeAtFirstPosition.getAttribute( 'linkHref' ), model );
+
+	// Then we can check whether selected range is inside the found link range. If so, attributes should be preserved.
+	return linkRange.containsRange( model.createRange( firstPosition, lastPosition ), true );
+}
+
+// Checks whether provided changes were caused by typing.
+//
+// @params {module:core/editor/editor~Editor} editor
+// @returns {Boolean}
+function isTyping( editor ) {
+	const input = editor.plugins.get( 'Input' );
+
+	return input.isInput( editor.model.change( writer => writer.batch ) );
 }

+ 2 - 0
packages/ckeditor5-link/src/linkimage.js

@@ -11,6 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import LinkImageEditing from './linkimageediting';
 import LinkImageUI from './linkimageui';
 
+import '../theme/linkimage.css';
+
 /**
  * The `LinkImage` plugin.
  *

+ 13 - 0
packages/ckeditor5-link/src/linkimageediting.js

@@ -11,6 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
 import LinkEditing from './linkediting';
 
+import linkIcon from '../theme/icons/link.svg';
+
 /**
  * The link image engine feature.
  *
@@ -115,6 +117,14 @@ function downcastImageLink() {
 			// But we need to check whether the link element exists.
 			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
 
+			// Create an icon indicator for a linked image.
+			const linkIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-link-image_icon' }, function( domDocument ) {
+				const domElement = this.toDomElement( domDocument );
+				domElement.innerHTML = linkIcon;
+
+				return domElement;
+			} );
+
 			// If so, update the attribute if it's defined or remove the entire link if the attribute is empty.
 			if ( linkInImage ) {
 				if ( data.attributeNewValue ) {
@@ -135,6 +145,9 @@ function downcastImageLink() {
 
 				// 3. Move the image to the link.
 				writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) );
+
+				// 4. Inset the linked image icon indicator.
+				writer.insert( writer.createPositionAt( linkElement, 'end' ), linkIconIndicator );
 			}
 		} );
 	};

+ 351 - 11
packages/ckeditor5-link/tests/linkediting.js

@@ -8,15 +8,19 @@ import LinkCommand from '../src/linkcommand';
 import UnlinkCommand from '../src/unlinkcommand';
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import ItalicEditing from '@ckeditor/ckeditor5-basic-styles/src/italic/italicediting';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Input from '@ckeditor/ckeditor5-typing/src/input';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-import { isLinkElement } from '../src/utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import { isLinkElement } from '../src/utils';
 
 /* global document */
 
@@ -869,7 +873,7 @@ describe( 'LinkEditing', () => {
 
 		beforeEach( async () => {
 			editor = await ClassicTestEditor.create( element, {
-				plugins: [ Paragraph, LinkEditing, Enter, Typing, BoldEditing ],
+				plugins: [ Paragraph, LinkEditing, Enter, Input, BoldEditing ],
 				link: {
 					decorators: {
 						isFoo: {
@@ -892,6 +896,11 @@ describe( 'LinkEditing', () => {
 
 			model = editor.model;
 			view = editor.editing.view;
+
+			model.schema.extend( '$text', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
+			} );
 		} );
 
 		afterEach( async () => {
@@ -1033,11 +1042,6 @@ describe( 'LinkEditing', () => {
 		} );
 
 		it( 'should remove manual decorators', () => {
-			model.schema.extend( '$text', {
-				allowIn: '$root',
-				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
-			} );
-
 			setModelData( model, '<paragraph><$text linkIsFoo="true" linkIsBar="true" linkHref="url">Bar[]</$text></paragraph>' );
 
 			editor.editing.view.document.fire( 'mousedown' );
@@ -1056,4 +1060,340 @@ describe( 'LinkEditing', () => {
 			);
 		} );
 	} );
+
+	// https://github.com/ckeditor/ckeditor5/issues/4762
+	describe( 'typing over the link', () => {
+		let editor;
+
+		beforeEach( async () => {
+			editor = await ClassicTestEditor.create( element, {
+				plugins: [ Paragraph, LinkEditing, Enter, BoldEditing, ItalicEditing, ImageEditing ],
+				link: {
+					decorators: {
+						isFoo: {
+							mode: 'manual',
+							label: 'Foo',
+							attributes: {
+								class: 'foo'
+							}
+						},
+						isBar: {
+							mode: 'manual',
+							label: 'Bar',
+							attributes: {
+								target: '_blank'
+							}
+						}
+					}
+				}
+			} );
+
+			model = editor.model;
+			view = editor.editing.view;
+
+			model.schema.extend( '$text', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
+			} );
+		} );
+
+		afterEach( async () => {
+			await editor.destroy();
+		} );
+
+		it( 'should require Clipboard plugin', () => {
+			expect( LinkEditing.requires.includes( Clipboard ) ).to.equal( true );
+		} );
+
+		it( 'should require Input plugin', () => {
+			expect( LinkEditing.requires.includes( Input ) ).to.equal( true );
+		} );
+
+		it( 'should preserve selection attributes when the entire link is selected', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is <$text linkHref="foo">Abcde</$text>[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should preserve selection attributes when the entire link is selected (mixed attributes in the link)', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'This is [' +
+					'<$text linkHref="foo" italic="true">F</$text>' +
+					'<$text linkHref="foo" bold="true">o</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">o</$text>' +
+					'<$text linkHref="foo" bold="true">B</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">a</$text>' +
+					'<$text linkHref="foo">r</$text>]' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>' +
+					'This is ' +
+					'<$text italic="true" linkHref="foo">Abcde</$text>' +
+					'<$text italic="true">[]</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'should preserve selection attributes when the selection starts at the beginning of the link', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Fo]o</$text> from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is <$text linkHref="foo">Abcde[]o</$text> from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should preserve selection attributes when it starts at the beginning of the link (mixed attributes in the link)', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'This is [' +
+					'<$text linkHref="foo" italic="true">F</$text>' +
+					'<$text linkHref="foo" bold="true">o</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">o</$text>' +
+					'<$text linkHref="foo" bold="true">B</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">a]</$text>' +
+					'<$text linkHref="foo">r</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>' +
+					'This is ' +
+					'<$text italic="true" linkHref="foo">Abcde[]</$text>' +
+					'<$text linkHref="foo">r</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'should preserve all attributes of the link node (decorators check)', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'This is ' +
+					'<$text linkIsFoo="true" linkIsBar="true" linkHref="foo">[Foo]</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>' +
+					'This is ' +
+					'<$text linkHref="foo" linkIsBar="true" linkIsFoo="true">Abcde</$text>[]' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the changes are not caused by typing', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			model.change( writer => {
+				model.deleteContent( model.document.selection );
+				model.insertContent( writer.createText( 'Abcde' ) );
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the changes are not caused by typing (pasting check)', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'paste', {
+				dataTransfer: createDataTransfer( {
+					'text/html': '<p>Abcde</p>',
+					'text/plain': 'Abcde'
+				} ),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when typed after cutting the content', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'cut', {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve anything if selected an element instead of text', () => {
+			setModelData( model,
+				'[<image src="/assets/sample.png"></image>]'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'Abcde[]'
+			);
+		} );
+
+		it( 'should not preserve anything if selected text does not have the `linkHref` attribute`', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text bold="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the entire link is selected and pressed "Backspace"', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'delete', new DomEventData( view.document, {
+				keyCode: keyCodes.backspace,
+				preventDefault: () => {}
+			} ) );
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the entire link is selected and pressed "Delete"', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'delete', new DomEventData( view.document, {
+				keyCode: keyCodes.delete,
+				preventDefault: () => {}
+			} ) );
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when selected different links', () => {
+			setModelData( model,
+				'<paragraph>This is <$text linkHref="foo">[Foo</$text> from <$text linkHref="bar">Bar]</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>This is Abcde[].</paragraph>' );
+		} );
+
+		it( 'should not preserve selection attributes when selected more than single link (start of the selection)', () => {
+			setModelData( model,
+				'<paragraph>This is[ <$text linkHref="foo">Foo]</$text> from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This isAbcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when selected more than single link (end of the selection)', () => {
+			setModelData( model,
+				'<paragraph>This is <$text linkHref="foo">[Foo</$text> ]from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[]from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		function createDataTransfer( data ) {
+			return {
+				getData( type ) {
+					return data[ type ];
+				},
+				setData() {}
+			};
+		}
+	} );
 } );

+ 20 - 3
packages/ckeditor5-link/tests/linkimageediting.js

@@ -11,6 +11,12 @@ import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml'
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting';
 
+import linkIcon from '../theme/icons/link.svg';
+
+// We can import the SVG code directly to avoid re-edit, but we have to make sure that the icon code has `</path>` closing tag.
+// After cleaning up the icons, the closing tag can be re-parsed and some tests will fail.
+const linkIconInditatorElement = '<span class="ck ck-link-image_icon">' + linkIcon + '</span>';
+
 describe( 'LinkImageEditing', () => {
 	let editor, model, view;
 
@@ -48,7 +54,9 @@ describe( 'LinkImageEditing', () => {
 				setModelData( model, '<image src="/assets/sample.png" alt="alt text" linkHref="http://ckeditor.com"></image>' );
 
 				expect( editor.getData() ).to.equal(
-					'<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" src="/assets/sample.png"></a></figure>'
+					'<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" src="/assets/sample.png">' +
+					linkIconInditatorElement +
+					'</a></figure>'
 				);
 			} );
 
@@ -56,7 +64,9 @@ describe( 'LinkImageEditing', () => {
 				setModelData( model, '<image src="/assets/sample.png" linkHref="http://ckeditor.com"></image>' );
 
 				expect( editor.getData() ).to.equal(
-					'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png"></a></figure>'
+					'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png">' +
+					linkIconInditatorElement +
+					'</a></figure>'
 				);
 			} );
 
@@ -72,6 +82,7 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="image">' +
 						'<a href="http://ckeditor.com">' +
 							'<img sizes="100vw" src="/assets/sample.png" srcset="small.png 148w, big.png 1024w"></img>' +
+							linkIconInditatorElement +
 						'</a>' +
 					'</figure>'
 				);
@@ -264,6 +275,8 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="ck-widget image" contenteditable="false">' +
 						'<a href="http://ckeditor.com">' +
 							'<img alt="alt text" src="/assets/sample.png"></img>' +
+							// Content of the UIElement is skipped here.
+							'<span class="ck ck-link-image_icon"></span>' +
 						'</a>' +
 					'</figure>'
 				);
@@ -281,6 +294,8 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="ck-widget image" contenteditable="false">' +
 						'<a href="https://ckeditor.com/why-ckeditor/">' +
 							'<img alt="alt text" src="/assets/sample.png"></img>' +
+							// Content of the UIElement is skipped here.
+							'<span class="ck ck-link-image_icon"></span>' +
 						'</a>' +
 					'</figure>'
 				);
@@ -302,7 +317,7 @@ describe( 'LinkImageEditing', () => {
 			} );
 		} );
 
-		describe( 'figure > a > img + figcaption', () => {
+		describe( 'figure > a > img + span + figcaption', () => {
 			it( 'should convert a link and the caption element', () => {
 				return VirtualTestEditor
 					.create( {
@@ -319,6 +334,8 @@ describe( 'LinkImageEditing', () => {
 							'<figure class="ck-widget image" contenteditable="false">' +
 								'<a href="http://ckeditor.com">' +
 									'<img alt="alt text" src="/assets/sample.png"></img>' +
+									// Content of the UIElement is skipped here.
+									'<span class="ck ck-link-image_icon"></span>' +
 								'</a>' +
 								'<figcaption class="ck-editor__editable ck-editor__nested-editable" ' +
 									'contenteditable="true" data-placeholder="Enter image caption">' +

+ 3 - 1
packages/ckeditor5-link/tests/manual/linkimage.md

@@ -1,3 +1,5 @@
 # Linking Image
 
-Both images should have defined the `linkHref` attribute.
+Only the first image should have:
+- defined the `linkHref` attribute.
+- the linked image icon indicator in the top-right corner (the element should be placed inside the `<a>` element).

File diff suppressed because it is too large
+ 1 - 1
packages/ckeditor5-link/theme/icons/link.svg


+ 18 - 0
packages/ckeditor5-link/theme/linkimage.css

@@ -0,0 +1,18 @@
+/*
+ * 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-link-image_icon {
+	position: absolute;
+	top: var(--ck-spacing-medium);
+	right: var(--ck-spacing-medium);
+	width: 18px;
+	height: 18px;
+	padding: 5px;
+	border-radius: var(--ck-border-radius);
+
+	& svg {
+		fill: currentColor;
+	}
+}

+ 14 - 6
packages/ckeditor5-table/src/tableclipboard.js

@@ -164,7 +164,18 @@ export default class TableClipboard extends Plugin {
 			// Content table to which we insert a pasted table.
 			const selectedTable = selectedTableCells[ 0 ].findAncestor( 'table' );
 
-			replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
+			const cellsToSelect = replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
+
+			if ( this.editor.plugins.get( 'TableSelection' ).isEnabled ) {
+				// Selection ranges must be sorted because the first and last selection ranges are considered
+				// as anchor/focus cell ranges for multi-cell selection.
+				const selectionRanges = sortRanges( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
+
+				writer.setSelection( selectionRanges );
+			} else {
+				// Set selection inside first cell if multi-cell selection is disabled.
+				writer.setSelection( cellsToSelect[ 0 ], 0 );
+			}
 		} );
 	}
 }
@@ -248,6 +259,7 @@ function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, t
 // @param {Number} selection.lastColumn
 // @param {Number} selection.lastRow
 // @param {module:engine/model/writer~Writer} writer
+// @returns {Array.<module:engine/model/element~Element>}
 function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
 	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
 
@@ -334,11 +346,7 @@ function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selected
 		cellsToSelect.push( ...newCells );
 	}
 
-	// Selection ranges must be sorted because the first and last selection ranges are considered
-	// as anchor/focus cell ranges for multi-cell selection.
-	const selectionRanges = sortRanges( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
-
-	writer.setSelection( selectionRanges );
+	return cellsToSelect;
 }
 
 // Expand table (in place) to expected size.

+ 15 - 0
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -899,6 +899,21 @@ describe( 'table clipboard', () => {
 						[ '', '', 'ba', 'bb' ]
 					] ) );
 				} );
+
+				it( 'should not set multi-cell selection if TableSelection plugin is disabled', () => {
+					editor.plugins.get( 'TableSelection' ).forceDisabled();
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model ), modelTable( [
+						[ '[]aa', 'ab', '02' ],
+						[ 'ba', 'bb', '12' ],
+						[ '20', '21', '22' ]
+					] ) );
+				} );
 			} );
 
 			describe( 'with spanned cells', () => {

+ 9 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css

@@ -0,0 +1,9 @@
+/*
+ * 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-link-image_icon {
+	color: hsl(0, 0%, 100%);
+	background: hsla(0, 0%, 0%, .4);
+}

+ 3 - 3
packages/ckeditor5-typing/src/inputcommand.js

@@ -89,6 +89,9 @@ export default class InputCommand extends Command {
 		model.enqueueChange( this._buffer.batch, writer => {
 			this._buffer.lock();
 
+			// Store the batch as an 'input' batch for the Input.isInput( batch ) check.
+			this._batches.add( this._buffer.batch );
+
 			model.deleteContent( selection );
 
 			if ( text ) {
@@ -104,9 +107,6 @@ export default class InputCommand extends Command {
 			this._buffer.unlock();
 
 			this._buffer.input( textInsertions );
-
-			// Store the batch as an 'input' batch for the Input.isInput( batch ) check.
-			this._batches.add( this._buffer.batch );
 		} );
 	}
 }

+ 4 - 1
packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js

@@ -110,7 +110,10 @@ export default function injectUnsafeKeystrokesHandling( editor ) {
 
 		buffer.lock();
 
-		model.enqueueChange( buffer.batch, () => {
+		const batch = buffer.batch;
+		inputCommand._batches.add( batch );
+
+		model.enqueueChange( batch, () => {
 			model.deleteContent( model.document.selection );
 		} );
 

+ 24 - 2
packages/ckeditor5-typing/tests/inputcommand.js

@@ -12,7 +12,7 @@ import ChangeBuffer from '../src/utils/changebuffer';
 import Input from '../src/input';
 
 describe( 'InputCommand', () => {
-	let editor, model, doc, buffer;
+	let editor, model, doc, buffer, inputCommand;
 
 	testUtils.createSinonSandbox();
 
@@ -23,7 +23,7 @@ describe( 'InputCommand', () => {
 				model = editor.model;
 				doc = model.document;
 
-				const inputCommand = new InputCommand( editor, 20 );
+				inputCommand = new InputCommand( editor, 20 );
 				editor.commands.add( 'input', inputCommand );
 
 				buffer = inputCommand.buffer;
@@ -281,6 +281,28 @@ describe( 'InputCommand', () => {
 				'<paragraph>z</paragraph>'
 			);
 		} );
+
+		it( 'uses typing batch while removing and inserting the content', () => {
+			expect( inputCommand._batches.has( getCurrentBatch() ), 'batch before typing' ).to.equal( false );
+
+			model.on( 'deleteContent', () => {
+				expect( inputCommand._batches.has( getCurrentBatch() ), 'batch when deleting content' ).to.equal( true );
+			}, { priority: 'highest' } );
+
+			model.on( 'insertContent', () => {
+				expect( inputCommand._batches.has( getCurrentBatch() ), 'batch when inserting content' ).to.equal( true );
+			}, { priority: 'lowest' } );
+
+			setData( model, '<paragraph>[foo]</paragraph>' );
+
+			editor.execute( 'input', { text: 'bar' } );
+
+			expect( getData( model ) ).to.equal( '<paragraph>bar[]</paragraph>' );
+
+			function getCurrentBatch() {
+				return editor.model.change( writer => writer.batch );
+			}
+		} );
 	} );
 
 	describe( 'destroy()', () => {

+ 46 - 3
packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js

@@ -3,10 +3,12 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import {
-	keyCodes
-} from '@ckeditor/ckeditor5-utils/src/keyboard';
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { keyCodes, getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { isNonTypingKeystroke } from '../../src/utils/injectunsafekeystrokeshandling';
+import Typing from '../../src/typing';
 
 describe( 'unsafe keystroke handling utils', () => {
 	describe( 'isNonTypingKeystroke()', () => {
@@ -82,4 +84,45 @@ describe( 'unsafe keystroke handling utils', () => {
 			expect( isNonTypingKeystroke( { keyCode: keyCodes.a, altKey: true } ), 'Alt+a' ).to.be.false;
 		} );
 	} );
+
+	describe( 'injectUnsafeKeystrokesHandling()', () => {
+		let editor, model;
+
+		beforeEach( () => {
+			return ModelTestEditor.create( { plugins: [ Typing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+
+					model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+				} );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'uses typing batch while removing the content', () => {
+			const inputCommand = editor.commands.get( 'input' );
+
+			expect( inputCommand._batches.has( getCurrentBatch() ), 'batch before typing' ).to.equal( false );
+
+			model.on( 'deleteContent', () => {
+				expect( inputCommand._batches.has( getCurrentBatch() ), 'batch when deleting content' ).to.equal( true );
+			}, { priority: 'highest' } );
+
+			setData( model, '<paragraph>[foo]</paragraph>' );
+
+			editor.editing.view.document.fire( 'keydown', new DomEventData( editor.editing.view.document, {
+				preventDefault: () => {},
+				keyCode: getCode( 'A' )
+			} ) );
+
+			expect( getData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+
+			function getCurrentBatch() {
+				return editor.model.change( writer => writer.batch );
+			}
+		} );
+	} );
 } );

+ 292 - 18
scripts/docs/build-content-styles.js

@@ -7,26 +7,66 @@
 
 const path = require( 'path' );
 const fs = require( 'fs' );
+const chalk = require( 'chalk' );
+const glob = require( 'glob' );
+const mkdirp = require( 'mkdirp' );
+const postcss = require( 'postcss' );
 const webpack = require( 'webpack' );
-const { styles } = require( '@ckeditor/ckeditor5-dev-utils' );
+const { tools, styles } = require( '@ckeditor/ckeditor5-dev-utils' );
 const { version } = require( '../../package.json' );
 
 const DESTINATION_DIRECTORY = path.join( __dirname, '..', '..', 'build', 'content-styles' );
 const DOCUMENTATION_URL = 'https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/content-styles.html';
 const VARIABLE_DEFINITION_REGEXP = /(--[\w-]+):\s+(.*);/g;
 const VARIABLE_USAGE_REGEXP = /var\((--[\w-]+)\)/g;
+const CONTENT_STYLES_GUIDE_PATH = path.join( __dirname, '..', '..', 'docs', 'builds', 'guides', 'integration', 'content-styles.md' );
 
 const contentRules = {
 	selector: [],
 	variables: [],
 	atRules: {}
 };
-
-const webpackConfig = getWebpackConfig();
 const packagesPath = path.join( process.cwd(), 'packages' );
+const shouldUpdateGuide = process.argv.includes( '--commit' );
+
+logProcess( 'Gathering all CKEditor 5 modules...' );
+
+getCkeditor5ModulePaths()
+	.then( files => {
+		console.log( `Found ${ files.length } files.` );
+		logProcess( 'Filtering CKEditor 5 plugins...' );
+
+		let promise = Promise.resolve();
+		const ckeditor5Modules = [];
+
+		for ( const modulePath of files ) {
+			promise = promise.then( () => {
+				return checkWhetherIsCKEditor5Plugin( modulePath )
+					.then( isModule => {
+						if ( isModule ) {
+							ckeditor5Modules.push( path.join( process.cwd(), modulePath ) );
+						}
+					} );
+			} );
+		}
 
-runWebpack( webpackConfig )
+		return promise.then( () => ckeditor5Modules );
+	} )
+	.then( ckeditor5Modules => {
+		console.log( `Found ${ ckeditor5Modules.length } plugins.` );
+		logProcess( 'Generating source file...' );
+
+		return mkdirp( DESTINATION_DIRECTORY ).then( () => generateCKEditor5Source( ckeditor5Modules ) );
+	} )
 	.then( () => {
+		logProcess( 'Building the editor...' );
+		const webpackConfig = getWebpackConfig();
+
+		return runWebpack( webpackConfig );
+	} )
+	.then( () => {
+		logProcess( 'Preparing the content styles file...' );
+
 		// All variables are placed inside the `:root` selector. Let's extract their names and values as a map.
 		const cssVariables = new Map( contentRules.variables
 			.map( rule => {
@@ -121,12 +161,131 @@ runWebpack( webpackConfig )
 	} )
 	.then( () => {
 		console.log( `Content styles have been extracted to ${ path.join( DESTINATION_DIRECTORY, 'content-styles.css' ) }` );
+
+		if ( !shouldUpdateGuide ) {
+			logProcess( 'Done.' );
+
+			return Promise.resolve();
+		}
+
+		logProcess( 'Updating the content styles guide...' );
+
+		const promises = [
+			readFile( CONTENT_STYLES_GUIDE_PATH ),
+			readFile( path.join( DESTINATION_DIRECTORY, 'content-styles.css' ) )
+		];
+
+		return Promise.all( promises )
+			.then( ( [ guideContent, newContentStyles ] ) => {
+				guideContent = guideContent.replace( /```css([^`]+)```/, newContentStyles );
+
+				return writeFile( CONTENT_STYLES_GUIDE_PATH, guideContent );
+			} )
+			.then( () => {
+				logProcess( 'Saving and committing...' );
+
+				const contentStyleFile = CONTENT_STYLES_GUIDE_PATH.replace( process.cwd() + path.sep, '' );
+
+				// Commit the documentation.
+				if ( exec( `git diff --name-only ${ contentStyleFile }` ).trim().length ) {
+					exec( `git add ${ contentStyleFile }` );
+					exec( 'git commit -m "Docs (ckeditor5): Updated the content styles stylesheet."' );
+
+					console.log( 'Successfully updated the content styles guide.' );
+				} else {
+					console.log( 'Nothing to commit. The content styles guide is up to date.' );
+				}
+
+				logProcess( 'Done.' );
+			} );
 	} )
 	.catch( err => {
 		console.log( err );
 	} );
 
 /**
+ * Resolves the promise with an array of paths to CKEditor 5 modules.
+ *
+ * @returns {Promise.<Array>}
+ */
+function getCkeditor5ModulePaths() {
+	return new Promise( ( resolve, reject ) => {
+		glob( 'packages/*/src/**/*.js', ( err, files ) => {
+			if ( err ) {
+				return reject( err );
+			}
+
+			return resolve( files );
+		} );
+	} );
+}
+
+/**
+ * Resolves the promise with a boolean value that indicates whether the module under `modulePath` is the CKEditor 5 plugin.
+ *
+ * @param modulePath
+ * @returns {Promise.<Boolean>}
+ */
+function checkWhetherIsCKEditor5Plugin( modulePath ) {
+	return readFile( path.join( process.cwd(), modulePath ) )
+		.then( content => {
+			const pluginName = path.basename( modulePath, '.js' );
+
+			if ( content.match( new RegExp( `export default class ${ pluginName } extends Plugin`, 'i' ) ) ) {
+				return Promise.resolve( true );
+			}
+
+			return Promise.resolve( false );
+		} );
+}
+
+/**
+ * Generates a source file that will be used to build the editor.
+ *
+ * @param {Array.<String>} ckeditor5Modules Paths to CKEditor 5 modules.
+ * @returns {Promise>}
+ */
+function generateCKEditor5Source( ckeditor5Modules ) {
+	ckeditor5Modules = ckeditor5Modules.map( modulePath => {
+		const pluginName = capitalize( path.basename( modulePath, '.js' ) );
+		return { modulePath, pluginName };
+	} );
+
+	const sourceFileContent = [
+		'/**',
+		` * @license Copyright (c) 2003-${ new Date().getFullYear() }, CKSource - Frederico Knabben. All rights reserved.`,
+		' * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license',
+		' */',
+		'',
+		'// The editor creator to use.',
+		'import ClassicEditorBase from \'@ckeditor/ckeditor5-editor-classic/src/classiceditor\';',
+		''
+	];
+
+	for ( const { modulePath, pluginName } of ckeditor5Modules ) {
+		sourceFileContent.push( `import ${ pluginName } from '${ modulePath }';` );
+	}
+
+	sourceFileContent.push( '' );
+	sourceFileContent.push( 'export default class ClassicEditor extends ClassicEditorBase {}' );
+	sourceFileContent.push( '' );
+	sourceFileContent.push( '// Plugins to include in the build.' );
+	sourceFileContent.push( 'ClassicEditor.builtinPlugins = [' );
+
+	for ( const { pluginName } of ckeditor5Modules ) {
+		sourceFileContent.push( '\t' + pluginName + ',' );
+	}
+
+	sourceFileContent.push( '];' );
+
+	return writeFile( path.join( DESTINATION_DIRECTORY, 'source.js' ), sourceFileContent.join( '\n' ) );
+
+	function capitalize( value ) {
+		return value.charAt( 0 ).toUpperCase() + value.slice( 1 );
+	}
+}
+
+/**
  * Prepares the configuration for webpack.
  *
  * @returns {Object}
@@ -139,34 +298,24 @@ function getWebpackConfig() {
 		minify: false
 	} );
 
-	const contentStylesPlugin = require( './content-styles/list-content-styles' )( { contentRules } );
-
-	postCssConfig.plugins.push( contentStylesPlugin );
+	postCssConfig.plugins.push( postCssContentStylesPlugin( contentRules ) );
 
 	return {
 		mode: 'development',
-
 		devtool: 'source-map',
-
 		entry: {
-			ckeditor5: path.join( __dirname, 'content-styles', 'ckeditor.js' )
+			ckeditor5: path.join( DESTINATION_DIRECTORY, 'source.js' )
 		},
-
 		output: {
 			path: DESTINATION_DIRECTORY,
 			filename: '[name].js'
 		},
-
-		// Configure the paths so building CKEditor 5 snippets work even if the script
-		// is triggered from a directory outside `ckeditor5` (e.g. in a multi-project case).
 		resolve: {
 			modules: getModuleResolvePaths()
 		},
-
 		resolveLoader: {
 			modules: getModuleResolvePaths()
 		},
-
 		module: {
 			rules: [
 				{
@@ -189,6 +338,92 @@ function getWebpackConfig() {
 }
 
 /**
+ * Returns the PostCSS plugin that allows intercepting CSS definition used in the editor's build.
+ *
+ * @param {Object} contentRules
+ * @param {Array.<String>} contentRules.variables Variables defined as `:root`.
+ * @param {Object} contentRules.atRules Definitions of behaves.
+ * @param {Array.<String>} contentRules.selector CSS definitions for all selectors.
+ * @returns {Function}
+ */
+function postCssContentStylesPlugin( contentRules ) {
+	return postcss.plugin( 'list-content-styles', function() {
+		const selectorStyles = contentRules.selector;
+		const variables = contentRules.variables;
+
+		return root => {
+			root.walkRules( rule => {
+				for ( const selector of rule.selectors ) {
+					const data = {
+						file: root.source.input.file,
+						css: rule.toString()
+					};
+
+					if ( selector.match( ':root' ) ) {
+						addDefinition( variables, data );
+					}
+
+					if ( selector.match( '.ck-content' ) ) {
+						if ( rule.parent.name && rule.parent.params ) {
+							const atRule = getAtRuleArray( contentRules.atRules, rule.parent.name, rule.parent.params );
+
+							addDefinition( atRule, data );
+						} else {
+							addDefinition( selectorStyles, data );
+						}
+					}
+				}
+			} );
+		};
+	} );
+
+	/**
+	 * @param {Object} collection
+	 * @param {String} name Name of an `at-rule`.
+	 * @param {String} params Parameters that describes the `at-rule`.
+	 * @returns {Array}
+	 */
+	function getAtRuleArray( collection, name, params ) {
+		const definition = `${ name } ${ params }`;
+
+		if ( !collection[ definition ] ) {
+			collection[ definition ] = [];
+		}
+
+		return collection[ definition ];
+	}
+
+	/**
+	 * Checks whether specified definition is duplicated in the colletion.
+	 *
+	 * @param {Array.<StyleStructure>} collection
+	 * @param {StyleStructure} def
+	 * @returns {Boolean}
+	 */
+	function isDuplicatedDefinition( collection, def ) {
+		for ( const item of collection ) {
+			if ( item.file === def.file && item.css === def.css ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Adds definition to the collection if it does not exist in the collection.
+	 *
+	 * @param {Array.<StyleStructure>} collection
+	 * @param {StyleStructure} def
+	 */
+	function addDefinition( collection, def ) {
+		if ( !isDuplicatedDefinition( collection, def ) ) {
+			collection.push( def );
+		}
+	}
+}
+
+/**
  * @param {Object} webpackConfig
  * @returns {Promise}
  */
@@ -216,9 +451,34 @@ function getModuleResolvePaths() {
 	];
 }
 
-function writeFile( file, data ) {
+/**
+ * Resolves the promise with the content of the file saved under the `filePath` location.
+ *
+ * @param {String} filePath The path to fhe file.
+ * @returns {Promise.<String>}
+ */
+function readFile( filePath ) {
 	return new Promise( ( resolve, reject ) => {
-		fs.writeFile( file, data, err => {
+		fs.readFile( filePath, 'utf-8', ( err, content ) => {
+			if ( err ) {
+				return reject( err );
+			}
+
+			return resolve( content );
+		} );
+	} );
+}
+
+/**
+ * Saves the `data` value to the file saved under the `filePath` location.
+ *
+ * @param {String} filePath The path to fhe file.
+ * @param {String} data The content to save.
+ * @returns {Promise.<String>}
+ */
+function writeFile( filePath, data ) {
+	return new Promise( ( resolve, reject ) => {
+		fs.writeFile( filePath, data, err => {
 			if ( err ) {
 				return reject( err );
 			}
@@ -275,3 +535,17 @@ function transformCssRules( rules ) {
 		} )
 		.join( '\n' );
 }
+
+function exec( command ) {
+	return tools.shExec( command, { verbosity: 'error' } );
+}
+
+function logProcess( message ) {
+	console.log( '\n📍 ' + chalk.cyan( message ) );
+}
+
+/**
+ * @typedef {Object} StyleStructure
+ * @property {String} file An absolute path to the file where a definition is defined.
+ * @property {String} css Definition.
+ */

+ 0 - 96
scripts/docs/content-styles/ckeditor.js

@@ -1,96 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-// The editor creator to use.
-import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-
-import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
-import UploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter';
-import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
-import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
-import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough';
-import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript';
-import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript';
-import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
-import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
-import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
-import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
-import Image from '@ckeditor/ckeditor5-image/src/image';
-import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
-import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize';
-import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
-import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
-import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import List from '@ckeditor/ckeditor5-list/src/list';
-import TodoList from '@ckeditor/ckeditor5-list/src/todolist';
-import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
-import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice';
-import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters';
-import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials';
-import StandardEditingMode from '@ckeditor/ckeditor5-restricted-editing/src/standardeditingmode';
-import RestrictedEditingMode from '@ckeditor/ckeditor5-restricted-editing/src/restrictededitingmode';
-import Table from '@ckeditor/ckeditor5-table/src/table';
-import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
-import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';
-import TableSelection from '@ckeditor/ckeditor5-table/src/tableselection';
-import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
-import Font from '@ckeditor/ckeditor5-font/src/font';
-import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight';
-import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
-
-export default class ClassicEditor extends ClassicEditorBase {}
-
-// Plugins to include in the build.
-ClassicEditor.builtinPlugins = [
-	Essentials,
-	UploadAdapter,
-	Autoformat,
-	Bold,
-	Code,
-	CodeBlock,
-	Italic,
-	Strikethrough,
-	Subscript,
-	Superscript,
-	Underline,
-	BlockQuote,
-	CKFinder,
-	EasyImage,
-	Heading,
-	HorizontalLine,
-	Image,
-	ImageCaption,
-	ImageResize,
-	ImageStyle,
-	ImageToolbar,
-	ImageUpload,
-	Link,
-	List,
-	TodoList,
-	MediaEmbed,
-	PageBreak,
-	Paragraph,
-	PasteFromOffice,
-	SpecialCharacters,
-	SpecialCharactersEssentials,
-	StandardEditingMode,
-	RestrictedEditingMode,
-	Table,
-	TableProperties,
-	TableCellProperties,
-	TableSelection,
-	TableToolbar,
-	Font,
-	Highlight,
-	Alignment
-];

+ 0 - 91
scripts/docs/content-styles/list-content-styles.js

@@ -1,91 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/* eslint-env node */
-
-const postcss = require( 'postcss' );
-
-module.exports = postcss.plugin( 'list-content-styles', function( options ) {
-	const selectorStyles = options.contentRules.selector;
-	const variables = options.contentRules.variables;
-
-	return root => {
-		root.walkRules( rule => {
-			for ( const selector of rule.selectors ) {
-				const data = {
-					file: root.source.input.file,
-					css: rule.toString()
-				};
-
-				if ( selector.match( ':root' ) ) {
-					addDefinition( variables, data );
-				}
-
-				if ( selector.match( '.ck-content' ) ) {
-					if ( rule.parent.name && rule.parent.params ) {
-						const atRule = getAtRuleArray( options.contentRules.atRules, rule.parent.name, rule.parent.params );
-
-						addDefinition( atRule, data );
-					} else {
-						addDefinition( selectorStyles, data );
-					}
-				}
-			}
-		} );
-	};
-} );
-
-/**
- * @param {Object} collection
- * @param {String} name Name of an `at-rule`.
- * @param {String} params Parameters that describes the `at-rule`.
- * @returns {Array}
- */
-function getAtRuleArray( collection, name, params ) {
-	const definition = `${ name } ${ params }`;
-
-	if ( !collection[ definition ] ) {
-		collection[ definition ] = [];
-	}
-
-	return collection[ definition ];
-}
-
-/**
- * Checks whether specified definition is duplicated in the colletion.
- *
- * @param {Array.<StyleStructure>} collection
- * @param {StyleStructure} def
- * @returns {Boolean}
- */
-function isDuplicatedDefinition( collection, def ) {
-	for ( const item of collection ) {
-		if ( item.file === def.file && item.css === def.css ) {
-			return true;
-		}
-	}
-
-	return false;
-}
-
-/**
- * Adds definition to the collection if it does not exist in the collection.
- *
- * @param {Array.<StyleStructure>} collection
- * @param {StyleStructure} def
- */
-function addDefinition( collection, def ) {
-	if ( !isDuplicatedDefinition( collection, def ) ) {
-		collection.push( def );
-	}
-}
-
-/**
- * @typedef {Object} StyleStructure
- * @property {String} file An absolute path to the file where a definition is defined.
- * @property {String} css Definition.
- */

File diff suppressed because it is too large
+ 1384 - 140
yarn.lock