diff --git a/packages/ckeditor5-editor-classic/src/classiceditorui.ts b/packages/ckeditor5-editor-classic/src/classiceditorui.ts index cdd34da2f77..6eac1638127 100644 --- a/packages/ckeditor5-editor-classic/src/classiceditorui.ts +++ b/packages/ckeditor5-editor-classic/src/classiceditorui.ts @@ -14,7 +14,8 @@ import { normalizeToolbarConfig, type DialogViewMoveToEvent, type Dialog, - type EditorUIReadyEvent + type EditorUIReadyEvent, + type ContextualBalloonGetPositionOptionsEvent } from 'ckeditor5/src/ui.js'; import { enablePlaceholder, @@ -121,6 +122,8 @@ export default class ClassicEditorUI extends EditorUI { } this._initDialogPluginIntegration(); + this._initContextualBalloonIntegration(); + this.fire( 'ready' ); } @@ -187,6 +190,65 @@ export default class ClassicEditorUI extends EditorUI { } ); } + /** + * Provides an integration between the sticky toolbar and {@link module:ui/panel/balloon/contextualballoon contextual balloon plugin}. + * It allows the contextual balloon to consider the height of the + * {@link module:editor-classic/classiceditoruiview~ClassicEditorUIView#stickyPanel}. It prevents the balloon from overlapping + * the sticky toolbar by adjusting the balloon's position using viewport offset configuration. + */ + private _initContextualBalloonIntegration(): void { + if ( !this.editor.plugins.has( 'ContextualBalloon' ) ) { + return; + } + + const { stickyPanel } = this.view; + const contextualBalloon = this.editor.plugins.get( 'ContextualBalloon' ); + + contextualBalloon.on( 'getPositionOptions', evt => { + const position = evt.return; + + if ( !position || !stickyPanel.isSticky || !stickyPanel.element ) { + return; + } + + // Measure toolbar (and menu bar) height. + const stickyPanelHeight = new Rect( stickyPanel.element ).height; + + // Handle edge case when the target element is larger than the limiter. + // It's an issue because the contextual balloon can overlap top table cells when the table is larger than the viewport + // and it's placed at the top of the editor. It's better to overlap toolbar in that situation. + // Check this issue: https://github.com/ckeditor/ckeditor5/issues/15744 + const target = typeof position.target === 'function' ? position.target() : position.target; + const limiter = typeof position.limiter === 'function' ? position.limiter() : position.limiter; + + if ( target && limiter && new Rect( target ).height >= new Rect( limiter ).height - stickyPanelHeight ) { + return; + } + + // Ensure that viewport offset is present, it can be undefined according to the typing. + const viewportOffsetConfig = { ...position.viewportOffsetConfig }; + const newTopViewportOffset = ( viewportOffsetConfig.top || 0 ) + stickyPanelHeight; + + evt.return = { + ...position, + viewportOffsetConfig: { + ...viewportOffsetConfig, + top: newTopViewportOffset + } + }; + }, { priority: 'low' } ); + + // Update balloon position when the toolbar becomes sticky or when ui viewportOffset changes. + const updateBalloonPosition = () => { + if ( contextualBalloon.visibleView ) { + contextualBalloon.updatePosition(); + } + }; + + this.listenTo( stickyPanel, 'change:isSticky', updateBalloonPosition ); + this.listenTo( this.editor.ui, 'change:viewportOffset', updateBalloonPosition ); + } + /** * Provides an integration between the sticky toolbar and {@link module:utils/dom/scroll~scrollViewportToShowTarget}. * It allows the UI-agnostic engine method to consider the geometry of the diff --git a/packages/ckeditor5-editor-classic/tests/classiceditorui.js b/packages/ckeditor5-editor-classic/tests/classiceditorui.js index 24723aa56e7..46047b15038 100644 --- a/packages/ckeditor5-editor-classic/tests/classiceditorui.js +++ b/packages/ckeditor5-editor-classic/tests/classiceditorui.js @@ -20,7 +20,7 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import { assertBinding } from '@ckeditor/ckeditor5-utils/tests/_utils/utils.js'; import { isElement } from 'lodash-es'; -import { Dialog, DialogViewPosition } from '@ckeditor/ckeditor5-ui'; +import { ContextualBalloon, Dialog, DialogViewPosition } from '@ckeditor/ckeditor5-ui'; describe( 'ClassicEditorUI', () => { let editor, view, ui, viewElement; @@ -273,6 +273,211 @@ describe( 'ClassicEditorUI', () => { } ); } ); + describe( 'integration with the Contextual Balloon plugin', () => { + let editorWithUi, editorElement, contextualBalloon; + + beforeEach( async () => { + editorElement = document.body.appendChild( + document.createElement( 'div' ) + ); + + editorWithUi = await ClassicEditor.create( editorElement, { + plugins: [ + ContextualBalloon, + Paragraph + ] + } ); + + contextualBalloon = editorWithUi.plugins.get( 'ContextualBalloon' ); + + sinon.stub( editorWithUi.ui.view.stickyPanel.element, 'getBoundingClientRect' ).returns( { + height: 50, + bottom: 50 + } ); + + sinon.stub( editorWithUi.ui.view.editable.element, 'getBoundingClientRect' ).returns( { + top: 0, + right: 300, + bottom: 100, + left: 0, + width: 300, + height: 100 + } ); + } ); + + afterEach( async () => { + await editorWithUi.destroy(); + editorElement.remove(); + } ); + + it( 'should handle BalloonPlugin#getPositionOptions returning undefined value', () => { + sinon.stub( contextualBalloon, '_visibleStack' ).get( () => ( { values: () => [ { position: undefined } ] } ) ); + + expect( contextualBalloon.getPositionOptions() ).to.be.undefined; + } ); + + it( 'should set proper viewportOffsetConfig top offset when sticky panel is visible', () => { + editorWithUi.ui.view.stickyPanel.isSticky = true; + + setModelData( editorWithUi.model, 'foo[]' ); + + const pinSpy = sinon.spy( contextualBalloon.view, 'pin' ); + const contentView = new View( editorWithUi.locale ); + + contentView.setTemplate( { + tag: 'div', + children: [ 'Hello World' ] + } ); + + contextualBalloon.add( { + view: contentView, + position: getBalloonPositionData() + } ); + + expect( pinSpy ).to.be.calledOnce; + expect( pinSpy.getCall( 0 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 50 + } ); + } ); + + it( 'should summarize ui viewportOffset and sticky panel height in the viewportOffset option', () => { + editorWithUi.ui.view.stickyPanel.isSticky = true; + editorWithUi.ui.viewportOffset = { + top: 100 + }; + + setModelData( editorWithUi.model, 'foo[]' ); + + const pinSpy = sinon.spy( contextualBalloon.view, 'pin' ); + const contentView = new View( editorWithUi.locale ); + + contentView.setTemplate( { + tag: 'div', + children: [ 'Hello World' ] + } ); + + contextualBalloon.add( { + view: contentView, + position: getBalloonPositionData() + } ); + + expect( pinSpy ).to.be.calledOnce; + expect( pinSpy.getCall( 0 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 150 + } ); + + // Handle change of viewport offset. + editorWithUi.ui.viewportOffset = { + top: 200 + }; + + expect( pinSpy ).to.be.calledTwice; + expect( pinSpy.getCall( 1 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 250 + } ); + } ); + + it( 'should set proper viewportOffsetConfig top offset when sticky panel is not visible', () => { + editorWithUi.ui.view.stickyPanel.isSticky = false; + + setModelData( editorWithUi.model, 'foo[]' ); + + const pinSpy = sinon.spy( contextualBalloon.view, 'pin' ); + const contentView = new View( editorWithUi.locale ); + + contentView.setTemplate( { + tag: 'div', + children: [ 'Hello World' ] + } ); + + contextualBalloon.add( { + view: contentView, + position: getBalloonPositionData() + } ); + + expect( pinSpy ).to.be.calledOnce; + expect( pinSpy.getCall( 0 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 0 + } ); + } ); + + it( 'should update viewportOffsetConfig top offset when sticky panel becomes visible', () => { + setModelData( editorWithUi.model, 'foo[]' ); + + const pinSpy = sinon.spy( contextualBalloon.view, 'pin' ); + const contentView = new View( editorWithUi.locale ); + + editorWithUi.ui.view.stickyPanel.isSticky = false; + + contentView.setTemplate( { + tag: 'div', + children: [ 'Hello World' ] + } ); + + contextualBalloon.add( { + view: contentView, + position: getBalloonPositionData() + } ); + + expect( pinSpy ).to.be.calledOnce; + expect( pinSpy.getCall( 0 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 0 + } ); + + editorWithUi.ui.view.stickyPanel.isSticky = true; + + expect( pinSpy.getCall( 1 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 50 + } ); + } ); + + it( 'should not update viewportOffsetConfig top offset when sticky panel becomes visible', () => { + setModelData( editorWithUi.model, 'foo[]' ); + editorWithUi.ui.view.stickyPanel.isSticky = true; + + const pinSpy = sinon.spy( contextualBalloon.view, 'pin' ); + const contentView = new View( editorWithUi.locale ); + + const targetElement = document.createElement( 'div' ); + const limiterElement = document.createElement( 'div' ); + + targetElement.style.height = '400px'; + limiterElement.style.height = '200px'; + + document.body.appendChild( targetElement ); + document.body.appendChild( limiterElement ); + + contentView.setTemplate( { + tag: 'div', + children: [ 'Hello World' ] + } ); + + contextualBalloon.add( { + view: contentView, + position: { + target: targetElement, + limiter: limiterElement + } + } ); + + expect( pinSpy ).to.be.calledOnce; + expect( pinSpy.getCall( 0 ).args[ 0 ].viewportOffsetConfig ).to.be.deep.equal( { + top: 0 + } ); + + targetElement.remove(); + limiterElement.remove(); + } ); + + function getBalloonPositionData() { + const view = editorWithUi.editing.view; + + return { + target: () => view.domConverter.viewRangeToDom( view.document.selection.getFirstRange() ) + }; + } + } ); + describe( 'integration with the Dialog plugin and sticky panel (toolbar)', () => { let editorWithUi, editorElement, dialogPlugin, dialogContentView; diff --git a/packages/ckeditor5-ui/src/index.ts b/packages/ckeditor5-ui/src/index.ts index a862e37e4af..191e1ca13db 100644 --- a/packages/ckeditor5-ui/src/index.ts +++ b/packages/ckeditor5-ui/src/index.ts @@ -94,7 +94,7 @@ export { default as Notification } from './notification/notification.js'; export { default as ViewModel } from './model.js'; export { default as BalloonPanelView } from './panel/balloon/balloonpanelview.js'; -export { default as ContextualBalloon } from './panel/balloon/contextualballoon.js'; +export { default as ContextualBalloon, type ContextualBalloonGetPositionOptionsEvent } from './panel/balloon/contextualballoon.js'; export { default as StickyPanelView } from './panel/sticky/stickypanelview.js'; export { default as AutocompleteView, type AutocompleteViewConfig, type AutocompleteResultsView } from './autocomplete/autocompleteview.js'; diff --git a/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.ts b/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.ts index eb17d0a8107..6f8bbce21ac 100644 --- a/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.ts +++ b/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.ts @@ -21,7 +21,8 @@ import { toUnit, type Locale, type ObservableChangeEvent, - type PositionOptions + type PositionOptions, + type DecoratedMethodEvent } from '@ckeditor/ckeditor5-utils'; import '../../../theme/components/panel/balloonrotator.css'; @@ -154,6 +155,8 @@ export default class ContextualBalloon extends Plugin { return null; }; + this.decorate( 'getPositionOptions' ); + this.set( 'visibleView', null ); this.set( '_numberOfStacks', 0 ); this.set( '_singleViewMode', false ); @@ -320,10 +323,35 @@ export default class ContextualBalloon extends Plugin { this._visibleStack.get( this.visibleView! )!.position = position; } - this.view.pin( this._getBalloonPosition()! ); + this.view.pin( this.getPositionOptions()! ); this._fakePanelsView!.updatePosition(); } + /** + * Returns position options of the last view in the stack. + * This keeps the balloon in the same position when the view is changed. + */ + public getPositionOptions(): Partial | undefined { + let position = Array.from( this._visibleStack.values() ).pop()!.position; + + if ( position ) { + // Use the default limiter if none has been specified. + if ( !position.limiter ) { + // Don't modify the original options object. + position = Object.assign( {}, position, { + limiter: this.positionLimiter + } ); + } + + // Don't modify the original options object. + position = Object.assign( {}, position, { + viewportOffsetConfig: this.editor.ui.viewportOffset + } ); + } + + return position; + } + /** * Shows the last view from the stack of a given ID. */ @@ -495,40 +523,22 @@ export default class ContextualBalloon extends Plugin { this._rotatorView!.showView( view ); this.visibleView = view; - this.view.pin( this._getBalloonPosition()! ); + this.view.pin( this.getPositionOptions()! ); this._fakePanelsView!.updatePosition(); if ( singleViewMode ) { this._singleViewMode = true; } } - - /** - * Returns position options of the last view in the stack. - * This keeps the balloon in the same position when the view is changed. - */ - private _getBalloonPosition() { - let position = Array.from( this._visibleStack.values() ).pop()!.position; - - if ( position ) { - // Use the default limiter if none has been specified. - if ( !position.limiter ) { - // Don't modify the original options object. - position = Object.assign( {}, position, { - limiter: this.positionLimiter - } ); - } - - // Don't modify the original options object. - position = Object.assign( {}, position, { - viewportOffsetConfig: this.editor.ui.viewportOffset - } ); - } - - return position; - } } +/** + * An event fired when the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} is about to get the position of the balloon. + * + * @eventName ~ContextualBalloon#getPositionOptions + */ +export type ContextualBalloonGetPositionOptionsEvent = DecoratedMethodEvent; + /** * The configuration of the view. */ diff --git a/packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js b/packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js index d7f82c5c4db..284c5075b5f 100644 --- a/packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js +++ b/packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js @@ -211,6 +211,60 @@ describe( 'ContextualBalloon', () => { } ); } ); + describe( 'getPositionOptions()', () => { + beforeEach( () => { + sinon.stub( balloon.view, 'attachTo' ).returns( {} ); + sinon.stub( balloon.view, 'pin' ).returns( {} ); + } ); + + it( 'should return undefined if last element from visible stack has no position', () => { + balloon.add( { + view: viewA + } ); + + expect( balloon.getPositionOptions() ).to.be.undefined; + } ); + + it( 'should return position of the last visible stack element', () => { + balloon.add( { + view: viewA, + position: { + target: 'fake' + } + } ); + + expect( balloon.getPositionOptions() ).to.be.deep.equal( { + limiter: balloon.positionLimiter, + target: 'fake', + viewportOffsetConfig: { + top: 0 + } + } ); + } ); + + it( 'should attach limiter to the position of element from the last visible stack if it\'s not present', () => { + balloon.add( { + view: viewA, + position: { + target: 'blank' + } + } ); + + expect( balloon.getPositionOptions().limiter ).to.be.equal( balloon.positionLimiter ); + } ); + + it( 'should attach viewportOffsetConfig to the position of element from the last visible stack if it\'s not present', () => { + balloon.add( { + view: viewA, + position: { + target: 'blank' + } + } ); + + expect( balloon.getPositionOptions().viewportOffsetConfig ).to.be.equal( editor.ui.viewportOffset ); + } ); + } ); + describe( 'add()', () => { beforeEach( () => { stubBalloonPanelView();