UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

247 lines (246 loc) • 10.8 kB
/** * @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); } }