Explorar el Código

Merge branch 'master' into t/389

Aleksander Nowodzinski hace 7 años
padre
commit
f616b867b1

+ 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",

+ 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.

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

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

@@ -0,0 +1,422 @@
+/**
+ * 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', () => {
+		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);
+}