diff --git a/src/widgettoolbarrepository.js b/src/widgettoolbarrepository.js index d273e5c0..3af1c7f7 100644 --- a/src/widgettoolbarrepository.js +++ b/src/widgettoolbarrepository.js @@ -33,7 +33,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; * * widgetToolbarRepository.register( 'image', { * items: editor.config.get( 'image.toolbar' ), - * visibleWhen: viewSelection => isImageWidgetSelected( viewSelection ) + * getRelatedElement: getSelectedImageWidget * } ); * } * } @@ -71,12 +71,12 @@ export default class WidgetToolbarRepository extends Plugin { } /** - * A map of toolbars. + * A map of toolbar definitions. * * @protected - * @member {Map.} #_toolbars + * @member {Map.} #_toolbarDefinitions */ - this._toolbars = new Map(); + this._toolbarDefinitions = new Map(); /** * @private @@ -95,7 +95,7 @@ export default class WidgetToolbarRepository extends Plugin { /** * 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. + * `getRelatedElement` 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()`} @@ -104,14 +104,14 @@ export default class WidgetToolbarRepository extends Plugin { * @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 {Function} options.getRelatedElement Callback which returns an element the toolbar should be attached to. * @param {String} [options.balloonClassName='ck-toolbar-container'] CSS class for the widget balloon. */ - register( toolbarId, { items, visibleWhen, balloonClassName = 'ck-toolbar-container' } ) { + register( toolbarId, { items, getRelatedElement, balloonClassName = 'ck-toolbar-container' } ) { const editor = this.editor; const toolbarView = new ToolbarView(); - if ( this._toolbars.has( toolbarId ) ) { + if ( this._toolbarDefinitions.has( toolbarId ) ) { /** * Toolbar with the given id was already added. * @@ -123,9 +123,9 @@ export default class WidgetToolbarRepository extends Plugin { toolbarView.fillFromConfig( items, editor.ui.componentFactory ); - this._toolbars.set( toolbarId, { + this._toolbarDefinitions.set( toolbarId, { view: toolbarView, - visibleWhen, + getRelatedElement, balloonClassName, } ); } @@ -136,47 +136,68 @@ export default class WidgetToolbarRepository extends Plugin { * @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 ); + let maxRelatedElementDepth = 0; + let deepestRelatedElement = null; + let deepestToolbarDefinition = null; + + for ( const definition of this._toolbarDefinitions.values() ) { + const relatedElement = definition.getRelatedElement( this.editor.editing.view.document.selection ); + + if ( !this.editor.ui.focusTracker.isFocused || !relatedElement ) { + this._hideToolbar( definition ); } else { - this._showToolbar( toolbar ); + const relatedElementDepth = relatedElement.getAncestors().length; + + // Many toolbars can express willingness to be displayed but they do not know about + // each other. Figure out which toolbar is deepest in the view tree to decide which + // should be displayed. For instance, if a selected image is inside a table cell, display + // the ImageToolbar rather than the TableToolbar (#60). + if ( relatedElementDepth > maxRelatedElementDepth ) { + maxRelatedElementDepth = relatedElementDepth; + deepestRelatedElement = relatedElement; + deepestToolbarDefinition = definition; + } } } + + if ( deepestToolbarDefinition ) { + this._showToolbar( deepestToolbarDefinition, deepestRelatedElement ); + } } /** * Hides the given toolbar. * * @private - * @param {Object} toolbar + * @param {module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition} toolbarDefinition */ - _hideToolbar( toolbar ) { - if ( !this._isToolbarVisible( toolbar ) ) { + _hideToolbar( toolbarDefinition ) { + if ( !this._isToolbarVisible( toolbarDefinition ) ) { return; } - this._balloon.remove( toolbar.view ); + this._balloon.remove( toolbarDefinition.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. + * Shows up the toolbar if the toolbar is not visible. + * Otherwise, 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 + * @param {module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition} toolbarDefinition + * @param {module:engine/view/element~Element} relatedElement */ - _showToolbar( toolbar ) { - if ( this._isToolbarVisible( toolbar ) ) { - repositionContextualBalloon( this.editor ); - } else if ( !this._balloon.hasView( toolbar.view ) ) { + _showToolbar( toolbarDefinition, relatedElement ) { + if ( this._isToolbarVisible( toolbarDefinition ) ) { + repositionContextualBalloon( this.editor, relatedElement ); + } else if ( !this._balloon.hasView( toolbarDefinition.view ) ) { this._balloon.add( { - view: toolbar.view, - position: getBalloonPositionData( this.editor ), - balloonClassName: toolbar.balloonClassName, + view: toolbarDefinition.view, + position: getBalloonPositionData( this.editor, relatedElement ), + balloonClassName: toolbarDefinition.balloonClassName, } ); } } @@ -190,20 +211,19 @@ export default class WidgetToolbarRepository extends Plugin { } } -function repositionContextualBalloon( editor ) { +function repositionContextualBalloon( editor, relatedElement ) { const balloon = editor.plugins.get( 'ContextualBalloon' ); - const position = getBalloonPositionData( editor ); + const position = getBalloonPositionData( editor, relatedElement ); balloon.updatePosition( position ); } -function getBalloonPositionData( editor ) { +function getBalloonPositionData( editor, relatedElement ) { const editingView = editor.editing.view; const defaultPositions = BalloonPanelView.defaultPositions; - const widget = getParentWidget( editingView.document.selection ); return { - target: editingView.domConverter.viewToDom( widget ), + target: editingView.domConverter.viewToDom( relatedElement ), positions: [ defaultPositions.northArrowSouth, defaultPositions.northArrowSouthWest, @@ -215,27 +235,23 @@ function getBalloonPositionData( editor ) { }; } -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 ) ); } + +/** + * The toolbar definition object used by the toolbar repository to manage toolbars. + * It contains additional information necessary to display the toolbar in the + * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} and + * update it during its life (display) cycle. + * + * @typedef {Object} module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition + * + * @property {module:ui/view~View} view The UI view of the toolbar. + * @property {Function} getRelatedElement A function that returns an engine {@link module:engine/view/view~View} + * element the toolbar is to be attached to. For instance, an image widget or a table widget (or `null` when + * there is no such element). The function accepts an instance of {@link module:engine/view/selection~Selection}. + * @property {String} balloonClassName CSS class for the widget balloon when a toolbar is displayed. + */ diff --git a/tests/widgettoolbarrepository.js b/tests/widgettoolbarrepository.js index f40ae922..27e13385 100644 --- a/tests/widgettoolbarrepository.js +++ b/tests/widgettoolbarrepository.js @@ -31,7 +31,7 @@ describe( 'WidgetToolbarRepository', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget ], + plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget, FakeChildWidget ], fake: { toolbar: [ 'fake_button' ] } @@ -69,23 +69,23 @@ describe( 'WidgetToolbarRepository', () => { it( 'should create a widget toolbar and add it to the collection', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: () => false, + getRelatedElement: () => null, } ); - expect( widgetToolbarRepository._toolbars.size ).to.equal( 1 ); - expect( widgetToolbarRepository._toolbars.get( 'fake' ) ).to.be.an( 'object' ); + expect( widgetToolbarRepository._toolbarDefinitions.size ).to.equal( 1 ); + expect( widgetToolbarRepository._toolbarDefinitions.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 + getRelatedElement: () => null } ); expect( () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: () => false + getRelatedElement: () => null } ); } ).to.throw( CKEditorError, /^widget-toolbar-duplicated/ ); } ); @@ -96,23 +96,23 @@ describe( 'WidgetToolbarRepository', () => { editor.ui.focusTracker.isFocused = true; } ); - it( 'toolbar should be visible when the `visibleWhen` callback returns true', () => { + it( 'toolbar should be visible when the `getRelatedElement` callback returns a selected widget element', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected + getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); - const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view; + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); - it( 'toolbar should be hidden when the `visibleWhen` callback returns false', () => { + it( 'toolbar should be hidden when the `getRelatedElement` callback returns null', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected + getRelatedElement: getSelectedFakeWidget } ); setData( model, '[foo]' ); @@ -120,10 +120,10 @@ describe( 'WidgetToolbarRepository', () => { expect( balloon.visibleView ).to.equal( null ); } ); - it( 'toolbar should be hidden when the `visibleWhen` callback returns false #2', () => { + it( 'toolbar should be hidden when the `getRelatedElement` callback returns null #2', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected + getRelatedElement: getSelectedFakeWidget } ); setData( model, 'foo[]' ); @@ -139,7 +139,7 @@ describe( 'WidgetToolbarRepository', () => { it( 'toolbar should update its position when other widget is selected', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected + getRelatedElement: getSelectedFakeWidget } ); setData( model, '[]' ); @@ -149,7 +149,7 @@ describe( 'WidgetToolbarRepository', () => { writer.setSelection( model.document.getRoot().getChild( 1 ), 'on' ); } ); - const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view; + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); @@ -157,12 +157,12 @@ describe( 'WidgetToolbarRepository', () => { 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 + getRelatedElement: getSelectedFakeWidgetContent } ); setData( model, '[foo]' ); - const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view; + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); } ); @@ -170,10 +170,10 @@ describe( 'WidgetToolbarRepository', () => { it( 'toolbar should not engage when is in the balloon yet invisible', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected + getRelatedElement: getSelectedFakeWidget } ); - const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view; + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; setData( model, '[]' ); @@ -195,6 +195,77 @@ describe( 'WidgetToolbarRepository', () => { expect( balloon.visibleView ).to.equal( lastView ); } ); + + // #60 + it( 'should show up only for the related element which is deepest in the view document', () => { + // The point of this widget is to provide a getRelatedElement function that + // returns a super–shallow related element which is ignored but satisfies code coverage. + widgetToolbarRepository.register( 'dummy', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: () => editor.editing.view.document.getRoot() + } ); + + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: getSelectedFakeWidget + } ); + + widgetToolbarRepository.register( 'fake-child', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: getSelectedFakeChildWidget + } ); + + setData( model, + 'foo' + + '' + + 'foo' + + '[]' + + '' ); + + const fakeChildWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake-child' ).view; + + expect( balloon.visibleView ).to.equal( fakeChildWidgetToolbarView ); + } ); + + // #60 + it( 'should attach to the new related view element upon selecting another widget', () => { + const view = editor.editing.view; + + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: getSelectedFakeWidget + } ); + + widgetToolbarRepository.register( 'fake-child', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: getSelectedFakeChildWidget + } ); + + setData( model, + 'foo' + + '[' + + 'foo' + + '' + + ']' ); + + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; + const fakeChildWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake-child' ).view; + + expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); + + const fakeChildViewElement = view.document.getRoot().getChild( 1 ).getChild( 1 ); + const updatePositionSpy = sinon.spy( balloon, 'add' ); + + view.change( writer => { + // [] + writer.setSelection( fakeChildViewElement, 'on' ); + } ); + + expect( balloon.visibleView ).to.equal( fakeChildWidgetToolbarView ); + + expect( updatePositionSpy.firstCall.args[ 0 ].position.target ).to.equal( + view.domConverter.viewToDom( fakeChildViewElement ) ); + } ); } ); } ); @@ -236,10 +307,10 @@ describe( 'WidgetToolbarRepository - integration with the BalloonToolbar', () => it( 'balloon toolbar should be hidden when the widget is selected', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected, + getRelatedElement: getSelectedFakeWidget, } ); - const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view; + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; setData( model, '[]foo' ); editor.ui.focusTracker.isFocused = true; @@ -252,7 +323,7 @@ describe( 'WidgetToolbarRepository - integration with the BalloonToolbar', () => it( 'balloon toolbar should be visible when the widget is not selected', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), - visibleWhen: isFakeWidgetSelected + getRelatedElement: getSelectedFakeWidget } ); setData( model, '[foo]' ); @@ -265,26 +336,41 @@ describe( 'WidgetToolbarRepository - integration with the BalloonToolbar', () => } ); const fakeWidgetSymbol = Symbol( 'fakeWidget' ); +const fakeChildWidgetSymbol = Symbol( 'fakeChildWidget' ); + +function getSelectedFakeWidget( selection ) { + const viewElement = selection.getSelectedElement(); + + if ( viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( fakeWidgetSymbol ) ) { + return viewElement; + } -function isFakeWidgetSelected( selection ) { + return null; +} + +function getSelectedFakeChildWidget( selection ) { const viewElement = selection.getSelectedElement(); - return !!viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( fakeWidgetSymbol ); + if ( viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( fakeChildWidgetSymbol ) ) { + return viewElement; + } + + return null; } -function isFakeWidgetContentSelected( selection ) { +function getSelectedFakeWidgetContent( selection ) { const pos = selection.getFirstPosition(); let node = pos.parent; while ( node ) { if ( node.is( 'element' ) && isWidget( node ) && node.getCustomProperty( fakeWidgetSymbol ) ) { - return true; + return node; } node = node.parent; } - return false; + return null; } // Plugin that adds fake_button to editor's component factory. @@ -317,10 +403,11 @@ class FakeWidget extends Plugin { schema.register( 'fake-widget', { isObject: true, isBlock: true, - allowWhere: '$block', + allowWhere: '$block' } ); schema.extend( '$text', { allowIn: 'fake-widget' } ); + schema.extend( 'paragraph', { allowIn: 'fake-widget' } ); const conversion = editor.conversion; @@ -351,3 +438,55 @@ class FakeWidget extends Plugin { } ); } } + +// A simple child widget plugin +// It registers `` block in model and represents `div` in the view. +// It allows having text inside self. +class FakeChildWidget extends Plugin { + static get requires() { + return [ Widget ]; + } + + init() { + const editor = this.editor; + const schema = editor.model.schema; + + schema.register( 'fake-child-widget', { + isObject: true, + isBlock: true, + allowWhere: '$block', + allowIn: 'fake-widget' + } ); + + schema.extend( '$text', { allowIn: 'fake-child-widget' } ); + schema.extend( 'paragraph', { allowIn: 'fake-child-widget' } ); + + const conversion = editor.conversion; + + conversion.for( 'dataDowncast' ).elementToElement( { + model: 'fake-child-widget', + view: ( modelElement, viewWriter ) => { + return viewWriter.createContainerElement( 'div' ); + } + } ); + + conversion.for( 'editingDowncast' ).elementToElement( { + model: 'fake-child-widget', + view: ( modelElement, viewWriter ) => { + const fakeWidget = viewWriter.createContainerElement( 'div' ); + viewWriter.setCustomProperty( fakeChildWidgetSymbol, true, fakeWidget ); + + return toWidget( fakeWidget, viewWriter, { label: 'fake-child-widget' } ); + } + } ); + + conversion.for( 'upcast' ).elementToElement( { + view: { + name: 'div' + }, + model: ( view, modelWriter ) => { + return modelWriter.createElement( 'fake-child-widget' ); + } + } ); + } +}