8
0
Просмотр исходного кода

Ensured only the widget toolbar attached to the view element which is deepest in the view tree will show up.

Aleksander Nowodzinski 7 лет назад
Родитель
Сommit
b25ac9df66

+ 68 - 52
packages/ckeditor5-widget/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: viewSelection => getSelectedImageWidget( viewSelection )
  *				} );
  *			}
  *		}
@@ -71,12 +71,12 @@ export default class WidgetToolbarRepository extends Plugin {
 		}
 
 		/**
-		 * A map of toolbars.
+		 * A map of toolbar definitions.
 		 *
 		 * @protected
-		 * @member {Map.<string,Object>} #_toolbars
+		 * @member {Map.<string,module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition>} #_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.<String>} 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/model/view~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.
+ */

+ 167 - 28
packages/ckeditor5-widget/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, '<paragraph>foo</paragraph>[<fake-widget></fake-widget>]' );
 
-			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, '[<paragraph>foo</paragraph>]<fake-widget></fake-widget>' );
@@ -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, '<paragraph>foo</paragraph>[<fake-widget></fake-widget>]' );
@@ -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, '[<fake-widget></fake-widget>]<fake-widget></fake-widget>' );
@@ -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, '<fake-widget>[foo]</fake-widget>' );
 
-			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, '[<fake-widget></fake-widget>]' );
 
@@ -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,
+				'<paragraph>foo</paragraph>' +
+				'<fake-widget>' +
+					'<paragraph>foo</paragraph>' +
+					'[<fake-child-widget></fake-child-widget>]' +
+				'</fake-widget>' );
+
+			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,
+				'<paragraph>foo</paragraph>' +
+				'[<fake-widget>' +
+					'<paragraph>foo</paragraph>' +
+					'<fake-child-widget></fake-child-widget>' +
+				'</fake-widget>]' );
+
+			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 => {
+				// [<fake-child-widget></fake-child-widget>]
+				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, '[<fake-widget></fake-widget>]<paragraph>foo</paragraph>' );
 		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, '<fake-widget></fake-widget><paragraph>[foo]</paragraph>' );
@@ -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 `<fake-child-widget>` 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' );
+			}
+		} );
+	}
+}