浏览代码

Merge branch 'master' into t/ckeditor/ckeditor5/1013

Piotrek Koszuliński 7 年之前
父节点
当前提交
0684fcdcbb

+ 1 - 1
packages/ckeditor5-ui/README.md

@@ -4,7 +4,7 @@ CKEditor 5 UI framework
 [![Join the chat at https://gitter.im/ckeditor/ckeditor5](https://badges.gitter.im/ckeditor/ckeditor5.svg)](https://gitter.im/ckeditor/ckeditor5?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
 [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-ui.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-ui)
 [![Build Status](https://travis-ci.org/ckeditor/ckeditor5-ui.svg?branch=master)](https://travis-ci.org/ckeditor/ckeditor5-ui)
-[![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=d3hvenZqQVZERFQ5d09FWXdyT0ozVXhLaVltRFRjTTUyZGpvQWNmWVhUUT0tLUZqNlJ1YWRUd0RvdEVOaEptM1B2Q0E9PQ==--c9d3dee40b9b4471ff3fb516d9ecf8d09292c7e0)](https://www.browserstack.com/automate/public-build/d3hvenZqQVZERFQ5d09FWXdyT0ozVXhLaVltRFRjTTUyZGpvQWNmWVhUUT0tLUZqNlJ1YWRUd0RvdEVOaEptM1B2Q0E9PQ==--c9d3dee40b9b4471ff3fb516d9ecf8d09292c7e0)
+[![BrowserStack Status](https://automate.browserstack.com/automate/badge.svg?badge_key=d3hvenZqQVZERFQ5d09FWXdyT0ozVXhLaVltRFRjTTUyZGpvQWNmWVhUUT0tLUZqNlJ1YWRUd0RvdEVOaEptM1B2Q0E9PQ==--c9d3dee40b9b4471ff3fb516d9ecf8d09292c7e0)](https://automate.browserstack.com/public-build/d3hvenZqQVZERFQ5d09FWXdyT0ozVXhLaVltRFRjTTUyZGpvQWNmWVhUUT0tLUZqNlJ1YWRUd0RvdEVOaEptM1B2Q0E9PQ==--c9d3dee40b9b4471ff3fb516d9ecf8d09292c7e0)
 [![Coverage Status](https://coveralls.io/repos/github/ckeditor/ckeditor5-ui/badge.svg?branch=master)](https://coveralls.io/github/ckeditor/ckeditor5-ui?branch=master)
 <br>
 [![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-ui/status.svg)](https://david-dm.org/ckeditor/ckeditor5-ui)

+ 27 - 27
packages/ckeditor5-ui/docs/framework/guides/external-ui.md

@@ -5,7 +5,7 @@ order: 20
 
 # Third–party UI
 
-CKEditor 5 is a modular editing framework that allows various flexible configurations. That includes the usage of a third–party user interface on top of the base editor classes.
+CKEditor 5 is a modular editing framework that allows various flexible configurations. This includes the usage of a third–party user interface on top of the base editor classes.
 
 In this guide, a [classic–like](https://www.npmjs.com/package/@ckeditor/ckeditor5-build-classic) editor will be bound to a completely separate, existing UI created in [Bootstrap](http://getbootstrap.com/), providing the basic structure and toolbar items necessary to start editing.
 
@@ -26,17 +26,17 @@ import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/i
 import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
 import ElementReplacer from '@ckeditor/ckeditor5-utils/src/elementreplacer';
 
-// Interfaces to extend basic Editor API.
+// Interfaces to extend the basic Editor API.
 import DataApiMixin from '@ckeditor/ckeditor5-core/src/editor/utils/dataapimixin';
 import ElementApiMixin from '@ckeditor/ckeditor5-core/src/editor/utils/elementapimixin';
 
 // Helper function for adding interfaces to the Editor class.
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
-// Helper function that gets data from HTML element that the Editor is attached to.
+// Helper function that gets the data from an HTML element that the Editor is attached to.
 import getDataFromElement from '@ckeditor/ckeditor5-utils/src/dom/getdatafromelement';
 
-// Helper function that binds editor with HTMLForm element.
+// Helper function that binds the editor with an HTMLForm element.
 import attachToForm from '@ckeditor/ckeditor5-core/src/editor/utils/attachtoform';
 
 // Basic features that every editor should enable.
@@ -46,7 +46,7 @@ import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoengine';
 
-// Basic features to associated with the edited content.
+// Basic features associated with the edited content.
 import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
 import ItalicEditing from '@ckeditor/ckeditor5-basic-styles/src/italic/italicediting';
 import UnderlineEditing from '@ckeditor/ckeditor5-basic-styles/src/underline/underlineediting';
@@ -54,7 +54,7 @@ import HeadingEditing from '@ckeditor/ckeditor5-heading/src/heading/headingediti
 ```
 
 <info-box info>
-	Note that instead of {@link module:basic-styles/bold~Bold}, which loads default Bold UI and Bold editing feature, just the {@link module:basic-styles/bold/boldediting~BoldEditing} is imported. It provides the [engine](https://www.npmjs.com/package/@ckeditor/ckeditor5-engine) features associated with editing any bold text but does not come with the actual UI.
+	Note that instead of {@link module:basic-styles/bold~Bold}, which loads the default bold UI and bold editing feature, just the {@link module:basic-styles/bold/boldediting~BoldEditing} is imported. It provides the [engine](https://www.npmjs.com/package/@ckeditor/ckeditor5-engine) features associated with editing any bold text but does not come with the actual UI.
 
 	Respectively, `ItalicEditing`, `UnderlineEditing`, `HeadingEditing` and `UndoEditing` are also imported.
 </info-box>
@@ -62,7 +62,7 @@ import HeadingEditing from '@ckeditor/ckeditor5-heading/src/heading/headingediti
 Having imported the very basic editor components, you can define the custom `BootstrapEditor` class that extends the {@link module:core/editor/editor~Editor `Editor`}:
 
 ```js
-// Extending the Editor class, which brings base editor API.
+// Extending the Editor class, which brings the base editor API.
 export default class BootstrapEditor extends Editor {
 	constructor( element, config ) {
 		super( config );
@@ -79,8 +79,8 @@ export default class BootstrapEditor extends Editor {
 		// The UI layer of the editor.
 		this.ui = new BootstrapEditorUI( this );
 
-		// When editor#element is a textarea inside a form element
-		// then content of this textarea will be updated on form submit.
+		// When editor#element is a textarea inside a form element,
+		// the content of this textarea will be updated on form submit.
 		attachToForm( this );
 
 		// A helper to easily replace the editor#element with editor.editable#element.
@@ -88,7 +88,7 @@ export default class BootstrapEditor extends Editor {
 	}
 
 	destroy() {
-		// When destroyed, editor sets the output of editor#getData() into editor#element...
+		// When destroyed, the editor sets the output of editor#getData() into editor#element...
 		this.updateElement();
 
 		// ...and restores the original editor#element...
@@ -117,7 +117,7 @@ export default class BootstrapEditor extends Editor {
 						// Tell the world that the UI of the editor is ready to use.
 						editor.fire( 'uiReady' );
 					} )
-					// Bind the editor editing layer to the editable in DOM.
+					// Bind the editor editing layer to the editable in the DOM.
 					.then( () => editor.editing.view.attachDomRoot( editable.element ) )
 					// Fill the editable with the intial data.
 					.then( () => editor.data.init( getDataFromElement( element ) ) )
@@ -132,7 +132,7 @@ export default class BootstrapEditor extends Editor {
 	}
 }
 
-// Mixing interfaces, which extends basic editor API.
+// Mixing interfaces, which extends the basic editor API.
 mix( BootstrapEditor, DataApiMixin );
 mix( BootstrapEditor, ElementApiMixin );
 ```
@@ -142,7 +142,7 @@ mix( BootstrapEditor, ElementApiMixin );
 Although the editor is ready to use, it is just a bare editable area &mdash; which is not much use to the users. You need to give it an actual interface with the toolbar and buttons.
 
 <info-box hint>
-	Refer to the Bootstrap [quick start guide](https://getbootstrap.com/docs/4.0/getting-started/introduction/) to learn how to include Bootstrap in your web page.
+	Refer to the Bootstrap [Getting started](https://getbootstrap.com/docs/4.0/getting-started/introduction/) guide to learn how to include Bootstrap in your web page.
 </info-box>
 
 With the Bootstrap framework loaded in the web page, you can define the actual UI of the editor in HTML:
@@ -243,16 +243,16 @@ as different headings are selected. */
 
 ## Binding the UI with the editor
 
-At this stage, we should bind the editor created at the very beginning of this guide with the Bootstrap UI defined in HTML. All the UI logic will be wrapped into a separate class matching the `EditorUI` {@link module:core/editor/editorui~EditorUI interface}. You may have noticed this line in the constructor of the `BootstrapEditor`:
+At this stage, you should bind the editor created at the very beginning of this guide with the Bootstrap UI defined in HTML. All the UI logic will be wrapped into a separate class matching the `EditorUI` {@link module:core/editor/editorui~EditorUI interface}. You may have noticed this line in the constructor of the `BootstrapEditor`:
 
 ```js
 this.ui = new BootstrapEditorUI( this );
 ```
 
-Let's define the `BootstrapEditorUI` then a have a closer look at the content of the class:
+Define the `BootstrapEditorUI` and then have a closer look at the content of the class:
 
 ```js
-// The class organizing the UI of the editor, binding it with existing Bootstrap elements in DOM.
+// The class organizing the UI of the editor, binding it with existing Bootstrap elements in the DOM.
 class BootstrapEditorUI {
 	constructor( editor ) {
 		this.editor = editor;
@@ -260,10 +260,10 @@ class BootstrapEditorUI {
 		// The global UI view of the editor. It aggregates various Bootstrap DOM elements.
 		const view = this.view = new EditorUIView( editor.locale );
 
-		// This is the main editor element in DOM.
+		// This is the main editor element in the DOM.
 		view.element = $( '.ck-editor' );
 
-		// This is the editable view in DOM. It will replace the data container in DOM.
+		// This is the editable view in the DOM. It will replace the data container in the DOM.
 		view.editable = new InlineEditableUIView( editor.locale );
 
 		// References to the dropdown elements for further usage. See #_setupBootstrapHeadingDropdown.
@@ -274,7 +274,7 @@ class BootstrapEditorUI {
 		view.toolbarButtons = {};
 
 		[ 'bold', 'italic', 'underline', 'undo', 'redo' ].forEach( name => {
-			// Retrieve the jQuery object corresponding with the button in DOM.
+			// Retrieve the jQuery object corresponding with the button in the DOM.
 			view.toolbarButtons[ name ] = view.element.find( `#${ name }` );
 		} );
 
@@ -287,14 +287,14 @@ class BootstrapEditorUI {
 		const editor = this.editor;
 		const view = this.view;
 
-		// Render the editable component in DOM first.
+		// Render the editable component in the DOM first.
 		view.editable.render();
 
 		// Create an editing root in the editing layer. It will correspond with the
 		// document root created in the constructor().
 		const editingRoot = editor.editing.view.document.getRoot();
 
-		// Bind the basic attributes of the editable in DOM with the editing layer.
+		// Bind the basic attributes of the editable in the DOM with the editing layer.
 		view.editable.bind( 'isReadOnly' ).to( editingRoot );
 		view.editable.bind( 'isFocused' ).to( editor.editing.view.document );
 		view.editable.name = editingRoot.rootName;
@@ -344,7 +344,7 @@ command.on( 'change:isEnabled', ( evt, name, isEnabled ) => {
 	To learn more about editor commands, check out the {@link module:core/command~Command} API. You can also `console.log` the {@link module:core/editor/editor~Editor#commands `editor.commands`} collection of a live editor to learn which commands it offers.
 </info-box>
 
-Knowing that, let's fill out the missing methods of the `BootstrapEditorUI`.
+Knowing that, fill out the missing methods of the `BootstrapEditorUI`.
 
 ### Binding the buttons to editor commands
 
@@ -356,11 +356,11 @@ _setupBootstrapToolbarButtons() {
 	const editor = this.editor;
 
 	for ( const name in this.view.toolbarButtons ) {
-		// Retrieve the editor command corresponding with the id of the button in DOM.
+		// Retrieve the editor command corresponding with the ID of the button in the DOM.
 		const command = editor.commands.get( name );
 		const button = this.view.toolbarButtons[ name ];
 
-		// Clicking on the buttons should execute the editor command...
+		// Clicking the buttons should execute the editor command...
 		button.click( () => editor.execute( name ) );
 
 		// ...but it should not steal the focus so the editing is uninterrupted.
@@ -381,7 +381,7 @@ _setupBootstrapToolbarButtons() {
 
 		// Bold, Italic and Underline commands have a value that changes
 		// when the selection starts in an element the command creates.
-		// The button should indicate that e.g. editing text which is already bold.
+		// The button should indicate that e.g. you are editing text which is already bold.
 		if ( !new Set( [ 'undo', 'redo' ] ).has( name ) ) {
 			command.on( 'change:value', onValueChange );
 			onValueChange();
@@ -409,7 +409,7 @@ _setupBootstrapHeadingDropdown() {
 
 	// Create a dropdown menu entry for each heading configuration option.
 	editor.config.get( 'heading.options' ).map( option => {
-		// Check is options is paragraph or heading as their commands slightly differ.
+		// Check if options is a paragraph or a heading as their commands differ slightly.
 		const isParagraph = option.model === 'paragraph';
 
 		// Create the menu item DOM element.
@@ -490,4 +490,4 @@ BootstrapEditor
 	} );
 ```
 
-Once everything is working as expected, you may want to create a custom build of your editor to ship it across the applications. To learn more check out the {@link builds/guides/development/custom-builds Creating custom builds} guide.
+Once everything works as expected, you may want to create a custom build of your editor to ship it across the applications. To learn more check out the {@link builds/guides/development/custom-builds Creating custom builds} guide.

+ 4 - 0
packages/ckeditor5-ui/package.json

@@ -15,14 +15,18 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^10.0.0",
+    "@ckeditor/ckeditor5-block-quote": "^10.0.0",
     "@ckeditor/ckeditor5-cloud-services": "^10.0.0",
+    "@ckeditor/ckeditor5-editor-balloon": "^10.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^10.0.0",
     "@ckeditor/ckeditor5-engine": "^10.0.0",
     "@ckeditor/ckeditor5-enter": "^10.0.0",
     "@ckeditor/ckeditor5-easy-image": "^10.0.0",
+    "@ckeditor/ckeditor5-essentials": "^10.0.0",
     "@ckeditor/ckeditor5-heading": "^10.0.0",
     "@ckeditor/ckeditor5-image": "^10.0.0",
     "@ckeditor/ckeditor5-link": "^10.0.0",
+    "@ckeditor/ckeditor5-list": "^10.0.0",
     "@ckeditor/ckeditor5-paragraph": "^10.0.0",
     "@ckeditor/ckeditor5-typing": "^10.0.0",
     "@ckeditor/ckeditor5-undo": "^10.0.0",

+ 3 - 3
packages/ckeditor5-ui/src/template.js

@@ -1020,7 +1020,7 @@ function hasTemplateBinding( schema ) {
 //
 // @param {module:ui/template~TemplateValueSchema} schema
 // @param {Node} node DOM Node updated when {@link module:utils/observablemixin~ObservableMixin} changes.
-// @return {Array}
+// @returns {Array}
 function getValueSchemaValue( schema, node ) {
 	return schema.map( schemaItem => {
 		// Process {@link module:ui/template~TemplateBinding} bindings.
@@ -1708,7 +1708,7 @@ function shouldExtend( attrName ) {
  * @param {String|Function} eventNameOrFunctionOrAttribute An attribute name of
  * {@link module:utils/observablemixin~Observable} or a DOM event name or an event callback.
  * @param {Function} [callback] Allows for processing of the value. Accepts `Node` and `value` as arguments.
- * @return {module:ui/template~TemplateBinding}
+ * @returns {module:ui/template~TemplateBinding}
  */
 
 /**
@@ -1743,7 +1743,7 @@ function shouldExtend( attrName ) {
  * @param {String} [valueIfTrue] Value set when the {@link module:utils/observablemixin~Observable} attribute is not
  * undefined/null/false/'' (empty string).
  * @param {Function} [callback] Allows for processing of the value. Accepts `Node` and `value` as arguments.
- * @return {module:ui/template~TemplateBinding}
+ * @returns {module:ui/template~TemplateBinding}
  */
 
 /**

+ 60 - 0
packages/ckeditor5-ui/src/toolbar/block/blockbuttonview.js

@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2016 - 2017, CKSource - Frederico Knabben. All rights reserved.
+ */
+
+/**
+ * @module ui/toolbar/block/blockbuttonview
+ */
+
+import ButtonView from '../../button/buttonview';
+import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
+import '../../../theme/components/toolbar/blocktoolbar.css';
+
+const toPx = toUnit( 'px' );
+
+/**
+ * The block button view class.
+ *
+ * This view represents a button attached next to block element where the selection is anchored.
+ *
+ * See {@link module:ui/toolbar/block/blocktoolbar~BlockToolbar}.
+ *
+ * @extends {module:ui/button/buttonview~ButtonView}
+ */
+export default class BlockButtonView extends ButtonView {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		// Hide button on init.
+		this.isVisible = false;
+
+		/**
+		 * Top offset.
+		 *
+		 * @member {Number} #top
+		 */
+		this.set( 'top', 0 );
+
+		/**
+		 * Left offset.
+		 *
+		 * @member {Number} #left
+		 */
+		this.set( 'left', 0 );
+
+		this.extendTemplate( {
+			attributes: {
+				class: 'ck-block-toolbar-button',
+				style: {
+					top: bind.to( 'top', val => toPx( val ) ),
+					left: bind.to( 'left', val => toPx( val ) ),
+				}
+			}
+		} );
+	}
+}

+ 356 - 0
packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js

@@ -0,0 +1,356 @@
+/**
+ * Copyright (c) 2016 - 2017, CKSource - Frederico Knabben. All rights reserved.
+ */
+
+/**
+ * @module ui/toolbar/block/blocktoolbar
+ */
+
+/* global window */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import BlockButtonView from './blockbuttonview';
+import BalloonPanelView from '../../panel/balloon/balloonpanelview';
+import ToolbarView from '../toolbarview';
+
+import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
+import clickOutsideHandler from '../../bindings/clickoutsidehandler';
+
+import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+import iconPilcrow from '@ckeditor/ckeditor5-core/theme/icons/pilcrow.svg';
+
+/**
+ * The block toolbar plugin.
+ *
+ * This plugin provides the button positioned next to the block of content where the selection is anchored.
+ * Upon clicking the button, a drop–down providing editor features shows up, as configured in
+ * {@link module:core/editor/editorconfig~EditorConfig#blockToolbar}.
+ *
+ * By default, the button is displayed next to all elements marked in {@link module:engine/model/schema~Schema}
+ * as `$block` for which the toolbar provides at least one option.
+ *
+ * By default, the button is attached so its right boundary is touching the
+ * {@link module:engine/view/editableelement~EditableElement}:
+ *
+ * 		 __ |
+ * 		|  ||  This is a block of content that
+ * 		 ¯¯ |  button is attached to. This is a
+ * 		    |  block of content that button is
+ * 		    |  attached to.
+ *
+ * The position of the button can be adjusted using the CSS `transform`:
+ *
+ * 		.ck-block-toolbar-button {
+ * 			transform: translateX( -10px );
+ * 		}
+ *
+ * 		 __   |
+ * 		|  |  |  This is a block of content that
+ * 		 ¯¯   |  button is attached to. This is a
+ * 		      |  block of content that button is
+ * 		      |  attached to.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class BlockToolbar extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'BlockToolbar';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.editing.view.addObserver( ClickObserver );
+
+		/**
+		 * The toolbar view.
+		 *
+		 * @type {module:ui/toolbar/toolbarview~ToolbarView}
+		 */
+		this.toolbarView = this._createToolbarView();
+
+		/**
+		 * The balloon panel view, containing the {@link #toolbarView}.
+		 *
+		 * @type {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
+		 */
+		this.panelView = this._createPanelView();
+
+		/**
+		 * The button view, that opens the {@link #toolbarView}.
+		 *
+		 * @type {module:ui/toolbar/block/blockbuttonview~BlockButtonView}
+		 */
+		this.buttonView = this._createButtonView();
+
+		// Close the #panelView upon clicking outside of the plugin UI.
+		clickOutsideHandler( {
+			emitter: this.panelView,
+			contextElements: [ this.panelView.element, this.buttonView.element ],
+			activator: () => this.panelView.isVisible,
+			callback: () => this._hidePanel()
+		} );
+
+		// Try to hide button when the editor switches to the read-only mode.
+		// Do not hide when panel if already visible to avoid a confusing UX when the panel
+		// unexpectedly disappears.
+		this.listenTo( editor, 'change:isReadOnly', () => {
+			if ( !this.panelView.isVisible ) {
+				this.buttonView.isVisible = false;
+			}
+		} );
+
+		// Enable as default.
+		this._initListeners();
+	}
+
+	/**
+	 * Fill the toolbar with its items based on the configuration.
+	 *
+	 * **Note:** This needs to be done after all plugins are ready.
+	 *
+	 * @inheritDoc
+	 */
+	afterInit() {
+		const factory = this.editor.ui.componentFactory;
+		const config = this.editor.config.get( 'blockToolbar' );
+
+		this.toolbarView.fillFromConfig( config, factory );
+
+		// Hide panel before executing each button in the panel.
+		for ( const item of this.toolbarView.items ) {
+			item.on( 'execute', () => this._hidePanel( true ), { priority: 'high' } );
+		}
+	}
+
+	/**
+	 * Creates the {@link #toolbarView}.
+	 *
+	 * @private
+	 * @returns {module:ui/toolbar/toolbarview~ToolbarView}
+	 */
+	_createToolbarView() {
+		const toolbarView = new ToolbarView( this.editor.locale );
+
+		toolbarView.extendTemplate( {
+			attributes: {
+				class: [
+					// https://github.com/ckeditor/ckeditor5-editor-inline/issues/11
+					'ck-toolbar_floating'
+				]
+			}
+		} );
+
+		return toolbarView;
+	}
+
+	/**
+	 * Creates the {@link #panelView}.
+	 *
+	 * @private
+	 * @returns {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
+	 */
+	_createPanelView() {
+		const editor = this.editor;
+		const panelView = new BalloonPanelView( editor.locale );
+
+		panelView.content.add( this.toolbarView );
+		panelView.className = 'ck-toolbar-container';
+		editor.ui.view.body.add( panelView );
+		editor.ui.focusTracker.add( panelView.element );
+
+		// Close #panelView on `Esc` press.
+		this.toolbarView.keystrokes.set( 'Esc', ( evt, cancel ) => {
+			this._hidePanel( true );
+			cancel();
+		} );
+
+		return panelView;
+	}
+
+	/**
+	 * Creates the {@link #buttonView}.
+	 *
+	 * @private
+	 * @returns {module:ui/toolbar/block/blockbuttonview~BlockButtonView}
+	 */
+	_createButtonView() {
+		const editor = this.editor;
+		const buttonView = new BlockButtonView( editor.locale );
+
+		buttonView.set( {
+			label: editor.t( 'Edit block' ),
+			icon: iconPilcrow,
+			withText: false
+		} );
+
+		// Bind the panelView observable properties to the buttonView.
+		buttonView.bind( 'isOn' ).to( this.panelView, 'isVisible' );
+		buttonView.bind( 'tooltip' ).to( this.panelView, 'isVisible', isVisible => !isVisible );
+
+		// Toggle the panelView upon buttonView#execute.
+		this.listenTo( buttonView, 'execute', () => {
+			if ( !this.panelView.isVisible ) {
+				this._showPanel();
+			} else {
+				this._hidePanel( true );
+			}
+		} );
+
+		editor.ui.view.body.add( buttonView );
+		editor.ui.focusTracker.add( buttonView.element );
+
+		return buttonView;
+	}
+
+	/**
+	 * Starts displaying the button next to allowed elements.
+	 *
+	 * @private
+	 */
+	_initListeners() {
+		const editor = this.editor;
+		const model = editor.model;
+		const view = editor.editing.view;
+		let modelTarget, domTarget;
+
+		// Hides panel on a direct selection change.
+		this.listenTo( editor.model.document.selection, 'change:range', ( evt, data ) => {
+			if ( data.directChange ) {
+				this._hidePanel();
+			}
+		} );
+
+		this.listenTo( view, 'render', () => {
+			// Get first selected block, button will be attached to this element.
+			modelTarget = Array.from( model.document.selection.getSelectedBlocks() )[ 0 ];
+
+			// Do not attach block button when there is no enabled item in toolbar for current block element.
+			if ( !modelTarget || Array.from( this.toolbarView.items ).every( item => !item.isEnabled ) ) {
+				this.buttonView.isVisible = false;
+
+				return;
+			}
+
+			// Get DOM target element.
+			domTarget = view.domConverter.mapViewToDom( editor.editing.mapper.toViewElement( modelTarget ) );
+
+			// Show block button.
+			this.buttonView.isVisible = true;
+
+			// Attach block button to target DOM element.
+			this._attachButtonToElement( domTarget );
+
+			// When panel is opened then refresh it position to be properly aligned with block button.
+			if ( this.panelView.isVisible ) {
+				this._showPanel();
+			}
+		}, { priority: 'low' } );
+
+		this.listenTo( this.buttonView, 'change:isVisible', ( evt, name, isVisible ) => {
+			if ( isVisible ) {
+				// Keep correct position of button and panel on window#resize.
+				this.buttonView.listenTo( window, 'resize', () => this._attachButtonToElement( domTarget ) );
+			} else {
+				// Stop repositioning button when is hidden.
+				this.buttonView.stopListening( window, 'resize' );
+
+				// Hide the panel when the button disappears.
+				this._hidePanel();
+			}
+		} );
+	}
+
+	/**
+	 * Attaches the {@link #buttonView} to the target block of content.
+	 *
+	 * @protected
+	 * @param {HTMLElement} targetElement Target element.
+	 */
+	_attachButtonToElement( targetElement ) {
+		const contentStyles = window.getComputedStyle( targetElement );
+
+		const editableRect = new Rect( this.editor.ui.view.editableElement );
+		const contentPaddingTop = parseInt( contentStyles.paddingTop, 10 );
+		// When line height is not an integer then thread it as "normal".
+		// MDN says that 'normal' == ~1.2 on desktop browsers.
+		const contentLineHeight = parseInt( contentStyles.lineHeight, 10 ) || parseInt( contentStyles.fontSize, 10 ) * 1.2;
+
+		const position = getOptimalPosition( {
+			element: this.buttonView.element,
+			target: targetElement,
+			positions: [
+				( contentRect, buttonRect ) => {
+					return {
+						top: contentRect.top + contentPaddingTop + ( ( contentLineHeight - buttonRect.height ) / 2 ),
+						left: editableRect.left - buttonRect.width
+					};
+				}
+			]
+		} );
+
+		this.buttonView.top = position.top;
+		this.buttonView.left = position.left;
+	}
+
+	/**
+	 * Shows the {@link #toolbarView} attached to the {@link #buttonView}.
+	 * If the toolbar is already visible, then it simply repositions it.
+	 *
+	 * @private
+	 */
+	_showPanel() {
+		const wasVisible = this.panelView.isVisible;
+
+		this.panelView.pin( {
+			target: this.buttonView.element,
+			limiter: this.editor.ui.view.editableElement
+		} );
+
+		if ( !wasVisible ) {
+			this.toolbarView.items.get( 0 ).focus();
+		}
+	}
+
+	/**
+	 * Hides the {@link #toolbarView}.
+	 *
+	 * @private
+	 * @param {Boolean} [focusEditable=false] When `true` then editable will be focused after hiding panel.
+	 */
+	_hidePanel( focusEditable ) {
+		this.panelView.isVisible = false;
+
+		if ( focusEditable ) {
+			this.editor.editing.view.focus();
+		}
+	}
+}
+
+/**
+ * The block toolbar configuration. Used by the {@link module:ui/toolbar/block/blocktoolbar~BlockToolbar}
+ * feature.
+ *
+ *		const config = {
+ *			blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'bulletedList', 'numberedList' ]
+ *		};
+ *
+ * You can also use `'|'` to create a separator between groups of items:
+ *
+ *		const config = {
+ *			blockToolbar: [ 'paragraph', 'heading1', 'heading2', '|', 'bulletedList', 'numberedList' ]
+ *		};
+ *
+ * Read more about configuring the main editor toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}.
+ *
+ * @member {Array.<String>|Object} module:core/editor/editorconfig~EditorConfig#blockToolbar
+ */

+ 59 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.html

@@ -0,0 +1,59 @@
+<button class="external-type">Start external typing</button>
+<button class="external-delete">Start external deleting</button>
+
+<div class="wrapper">
+	<div id="editor">
+		<h2>The three greatest things you learn from traveling</h2>
+		<p>
+			Like all the great things on earth traveling teaches us by example. Here are some of the most precious lessons
+			I’ve learned over the years of traveling.
+		</p>
+
+		<figure class="image">
+			<img src="./umbrellas.jpg" alt="Three Monks walking on ancient temple.">
+			<figcaption>Leaving your comfort zone might lead you to such beautiful sceneries like this one.</figcaption>
+		</figure>
+
+		<h3>Appreciation of diversity</h3>
+		<p>
+			Getting used to an entirely different culture can be challenging. While it’s also nice to learn about
+			cultures online or from books, nothing comes close to experiencing cultural diversity in person.
+			You learn to appreciate each and every single one of the differences while you become more culturally fluid.
+		</p>
+
+		<h3>Improvisation</h3>
+		<p>
+			Life doesn't allow us to execute every single plan perfectly. This especially seems to be the case when
+			you travel. You plan it down to every minute with a big checklist; but when it comes to executing it,
+			something always comes up and you’re left with your improvising skills. You learn to adapt as you go.
+			Here’s how my travel checklist looks now:
+		</p>
+
+		<ul>
+			<li>buy the ticket</li>
+			<li>start your adventure</li>
+		</ul>
+
+		<h3>Confidence</h3>
+		<p>
+			Going to a new place can be quite terrifying. While change and uncertainty makes us scared, traveling
+			teaches us how ridiculous it is to be afraid of something before it happens. The moment you face your
+			fear and see there was nothing to be afraid of, is the moment you discover bliss.
+		</p>
+	</div>
+</div>
+
+<style>
+	#editor {
+		margin: 0 auto;
+		max-width: 800px;
+	}
+
+	.wrapper {
+		padding: 50px 20px;
+	}
+
+	.ck-block-toolbar-button {
+		transform: translateX( -10px );
+	}
+</style>

+ 104 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.js

@@ -0,0 +1,104 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window, document, console:false, setTimeout */
+
+import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui';
+import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui';
+import BlockToolbar from '../../../src/toolbar/block/blocktoolbar';
+
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+
+BalloonEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, List, Paragraph, Heading, Image, ImageCaption, HeadingButtonsUI, ParagraphButtonUI, BlockToolbar ],
+		blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const externalChanges = createExternalChangesSimulator( editor );
+
+		document.querySelector( '.external-type' ).addEventListener( 'click', () => {
+			externalChanges.wait( 4000 )
+				.then( () => externalChanges.insertNewLine( [ 1 ] ) )
+				.then( () => externalChanges.type( [ 1, 0 ], 'New line' ) )
+				.then( () => externalChanges.insertNewLine( [ 2 ] ) )
+				.then( () => externalChanges.type( [ 2, 0 ], 'New line' ) )
+				.then( () => externalChanges.insertNewLine( [ 3 ] ) )
+				.then( () => externalChanges.type( [ 3, 0 ], 'New line' ) );
+		} );
+
+		document.querySelector( '.external-delete' ).addEventListener( 'click', () => {
+			externalChanges.wait( 4000 )
+				.then( () => externalChanges.removeElement( [ 1 ] ) );
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+// Move it to the test utils.
+// See https://github.com/ckeditor/ckeditor5-ui/issues/393.
+function createExternalChangesSimulator( editor ) {
+	const { model } = editor;
+
+	function wait( delay ) {
+		return new Promise( resolve => setTimeout( () => resolve(), delay ) );
+	}
+
+	function insertNewLine( path ) {
+		model.enqueueChange( 'transparent', writer => {
+			writer.insertElement( 'paragraph', new Position( model.document.getRoot(), path ) );
+		} );
+
+		return Promise.resolve();
+	}
+
+	function type( path, text ) {
+		return new Promise( resolve => {
+			let position = new Position( model.document.getRoot(), path );
+			let index = 0;
+
+			function typing() {
+				wait( 40 ).then( () => {
+					model.enqueueChange( 'transparent', writer => {
+						writer.insertText( text[ index ], position );
+						position = position.getShiftedBy( 1 );
+
+						const nextLetter = text[ ++index ];
+
+						if ( nextLetter ) {
+							typing( nextLetter );
+						} else {
+							index = 0;
+							resolve();
+						}
+					} );
+				} );
+			}
+
+			typing();
+		} );
+	}
+
+	function removeElement( path ) {
+		model.enqueueChange( 'transparent', writer => {
+			writer.remove( Range.createFromPositionAndShift( new Position( model.document.getRoot(), path ), 1 ) );
+		} );
+
+		return Promise.resolve();
+	}
+
+	return { wait, insertNewLine, type, removeElement };
+}

+ 19 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.md

@@ -0,0 +1,19 @@
+## Block toolbar demo
+
+1. Check if the button appears next to all block elements except the image (no toolbar item available).
+2. Change the format of one of the block elements, panel attached to button should hide after that.
+3. Put the selection in the one of the last blocks, click the button to display the panel then start resizing the browser window and observe if the button and panel are properly repositioned.
+
+### External changes
+
+## Typing
+
+1. Click `Start external typing`
+2. Put the selection in the first paragraph (`Like all the great things...`) and click the button to open the panel (be quick).
+3. Check if the button and the panel are repositioned correctly.
+
+## Removing
+
+1. Click `Start external deleting`
+2. Put the selection in the first paragraph (`Like all the great things...`) and click the button to open the panel (be quick).
+3. Check if the button and the panel are reattached.

二进制
packages/ckeditor5-ui/tests/manual/blocktoolbar/umbrellas.jpg


+ 45 - 0
packages/ckeditor5-ui/tests/toolbar/block/blockbuttonview.js

@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2016 - 2017, CKSource - Frederico Knabben. All rights reserved.
+ */
+
+import BlockButtonView from '../../../src/toolbar/block/blockbuttonview';
+
+describe( 'BlockButtonView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new BlockButtonView();
+
+		view.render();
+	} );
+
+	it( 'should be not visible on init', () => {
+		expect( view.isVisible ).to.be.false;
+	} );
+
+	it( 'should create element from template', () => {
+		expect( view.element.classList.contains( 'ck-block-toolbar-button' ) ).to.be.true;
+	} );
+
+	describe( 'DOM binding', () => {
+		it( 'should react on `view#top` change', () => {
+			view.top = 0;
+
+			expect( view.element.style.top ).to.equal( '0px' );
+
+			view.top = 10;
+
+			expect( view.element.style.top ).to.equal( '10px' );
+		} );
+
+		it( 'should react on `view#left` change', () => {
+			view.left = 0;
+
+			expect( view.element.style.left ).to.equal( '0px' );
+
+			view.left = 10;
+
+			expect( view.element.style.left ).to.equal( '10px' );
+		} );
+	} );
+} );

+ 428 - 0
packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js

@@ -0,0 +1,428 @@
+/**
+ * Copyright (c) 2016 - 2017, CKSource - Frederico Knabben. All rights reserved.
+ */
+
+/* global document, window, Event */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
+
+import BlockToolbar from '../../../src/toolbar/block/blocktoolbar';
+import ToolbarView from '../../../src/toolbar/toolbarview';
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
+import BlockButtonView from '../../../src/toolbar/block/blockbuttonview';
+
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ParagraphButtonUI from '@ckeditor/ckeditor5-paragraph/src/paragraphbuttonui';
+import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+testUtils.createSinonSandbox();
+
+describe( 'BlockToolbar', () => {
+	let editor, element, blockToolbar;
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor.create( element, {
+			plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote, Image, ImageCaption ],
+			blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			blockToolbar = editor.plugins.get( BlockToolbar );
+		} );
+	} );
+
+	afterEach( () => {
+		element.remove();
+		return editor.destroy();
+	} );
+
+	it( 'should have pluginName property', () => {
+		expect( BlockToolbar.pluginName ).to.equal( 'BlockToolbar' );
+	} );
+
+	it( 'should register a click observer', () => {
+		expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver );
+	} );
+
+	describe( 'child views', () => {
+		describe( 'panelView', () => {
+			it( 'should create a view instance', () => {
+				expect( blockToolbar.panelView ).to.instanceof( BalloonPanelView );
+			} );
+
+			it( 'should have an additional class name', () => {
+				expect( blockToolbar.panelView.className ).to.equal( 'ck-toolbar-container' );
+			} );
+
+			it( 'should be added to the ui.view.body collection', () => {
+				expect( Array.from( editor.ui.view.body ) ).to.include( blockToolbar.panelView );
+			} );
+
+			it( 'should add the #panelView to ui.focusTracker', () => {
+				expect( editor.ui.focusTracker.isFocused ).to.be.false;
+
+				blockToolbar.panelView.element.dispatchEvent( new Event( 'focus' ) );
+
+				expect( editor.ui.focusTracker.isFocused ).to.be.true;
+			} );
+
+			it( 'should close the #panelView after `Esc` is pressed and focus view document', () => {
+				const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+				blockToolbar.panelView.isVisible = true;
+
+				blockToolbar.toolbarView.keystrokes.press( {
+					keyCode: keyCodes.esc,
+					preventDefault: () => {},
+					stopPropagation: () => {}
+				} );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.false;
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should close the #panelView upon click outside the panel and not focus view document', () => {
+				const spy = testUtils.sinon.spy();
+
+				editor.editing.view.on( 'focus', spy );
+				blockToolbar.panelView.isVisible = true;
+				document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.false;
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should not close the #panelView upon click on panel element', () => {
+				blockToolbar.panelView.isVisible = true;
+				blockToolbar.panelView.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.true;
+			} );
+		} );
+
+		describe( 'toolbarView', () => {
+			it( 'should create the view instance', () => {
+				expect( blockToolbar.toolbarView ).to.instanceof( ToolbarView );
+			} );
+
+			it( 'should add an additional class to toolbar element', () => {
+				expect( blockToolbar.toolbarView.element.classList.contains( 'ck-toolbar_floating' ) ).to.be.true;
+			} );
+
+			it( 'should be added to the panelView#content collection', () => {
+				expect( Array.from( blockToolbar.panelView.content ) ).to.include( blockToolbar.toolbarView );
+			} );
+
+			it( 'should initialize the toolbar items based on Editor#blockToolbar config', () => {
+				expect( Array.from( blockToolbar.toolbarView.items ) ).to.length( 4 );
+			} );
+
+			it( 'should hide the panel after clicking on the button from toolbar', () => {
+				blockToolbar.buttonView.fire( 'execute' );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.true;
+
+				blockToolbar.toolbarView.items.get( 0 ).fire( 'execute' );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.false;
+			} );
+		} );
+
+		describe( 'buttonView', () => {
+			it( 'should create a view instance', () => {
+				expect( blockToolbar.buttonView ).to.instanceof( BlockButtonView );
+			} );
+
+			it( 'should be added to the editor ui.view.body collection', () => {
+				expect( Array.from( editor.ui.view.body ) ).to.include( blockToolbar.buttonView );
+			} );
+
+			it( 'should add the #buttonView to the ui.focusTracker', () => {
+				expect( editor.ui.focusTracker.isFocused ).to.be.false;
+
+				blockToolbar.buttonView.element.dispatchEvent( new Event( 'focus' ) );
+
+				expect( editor.ui.focusTracker.isFocused ).to.be.true;
+			} );
+
+			it( 'should pin the #panelView to the button and focus first item in toolbar on #execute event', () => {
+				expect( blockToolbar.panelView.isVisible ).to.be.false;
+
+				const pinSpy = testUtils.sinon.spy( blockToolbar.panelView, 'pin' );
+				const focusSpy = testUtils.sinon.spy( blockToolbar.toolbarView.items.get( 0 ), 'focus' );
+
+				blockToolbar.buttonView.fire( 'execute' );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.true;
+				sinon.assert.calledWith( pinSpy, {
+					target: blockToolbar.buttonView.element,
+					limiter: editor.ui.view.editableElement
+				} );
+				sinon.assert.calledOnce( focusSpy );
+			} );
+
+			it( 'should hide the #panelView and focus the editable on #execute event when panel was visible', () => {
+				blockToolbar.panelView.isVisible = true;
+				const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+				blockToolbar.buttonView.fire( 'execute' );
+
+				expect( blockToolbar.panelView.isVisible ).to.be.false;
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should bind #isOn to panelView#isVisible', () => {
+				blockToolbar.panelView.isVisible = false;
+
+				expect( blockToolbar.buttonView.isOn ).to.be.false;
+
+				blockToolbar.panelView.isVisible = true;
+
+				expect( blockToolbar.buttonView.isOn ).to.be.true;
+			} );
+
+			it( 'should hide the #button tooltip when the #panelView is open', () => {
+				blockToolbar.panelView.isVisible = false;
+
+				expect( blockToolbar.buttonView.tooltip ).to.be.true;
+
+				blockToolbar.panelView.isVisible = true;
+
+				expect( blockToolbar.buttonView.tooltip ).to.be.false;
+			} );
+		} );
+	} );
+
+	describe( 'allowed elements', () => {
+		it( 'should display the button when the first selected block is a block element', () => {
+			editor.model.schema.register( 'foo', { inheritAllFrom: '$block' } );
+			editor.conversion.elementToElement( { model: 'foo', view: 'foo' } );
+
+			setData( editor.model, '<foo>foo[]bar</foo>' );
+
+			expect( blockToolbar.buttonView.isVisible ).to.be.true;
+		} );
+
+		it( 'should display the button when the first selected block is an object', () => {
+			setData( editor.model, '[<image src="foo.jpg"><caption>foo</caption></image>]' );
+
+			expect( blockToolbar.buttonView.isVisible ).to.be.true;
+		} );
+
+		it( 'should display the button when the selection is inside the object', () => {
+			setData( editor.model, '<image src="foo.jpg"><caption>f[]oo</caption></image>' );
+
+			expect( blockToolbar.buttonView.isVisible ).to.be.true;
+		} );
+
+		it( 'should not display the button when the selection is placed in a root element', () => {
+			setData( editor.model, '<paragraph>foo</paragraph>[]<paragraph>bar</paragraph>' );
+
+			expect( blockToolbar.buttonView.isVisible ).to.be.false;
+		} );
+
+		it( 'should not display the button when all toolbar items are disabled for the selected element', () => {
+			const element = document.createElement( 'div' );
+
+			document.body.appendChild( element );
+
+			return ClassicTestEditor.create( element, {
+				plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, Image ],
+				blockToolbar: [ 'paragraph', 'heading1', 'heading2' ]
+			} ).then( editor => {
+				const blockToolbar = editor.plugins.get( BlockToolbar );
+
+				setData( editor.model, '[<image src="foo.jpg"></image>]' );
+
+				expect( blockToolbar.buttonView.isVisible ).to.be.false;
+
+				element.remove();
+
+				return editor.destroy();
+			} );
+		} );
+	} );
+
+	describe( 'attaching the button to the content', () => {
+		beforeEach( () => {
+			// Be sure that window is not scrolled.
+			testUtils.sinon.stub( window, 'scrollX' ).get( () => 0 );
+			testUtils.sinon.stub( window, 'scrollY' ).get( () => 0 );
+		} );
+
+		it( 'should attach the right side of the button to the left side of the editable and center with the first line ' +
+			'of the selected block #1', () => {
+			setData( editor.model, '<paragraph>foo[]bar</paragraph>' );
+
+			const target = editor.ui.view.editableElement.querySelector( 'p' );
+			const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' );
+
+			styleMock.withArgs( target ).returns( {
+				lineHeight: '20px',
+				paddingTop: '10px'
+			} );
+
+			styleMock.callThrough();
+
+			testUtils.sinon.stub( editor.ui.view.editableElement, 'getBoundingClientRect' ).returns( {
+				left: 200
+			} );
+
+			testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( {
+				top: 500,
+				left: 300
+			} );
+
+			testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( {
+				width: 100,
+				height: 100
+			} );
+
+			editor.editing.view.fire( 'render' );
+
+			expect( blockToolbar.buttonView.top ).to.equal( 470 );
+			expect( blockToolbar.buttonView.left ).to.equal( 100 );
+		} );
+
+		it( 'should attach the right side of the button to the left side of the editable and center with the first line ' +
+			'of the selected block #2', () => {
+			setData( editor.model, '<paragraph>foo[]bar</paragraph>' );
+
+			const target = editor.ui.view.editableElement.querySelector( 'p' );
+			const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' );
+
+			styleMock.withArgs( target ).returns( {
+				lineHeight: 'normal',
+				fontSize: '20px',
+				paddingTop: '10px'
+			} );
+
+			styleMock.callThrough();
+
+			testUtils.sinon.stub( editor.ui.view.editableElement, 'getBoundingClientRect' ).returns( {
+				left: 200
+			} );
+
+			testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( {
+				top: 500,
+				left: 300
+			} );
+
+			testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( {
+				width: 100,
+				height: 100
+			} );
+
+			editor.editing.view.fire( 'render' );
+
+			expect( blockToolbar.buttonView.top ).to.equal( 472 );
+			expect( blockToolbar.buttonView.left ).to.equal( 100 );
+		} );
+
+		it( 'should reposition the #panelView when open on view#render', () => {
+			blockToolbar.panelView.isVisible = false;
+
+			const spy = testUtils.sinon.spy( blockToolbar.panelView, 'pin' );
+
+			editor.editing.view.fire( 'render' );
+
+			sinon.assert.notCalled( spy );
+
+			blockToolbar.panelView.isVisible = true;
+
+			editor.editing.view.fire( 'render' );
+
+			sinon.assert.calledWith( spy, {
+				target: blockToolbar.buttonView.element,
+				limiter: editor.ui.view.editableElement
+			} );
+		} );
+
+		it( 'should not reset the toolbar focus on view#render', () => {
+			blockToolbar.panelView.isVisible = true;
+
+			const spy = testUtils.sinon.spy( blockToolbar.toolbarView, 'focus' );
+
+			editor.editing.view.fire( 'render' );
+
+			sinon.assert.notCalled( spy );
+		} );
+
+		it( 'should hide the open panel on a direct selection change', () => {
+			blockToolbar.panelView.isVisible = true;
+
+			editor.model.document.selection.fire( 'change:range', { directChange: true } );
+
+			expect( blockToolbar.panelView.isVisible ).to.be.false;
+		} );
+
+		it( 'should not hide the open panel on a indirect selection change', () => {
+			blockToolbar.panelView.isVisible = true;
+
+			editor.model.document.selection.fire( 'change:range', { directChange: false } );
+
+			expect( blockToolbar.panelView.isVisible ).to.be.true;
+		} );
+
+		it( 'should hide the UI when editor switches to readonly when the panel is not visible', () => {
+			setData( editor.model, '<paragraph>foo[]bar</paragraph>' );
+
+			blockToolbar.buttonView.isVisible = true;
+			blockToolbar.panelView.isVisible = false;
+
+			editor.isReadOnly = true;
+
+			expect( blockToolbar.buttonView.isVisible ).to.be.false;
+			expect( blockToolbar.panelView.isVisible ).to.be.false;
+		} );
+
+		it( 'should not hide button when the editor switches to readonly when the panel is visible', () => {
+			setData( editor.model, '<paragraph>foo[]bar</paragraph>' );
+
+			blockToolbar.buttonView.isVisible = true;
+			blockToolbar.panelView.isVisible = true;
+
+			editor.isReadOnly = true;
+
+			expect( blockToolbar.buttonView.isVisible ).to.be.true;
+			expect( blockToolbar.panelView.isVisible ).to.be.true;
+		} );
+
+		it( 'should update the button position on browser resize only when the button is visible', () => {
+			const spy = testUtils.sinon.spy( blockToolbar, '_attachButtonToElement' );
+
+			setData( editor.model, '[]<paragraph>bar</paragraph>' );
+
+			window.dispatchEvent( new Event( 'resize' ) );
+
+			sinon.assert.notCalled( spy );
+
+			setData( editor.model, '<paragraph>ba[]r</paragraph>' );
+
+			spy.resetHistory();
+
+			window.dispatchEvent( new Event( 'resize' ) );
+
+			sinon.assert.called( spy );
+
+			setData( editor.model, '[]<paragraph>bar</paragraph>' );
+
+			spy.resetHistory();
+
+			window.dispatchEvent( new Event( 'resize' ) );
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+} );

+ 9 - 0
packages/ckeditor5-ui/theme/components/toolbar/blocktoolbar.css

@@ -0,0 +1,9 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck.ck-block-toolbar-button {
+	position: absolute;
+	z-index: var(--ck-z-default);
+}

+ 20 - 0
packages/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css

@@ -10,6 +10,26 @@
 @define-mixin ck-tooltip_enabled {
 	& .ck-tooltip {
 		display: block;
+
+		/*
+		 * Don't display tooltips in devices which don't support :hover.
+		 * In fact, it's all about iOS, which forces user to click UI elements twice to execute
+		 * the primary action, when tooltips are enabled.
+		 *
+		 * Q: OK, but why not the following query?
+		 *
+		 *   @media (hover) {
+		 *       display: block;
+		 *   }
+		 *
+		 * A: Because FF does not support it and it would completely disable tooltips
+		 * in that browser.
+		 *
+		 * More in https://github.com/ckeditor/ckeditor5/issues/920.
+		 */
+		@media (hover:none) {
+			display: none;
+		}
 	}
 }