Skip to content

Commit

Permalink
Merge pull request #16780 from ckeditor/ck/15744
Browse files Browse the repository at this point in the history
Fix (editor-classic): Widget toolbar no longer covers editor's sticky toolbar when scrolling. Closes #15744.
  • Loading branch information
niegowski authored Aug 5, 2024
2 parents 248d64c + 1dac08c commit e58a5c6
Show file tree
Hide file tree
Showing 5 changed files with 362 additions and 31 deletions.
64 changes: 63 additions & 1 deletion packages/ckeditor5-editor-classic/src/classiceditorui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
normalizeToolbarConfig,
type DialogViewMoveToEvent,
type Dialog,
type EditorUIReadyEvent
type EditorUIReadyEvent,
type ContextualBalloonGetPositionOptionsEvent
} from 'ckeditor5/src/ui.js';
import {
enablePlaceholder,
Expand Down Expand Up @@ -121,6 +122,8 @@ export default class ClassicEditorUI extends EditorUI {
}

this._initDialogPluginIntegration();
this._initContextualBalloonIntegration();

this.fire<EditorUIReadyEvent>( 'ready' );
}

Expand Down Expand Up @@ -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<ContextualBalloonGetPositionOptionsEvent>( '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
Expand Down
207 changes: 206 additions & 1 deletion packages/ckeditor5-editor-classic/tests/classiceditorui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, '<paragraph>foo[]</paragraph>' );

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, '<paragraph>foo[]</paragraph>' );

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, '<paragraph>foo[]</paragraph>' );

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, '<paragraph>foo[]</paragraph>' );

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, '<paragraph>foo[]</paragraph>' );
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;

Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit e58a5c6

Please sign in to comment.