@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
247 lines (246 loc) • 10.8 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module ui/panel/sticky/stickypanelview
*/
import View from '../../view.js';
import Template from '../../template.js';
import { Rect, toUnit, getVisualViewportOffset, global } from '@ckeditor/ckeditor5-utils';
// @if CK_DEBUG_STICKYPANEL // const {
// @if CK_DEBUG_STICKYPANEL // default: RectDrawer,
// @if CK_DEBUG_STICKYPANEL // diagonalStylesBlack
// @if CK_DEBUG_STICKYPANEL // } = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' );
import '../../../theme/components/panel/stickypanel.css';
const toPx = /* #__PURE__ */ toUnit('px');
/**
* The sticky panel view class.
*/
export default class StickyPanelView extends View {
/**
* Collection of the child views which creates balloon panel contents.
*/
content;
/**
* The panel which accepts children into {@link #content} collection.
* Also an element which is positioned when {@link #isSticky}.
*/
contentPanelElement;
/**
* A dummy element which visually fills the space as long as the
* actual panel is sticky. It prevents flickering of the UI.
*/
_contentPanelPlaceholder;
/**
* @inheritDoc
*/
constructor(locale) {
super(locale);
const bind = this.bindTemplate;
this.set('isActive', false);
this.set('isSticky', false);
this.set('limiterElement', null);
this.set('limiterBottomOffset', 50);
this.set('viewportTopOffset', 0);
this.set('_marginLeft', null);
this.set('_isStickyToTheBottomOfLimiter', false);
this.set('_stickyTopOffset', null);
this.set('_stickyBottomOffset', null);
this.content = this.createCollection();
this._contentPanelPlaceholder = new Template({
tag: 'div',
attributes: {
class: [
'ck',
'ck-sticky-panel__placeholder'
],
style: {
display: bind.to('isSticky', isSticky => isSticky ? 'block' : 'none'),
height: bind.to('isSticky', isSticky => {
return isSticky ? toPx(this._contentPanelRect.height) : null;
})
}
}
}).render();
this.contentPanelElement = new Template({
tag: 'div',
attributes: {
class: [
'ck',
'ck-sticky-panel__content',
// Toggle class of the panel when "sticky" state changes in the view.
bind.if('isSticky', 'ck-sticky-panel__content_sticky'),
bind.if('_isStickyToTheBottomOfLimiter', 'ck-sticky-panel__content_sticky_bottom-limit')
],
style: {
width: bind.to('isSticky', isSticky => {
return isSticky ? toPx(this._contentPanelPlaceholder.getBoundingClientRect().width) : null;
}),
top: bind.to('_stickyTopOffset', value => value ? toPx(value) : value),
bottom: bind.to('_stickyBottomOffset', value => value ? toPx(value) : value),
marginLeft: bind.to('_marginLeft')
}
},
children: this.content
}).render();
this.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-sticky-panel'
]
},
children: [
this._contentPanelPlaceholder,
this.contentPanelElement
]
});
}
/**
* @inheritDoc
*/
render() {
super.render();
// Check if the panel should go into the sticky state immediately.
this.checkIfShouldBeSticky();
// Update sticky state of the panel as the window and ancestors are being scrolled.
this.listenTo(global.document, 'scroll', () => {
this.checkIfShouldBeSticky();
}, { useCapture: true });
// Synchronize with `model.isActive` because sticking an inactive panel is pointless.
this.listenTo(this, 'change:isActive', () => {
this.checkIfShouldBeSticky();
});
if (global.window.visualViewport) {
this.listenTo(global.window.visualViewport, 'scroll', () => {
this.checkIfShouldBeSticky();
});
this.listenTo(global.window.visualViewport, 'resize', () => {
this.checkIfShouldBeSticky();
});
}
}
/**
* Analyzes the environment to decide whether the panel should be sticky or not.
* Then handles the positioning of the panel.
*/
checkIfShouldBeSticky() {
// @if CK_DEBUG_STICKYPANEL // RectDrawer.clear();
if (!this.limiterElement || !this.isActive) {
this._unstick();
return;
}
const limiterRect = new Rect(this.limiterElement);
let visibleLimiterRect = limiterRect.getVisible();
if (visibleLimiterRect) {
const windowRect = new Rect(global.window);
windowRect.top += this.viewportTopOffset;
windowRect.height -= this.viewportTopOffset;
visibleLimiterRect = visibleLimiterRect.getIntersection(windowRect);
}
const { left: visualViewportOffsetLeft, top: visualViewportOffsetTop } = getVisualViewportOffset();
limiterRect.moveBy(visualViewportOffsetLeft, visualViewportOffsetTop);
if (visibleLimiterRect) {
visibleLimiterRect.moveBy(visualViewportOffsetLeft, visualViewportOffsetTop);
}
// Stick the panel only if
// * the limiter's ancestors are intersecting with each other so that some of their rects are visible,
// * and the limiter's top edge is above the visible ancestors' top edge.
if (visibleLimiterRect && limiterRect.top < visibleLimiterRect.top) {
// Check if there's a change the panel can be sticky to the bottom of the limiter.
if (this._contentPanelRect.height + this.limiterBottomOffset > visibleLimiterRect.height) {
const stickyBottomOffset = Math.max(limiterRect.bottom - visibleLimiterRect.bottom, 0) + this.limiterBottomOffset;
// @if CK_DEBUG_STICKYPANEL // const stickyBottomOffsetRect = new Rect( {
// @if CK_DEBUG_STICKYPANEL // top: limiterRect.bottom - stickyBottomOffset, left: 0, right: 2000,
// @if CK_DEBUG_STICKYPANEL // bottom: limiterRect.bottom - stickyBottomOffset, width: 2000, height: 1
// @if CK_DEBUG_STICKYPANEL // } );
// @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( stickyBottomOffsetRect,
// @if CK_DEBUG_STICKYPANEL // { outlineWidth: '1px', opacity: '.8', outlineColor: 'black' },
// @if CK_DEBUG_STICKYPANEL // 'Sticky bottom offset',
// @if CK_DEBUG_STICKYPANEL // { visualViewportOrigin: true }
// @if CK_DEBUG_STICKYPANEL // );
// Check if sticking the panel to the bottom of the limiter does not cause it to suddenly
// move upwards if there's not enough space for it.
// To avoid toolbar flickering we are adding 1 for potential style change (sticky has all borders set,
// non-sticky lacks bottom border).
if (this._contentPanelRect.height + stickyBottomOffset + 1 < limiterRect.height) {
this._stickToBottomOfLimiter(stickyBottomOffset);
}
else {
this._unstick();
}
}
else if (this._contentPanelRect.height + this.limiterBottomOffset < limiterRect.height) {
this._stickToTopOfAncestors(visibleLimiterRect.top);
}
else {
this._unstick();
}
}
else {
this._unstick();
}
// @if CK_DEBUG_STICKYPANEL // console.clear();
// @if CK_DEBUG_STICKYPANEL // console.log( 'isSticky', this.isSticky );
// @if CK_DEBUG_STICKYPANEL // console.log( '_isStickyToTheBottomOfLimiter', this._isStickyToTheBottomOfLimiter );
// @if CK_DEBUG_STICKYPANEL // console.log( '_stickyTopOffset', this._stickyTopOffset );
// @if CK_DEBUG_STICKYPANEL // console.log( '_stickyBottomOffset', this._stickyBottomOffset );
// @if CK_DEBUG_STICKYPANEL // if ( visibleLimiterRect ) {
// @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleLimiterRect,
// @if CK_DEBUG_STICKYPANEL // { ...diagonalStylesBlack,
// @if CK_DEBUG_STICKYPANEL // outlineWidth: '3px', opacity: '.8', outlineColor: 'orange', outlineOffset: '-3px',
// @if CK_DEBUG_STICKYPANEL // backgroundColor: 'rgba(0, 0, 255, .2)', zIndex: 2000 },
// @if CK_DEBUG_STICKYPANEL // 'visibleLimiterRect',
// @if CK_DEBUG_STICKYPANEL // { visualViewportOrigin: true }
// @if CK_DEBUG_STICKYPANEL // );
// @if CK_DEBUG_STICKYPANEL // }
}
/**
* Sticks the panel at the given CSS `top` offset.
*
* @private
* @param topOffset
*/
_stickToTopOfAncestors(topOffset) {
this.isSticky = true;
this._isStickyToTheBottomOfLimiter = false;
this._stickyTopOffset = topOffset;
this._stickyBottomOffset = null;
this._marginLeft = toPx(-global.window.scrollX + getVisualViewportOffset().left);
}
/**
* Sticks the panel at the bottom of the limiter with a given CSS `bottom` offset.
*
* @private
* @param stickyBottomOffset
*/
_stickToBottomOfLimiter(stickyBottomOffset) {
this.isSticky = true;
this._isStickyToTheBottomOfLimiter = true;
this._stickyTopOffset = null;
this._stickyBottomOffset = stickyBottomOffset;
this._marginLeft = toPx(-global.window.scrollX + getVisualViewportOffset().left);
}
/**
* Unsticks the panel putting it back to its original position.
*
* @private
*/
_unstick() {
this.isSticky = false;
this._isStickyToTheBottomOfLimiter = false;
this._stickyTopOffset = null;
this._stickyBottomOffset = null;
this._marginLeft = null;
}
/**
* Returns the bounding rect of the {@link #contentPanelElement}.
*
* @private
*/
get _contentPanelRect() {
return new Rect(this.contentPanelElement);
}
}