ソースを参照

Merge pull request #54 from ckeditor/t/ckeditor5-ui/442

Feature: Introduced the widget toolbar repository. Closes ckeditor/ckeditor5-ui#442.
Piotr Jasiun 7 年 前
コミット
5607f0c5a6

+ 5 - 0
packages/ckeditor5-widget/docs/api/widget.md

@@ -19,6 +19,11 @@ The widget API consists of two layers:
 * The {@link module:widget/widget~Widget} plugin which enables base support for this feature. Usually, your plugin which implements a specific widget will define its reliance on the `Widget` plugin via its {@link module:core/plugin~Plugin.requires `Plugin.requires`} property.
 * The {@link module:widget/utils~toWidget `toWidget()`} {@link module:widget/utils~toWidgetEditable `toWidgetEditable()`} functions which need to be used during the conversion in order to make a specific element either a widget or a widget's nested editable. See their documentation for more details.
 
+Besides the above mentioned core functionalities, this package implements the following utils:
+
+* The {@link module:widget/widgettoolbarrepository~WidgetToolbarRepository `WidgetToolbarRepository`} plugin which exposes a nice API for registering widget toolbars.
+* A couple of helper functions for managing widgets in the {@link module:widget/utils `@ckeditor/ckeditor5-widget/utils`} module.
+
 <info-box>
 	The widget API is proposed in a very different way than it was in CKEditor 4. It is just a set of utilities that allow you to implement typical object-like entities. Most of the work actually happens in the {@link api/engine engine} and this API's role is only to properly conduct the engine.
 </info-box>

+ 1 - 0
packages/ckeditor5-widget/package.json

@@ -17,6 +17,7 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^10.0.2",
+    "@ckeditor/ckeditor5-editor-balloon": "^11.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^11.0.0",
     "@ckeditor/ckeditor5-essentials": "^10.1.1",
     "@ckeditor/ckeditor5-paragraph": "^10.0.2",

+ 240 - 0
packages/ckeditor5-widget/src/widgettoolbarrepository.js

@@ -0,0 +1,240 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module widget/widgettoolbarrepository
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { isWidget } from './utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * Widget toolbar repository plugin. A central point for registering widget toolbars. This plugin handles the whole
+ * toolbar rendering process and exposes a concise API.
+ *
+ * To add a toolbar for your widget use the {@link ~WidgetToolbarRepository#register `WidgetToolbarRepository#register()`} method.
+ *
+ * The following example comes from the {@link module:image/imagetoolbar~ImageToolbar} plugin:
+ *
+ * 		class ImageToolbar extends Plugin {
+ *			static get requires() {
+ *				return [ WidgetToolbarRepository ];
+ *			}
+ *
+ *			afterInit() {
+ *				const editor = this.editor;
+ *				const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
+ *
+ *				widgetToolbarRepository.register( 'image', {
+ *					items: editor.config.get( 'image.toolbar' ),
+ *					visibleWhen: viewSelection => isImageWidgetSelected( viewSelection )
+ *				} );
+ *			}
+ *		}
+ */
+export default class WidgetToolbarRepository extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'WidgetToolbarRepository';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const balloonToolbar = editor.plugins.get( 'BalloonToolbar' );
+
+		// Disables the default balloon toolbar for all widgets.
+		if ( balloonToolbar ) {
+			this.listenTo( balloonToolbar, 'show', evt => {
+				if ( isWidgetSelected( editor.editing.view.document.selection ) ) {
+					evt.stop();
+				}
+			}, { priority: 'high' } );
+		}
+
+		/**
+		 * A map of toolbars.
+		 *
+		 * @protected
+		 * @member {Map.<string,Object>} #_toolbars
+		 */
+		this._toolbars = new Map();
+
+		/**
+		 * @private
+		 */
+		this._balloon = this.editor.plugins.get( 'ContextualBalloon' );
+
+		this.listenTo( editor.ui, 'update', () => {
+			this._updateToolbarsVisibility();
+		} );
+
+		// UI#update is not fired after focus is back in editor, we need to check if balloon panel should be visible.
+		this.listenTo( editor.ui.focusTracker, 'change:isFocused', () => {
+			this._updateToolbarsVisibility();
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * Registers toolbar in the WidgetToolbarRepository. It renders it in the `ContextualBalloon` based on the value of the invoked
+	 * `visibleWhen` function. Toolbar items are gathered from `items` array.
+	 * The balloon's CSS class is by default `ck-toolbar-container` and may be override with the `balloonClassName` option.
+	 *
+	 * Note: This method should be called in the {@link module:core/plugin~PluginInterface#afterInit `Plugin#afterInit()`}
+	 * callback (or later) to make sure that the given toolbar items were already registered by other plugins.
+	 *
+	 * @param {String} toolbarId An id for the toolbar. Used to
+	 * @param {Object} options
+	 * @param {Array.<String>} options.items Array of toolbar items.
+	 * @param {Function} options.visibleWhen Callback which specifies when the toolbar should be visible for the widget.
+	 * @param {String} [options.balloonClassName='ck-toolbar-container'] CSS class for the widget balloon.
+	 */
+	register( toolbarId, { items, visibleWhen, balloonClassName = 'ck-toolbar-container' } ) {
+		const editor = this.editor;
+		const toolbarView = new ToolbarView();
+
+		if ( this._toolbars.has( toolbarId ) ) {
+			/**
+			 * Toolbar with the given id was already added.
+			 *
+			 * @error widget-toolbar-duplicated
+			 * @param toolbarId Toolbar id.
+			 */
+			throw new CKEditorError( 'widget-toolbar-duplicated: Toolbar with the given id was already added.', { toolbarId } );
+		}
+
+		toolbarView.fillFromConfig( items, editor.ui.componentFactory );
+
+		this._toolbars.set( toolbarId, {
+			view: toolbarView,
+			visibleWhen,
+			balloonClassName,
+		} );
+	}
+
+	/**
+	 * Iterates over stored toolbars and makes them visible or hidden.
+	 *
+	 * @private
+	 */
+	_updateToolbarsVisibility() {
+		for ( const toolbar of this._toolbars.values() ) {
+			if ( !this.editor.ui.focusTracker.isFocused || !toolbar.visibleWhen( this.editor.editing.view.document.selection ) ) {
+				this._hideToolbar( toolbar );
+			} else {
+				this._showToolbar( toolbar );
+			}
+		}
+	}
+
+	/**
+	 * Hides the given toolbar.
+	 *
+	 * @private
+	 * @param {Object} toolbar
+	 */
+	_hideToolbar( toolbar ) {
+		if ( !this._isToolbarVisible( toolbar ) ) {
+			return;
+		}
+
+		this._balloon.remove( toolbar.view );
+	}
+
+	/**
+	 * Shows up the toolbar if the toolbar is not visible and repositions the toolbar's balloon when toolbar's
+	 * view is the most top view in balloon stack.
+	 *
+	 * It might happen here that the toolbar's view is under another view. Then do nothing as the other toolbar view
+	 * should be still visible after the {@link module:core/editor/editorui~EditorUI#event:update}.
+	 *
+	 * @private
+	 * @param {Object} toolbar
+	 */
+	_showToolbar( toolbar ) {
+		if ( this._isToolbarVisible( toolbar ) ) {
+			repositionContextualBalloon( this.editor );
+		} else if ( !this._balloon.hasView( toolbar.view ) ) {
+			this._balloon.add( {
+				view: toolbar.view,
+				position: getBalloonPositionData( this.editor ),
+				balloonClassName: toolbar.balloonClassName,
+			} );
+		}
+	}
+
+	/**
+	 * @private
+	 * @param {Object} toolbar
+	 */
+	_isToolbarVisible( toolbar ) {
+		return this._balloon.visibleView == toolbar.view;
+	}
+}
+
+function repositionContextualBalloon( editor ) {
+	const balloon = editor.plugins.get( 'ContextualBalloon' );
+	const position = getBalloonPositionData( editor );
+
+	balloon.updatePosition( position );
+}
+
+function getBalloonPositionData( editor ) {
+	const editingView = editor.editing.view;
+	const defaultPositions = BalloonPanelView.defaultPositions;
+	const widget = getParentWidget( editingView.document.selection );
+
+	return {
+		target: editingView.domConverter.viewToDom( widget ),
+		positions: [
+			defaultPositions.northArrowSouth,
+			defaultPositions.northArrowSouthWest,
+			defaultPositions.northArrowSouthEast,
+			defaultPositions.southArrowNorth,
+			defaultPositions.southArrowNorthWest,
+			defaultPositions.southArrowNorthEast
+		]
+	};
+}
+
+function getParentWidget( selection ) {
+	const selectedElement = selection.getSelectedElement();
+
+	if ( selectedElement && isWidget( selectedElement ) ) {
+		return selectedElement;
+	}
+
+	const position = selection.getFirstPosition();
+	let parent = position.parent;
+
+	while ( parent ) {
+		if ( parent.is( 'element' ) && isWidget( parent ) ) {
+			return parent;
+		}
+
+		parent = parent.parent;
+	}
+}
+
+function isWidgetSelected( selection ) {
+	const viewElement = selection.getSelectedElement();
+
+	return !!( viewElement && isWidget( viewElement ) );
+}

+ 344 - 0
packages/ckeditor5-widget/tests/widgettoolbarrepository.js

@@ -0,0 +1,344 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Widget from '../src/widget';
+import WidgetToolbarRepository from '../src/widgettoolbarrepository';
+import { isWidget, toWidget } from '../src/utils';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'WidgetToolbarRepository', () => {
+	let editor, model, balloon, widgetToolbarRepository, editorElement;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget ],
+				fake: {
+					toolbar: [ 'fake_button' ]
+				}
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = newEditor.model;
+				widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
+				balloon = editor.plugins.get( 'ContextualBalloon' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( WidgetToolbarRepository ) ).to.be.instanceOf( WidgetToolbarRepository );
+	} );
+
+	describe( 'register()', () => {
+		it( 'should create a widget toolbar and add it to the collection', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: () => false,
+			} );
+
+			expect( widgetToolbarRepository._toolbars.size ).to.equal( 1 );
+			expect( widgetToolbarRepository._toolbars.get( 'fake' ) ).to.be.an( 'object' );
+		} );
+
+		it( 'should throw when adding two times widget with the same id', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: () => false
+			} );
+
+			expect( () => {
+				widgetToolbarRepository.register( 'fake', {
+					items: editor.config.get( 'fake.toolbar' ),
+					visibleWhen: () => false
+				} );
+			} ).to.throw( CKEditorError, /^widget-toolbar-duplicated/ );
+		} );
+	} );
+
+	describe( 'integration tests', () => {
+		beforeEach( () => {
+			editor.ui.focusTracker.isFocused = true;
+		} );
+
+		it( 'toolbar should be visible when the `visibleWhen` callback returns true', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: isFakeWidgetSelected
+			} );
+
+			setData( model, '<paragraph>foo</paragraph>[<fake-widget></fake-widget>]' );
+
+			const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+			expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+		} );
+
+		it( 'toolbar should be hidden when the `visibleWhen` callback returns false', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: isFakeWidgetSelected
+			} );
+
+			setData( model, '[<paragraph>foo</paragraph>]<fake-widget></fake-widget>' );
+
+			expect( balloon.visibleView ).to.equal( null );
+		} );
+
+		it( 'toolbar should be hidden when the `visibleWhen` callback returns false #2', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: isFakeWidgetSelected
+			} );
+
+			setData( model, '<paragraph>foo</paragraph>[<fake-widget></fake-widget>]' );
+
+			model.change( writer => {
+				// Select the <paragraph>foo</paragraph>.
+				writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' );
+			} );
+
+			expect( balloon.visibleView ).to.equal( null );
+		} );
+
+		it( 'toolbar should update its position when other widget is selected', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: isFakeWidgetSelected
+			} );
+
+			setData( model, '[<fake-widget></fake-widget>]<fake-widget></fake-widget>' );
+
+			model.change( writer => {
+				// Select the second widget.
+				writer.setSelection( model.document.getRoot().getChild( 1 ), 'on' );
+			} );
+
+			const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+			expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+		} );
+
+		it( 'it should be possible to create a widget toolbar for content inside the widget', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: isFakeWidgetContentSelected
+			} );
+
+			setData( model, '<fake-widget>[foo]</fake-widget>' );
+
+			const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+			expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+		} );
+
+		it( 'toolbar should not engage when is in the balloon yet invisible', () => {
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				visibleWhen: isFakeWidgetSelected
+			} );
+
+			const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+			setData( model, '[<fake-widget></fake-widget>]' );
+
+			expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+
+			const lastView = new View();
+			lastView.element = document.createElement( 'div' );
+
+			balloon.add( {
+				view: lastView,
+				position: {
+					target: document.body
+				}
+			} );
+
+			expect( balloon.visibleView ).to.equal( lastView );
+
+			editor.ui.fire( 'update' );
+
+			expect( balloon.visibleView ).to.equal( lastView );
+		} );
+	} );
+} );
+
+describe( 'WidgetToolbarRepository - integration with the BalloonToolbar', () => {
+	let clock, editor, model, balloon, balloonToolbar, widgetToolbarRepository, editorElement;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+		clock = testUtils.sinon.useFakeTimers();
+
+		return BalloonEditor
+			.create( editorElement, {
+				plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget, Bold ],
+				balloonToolbar: [ 'bold' ],
+				fake: {
+					toolbar: [ 'fake_button' ]
+				}
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = newEditor.model;
+				widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
+				balloon = editor.plugins.get( 'ContextualBalloon' );
+				balloonToolbar = editor.plugins.get( 'BalloonToolbar' );
+
+				editor.editing.view.document.isFocused = true;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'balloon toolbar should be hidden when the widget is selected', () => {
+		widgetToolbarRepository.register( 'fake', {
+			items: editor.config.get( 'fake.toolbar' ),
+			visibleWhen: isFakeWidgetSelected,
+		} );
+
+		const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+		setData( model, '[<fake-widget></fake-widget>]<paragraph>foo</paragraph>' );
+		editor.ui.focusTracker.isFocused = true;
+
+		clock.tick( 200 );
+
+		expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+	} );
+
+	it( 'balloon toolbar should be visible when the widget is not selected', () => {
+		widgetToolbarRepository.register( 'fake', {
+			items: editor.config.get( 'fake.toolbar' ),
+			visibleWhen: isFakeWidgetSelected
+		} );
+
+		setData( model, '<fake-widget></fake-widget><paragraph>[foo]</paragraph>' );
+		editor.ui.focusTracker.isFocused = true;
+
+		clock.tick( 200 );
+
+		expect( balloon.visibleView ).to.equal( balloonToolbar.toolbarView );
+	} );
+} );
+
+const fakeWidgetSymbol = Symbol( 'fakeWidget' );
+
+function isFakeWidgetSelected( selection ) {
+	const viewElement = selection.getSelectedElement();
+
+	return !!viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( fakeWidgetSymbol );
+}
+
+function isFakeWidgetContentSelected( selection ) {
+	const pos = selection.getFirstPosition();
+	let node = pos.parent;
+
+	while ( node ) {
+		if ( node.is( 'element' ) && isWidget( node ) && node.getCustomProperty( fakeWidgetSymbol ) ) {
+			return true;
+		}
+
+		node = node.parent;
+	}
+
+	return false;
+}
+
+// Plugin that adds fake_button to editor's component factory.
+class FakeButton extends Plugin {
+	init() {
+		this.editor.ui.componentFactory.add( 'fake_button', locale => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: 'fake button'
+			} );
+
+			return view;
+		} );
+	}
+}
+
+// Simple widget plugin
+// It registers `<fake-widget>` block in model and represents `div` in the view.
+// It allows having text inside self.
+class FakeWidget extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+
+		schema.register( 'fake-widget', {
+			isObject: true,
+			isBlock: true,
+			allowWhere: '$block',
+		} );
+
+		schema.extend( '$text', { allowIn: 'fake-widget' } );
+
+		const conversion = editor.conversion;
+
+		conversion.for( 'dataDowncast' ).add( downcastElementToElement( {
+			model: 'fake-widget',
+			view: ( modelElement, viewWriter ) => {
+				return viewWriter.createContainerElement( 'div' );
+			}
+		} ) );
+
+		conversion.for( 'editingDowncast' ).add( downcastElementToElement( {
+			model: 'fake-widget',
+			view: ( modelElement, viewWriter ) => {
+				const fakeWidget = viewWriter.createContainerElement( 'div' );
+				viewWriter.setCustomProperty( fakeWidgetSymbol, true, fakeWidget );
+
+				return toWidget( fakeWidget, viewWriter, { label: 'fake-widget' } );
+			}
+		} ) );
+
+		conversion.for( 'upcast' ).add( upcastElementToElement( {
+			view: {
+				name: 'div'
+			},
+			model: ( view, modelWriter ) => {
+				return modelWriter.createElement( 'fake-widget' );
+			}
+		} ) );
+	}
+}