diff --git a/docs/api/widget.md b/docs/api/widget.md index 4ad8a0b6..da1688f5 100644 --- a/docs/api/widget.md +++ b/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. + 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. diff --git a/package.json b/package.json index d778b279..a41ed02b 100644 --- a/package.json +++ b/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", diff --git a/src/widgettoolbarrepository.js b/src/widgettoolbarrepository.js new file mode 100644 index 00000000..5497d8b4 --- /dev/null +++ b/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.} #_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.} 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 ) ); +} diff --git a/tests/widgettoolbarrepository.js b/tests/widgettoolbarrepository.js new file mode 100644 index 00000000..81ea441a --- /dev/null +++ b/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, 'foo[]' ); + + 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, '[foo]' ); + + 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, 'foo[]' ); + + model.change( writer => { + // Select the foo. + 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, '[]' ); + + 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, '[foo]' ); + + 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, '[]' ); + + 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, '[]foo' ); + 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, '[foo]' ); + 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 `` 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' ); + } + } ) ); + } +}