Skip to content

Commit

Permalink
Merge pull request #16685 from ckeditor/ck/5460
Browse files Browse the repository at this point in the history
Fix (ui): Block toolbar button no longer remains fixed in the same position while scrolling the editable content. Closes #5460.
  • Loading branch information
Mati365 authored Jul 23, 2024
2 parents ea0947a + 297131e commit 498f6f2
Show file tree
Hide file tree
Showing 6 changed files with 351 additions and 2 deletions.
116 changes: 114 additions & 2 deletions packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@ckeditor/ckeditor5-core';

import {
global,
Rect,
ResizeObserver,
toUnit,
Expand All @@ -29,6 +30,7 @@ import ToolbarView, { NESTED_TOOLBAR_ICONS } from '../toolbarview.js';
import clickOutsideHandler from '../../bindings/clickoutsidehandler.js';
import normalizeToolbarConfig from '../normalizetoolbarconfig.js';

import type ButtonView from '../../button/buttonview.js';
import type { ButtonExecuteEvent } from '../../button/button.js';
import type { EditorUIReadyEvent, EditorUIUpdateEvent } from '../../editorui/editorui.js';

Expand Down Expand Up @@ -184,6 +186,9 @@ export default class BlockToolbar extends Plugin {
}
} );

// Reposition button on scroll.
this._repositionButtonOnScroll();

// Register the toolbar so it becomes available for Alt+F10 and Esc navigation.
editor.ui.addToolbar( this.toolbarView, {
beforeFocus: () => this._showPanel(),
Expand Down Expand Up @@ -425,21 +430,59 @@ export default class BlockToolbar extends Plugin {
}
}

/**
* Repositions the button on scroll.
*/
private _repositionButtonOnScroll() {
const { buttonView } = this;

let pendingAnimationFrame = false;

// Reposition the button on scroll, but do it only once per animation frame to avoid performance issues.
const repositionOnScroll = () => {
if ( pendingAnimationFrame ) {
return;
}

pendingAnimationFrame = true;
global.window.requestAnimationFrame( () => {
this._updateButton();
pendingAnimationFrame = false;
} );
};

// Watch scroll event only when the button is visible, it prevents attaching the scroll event listener
// to the document when the button is not visible.
buttonView.on<ObservableChangeEvent<boolean>>( 'change:isVisible', ( evt, name, isVisible ) => {
if ( isVisible ) {
buttonView.listenTo( global.document, 'scroll', repositionOnScroll, {
useCapture: true,
usePassive: true
} );
} else {
buttonView.stopListening( global.document, 'scroll', repositionOnScroll );
}
} );
}

/**
* Attaches the {@link #buttonView} to the target block of content.
*
* @param targetElement Target element.
*/
private _attachButtonToElement( targetElement: HTMLElement ) {
const buttonElement = this.buttonView.element!;
const editableElement = this._getSelectedEditableElement();

const contentStyles = window.getComputedStyle( targetElement );

const editableRect = new Rect( this._getSelectedEditableElement() );
const editableRect = new Rect( editableElement );
const contentPaddingTop = parseInt( contentStyles.paddingTop, 10 );
// When line height is not an integer then treat it as "normal".
// MDN says that 'normal' == ~1.2 on desktop browsers.
const contentLineHeight = parseInt( contentStyles.lineHeight, 10 ) || parseInt( contentStyles.fontSize, 10 ) * 1.2;

const buttonRect = new Rect( this.buttonView.element! );
const buttonRect = new Rect( buttonElement );
const contentRect = new Rect( targetElement );

let positionLeft;
Expand All @@ -458,6 +501,75 @@ export default class BlockToolbar extends Plugin {

this.buttonView.top = absoluteButtonRect.top;
this.buttonView.left = absoluteButtonRect.left;

this._clipButtonToViewport( this.buttonView, editableElement );
}

/**
* Clips the button element to the viewport of the editable element.
*
* * If the button overflows the editable viewport, it is clipped to make it look like it's cut off by the editable scrollable region.
* * If the button is fully hidden by the top of the editable, it is not clickable but still visible in the DOM.
*
* @param buttonView The button view to clip.
* @param editableElement The editable element whose viewport is used for clipping.
*/
private _clipButtonToViewport(
buttonView: ButtonView,
editableElement: HTMLElement
) {
const absoluteButtonRect = new Rect( buttonView.element! );
const scrollViewportRect = new Rect( editableElement ).getVisible();

// Sets polygon clip path for the button element, if there is no argument provided, the clip path is removed.
const setButtonClipping = ( ...paths: Array<string> ) => {
buttonView.element!.style.clipPath = paths.length ? `polygon(${ paths.join( ',' ) })` : '';
};

// Hide the button if it's fully hidden by the top of the editable.
// Note that the button is still visible in the DOM, but it's not clickable. It's because we don't
// want to hide the button completely, as there are plenty of `isVisible` watchers which toggles
// the button scroll listeners.
const markAsHidden = ( isHidden: boolean ) => {
buttonView.isEnabled = !isHidden;
buttonView.element!.style.pointerEvents = isHidden ? 'none' : '';
};

if ( scrollViewportRect && scrollViewportRect.bottom < absoluteButtonRect.bottom ) {
// Calculate the delta between the button bottom and the editable bottom, and clip the button
// to make it look like it's cut off by the editable scrollable region.
const delta = Math.min(
absoluteButtonRect.height,
absoluteButtonRect.bottom - scrollViewportRect.bottom
);

markAsHidden( delta >= absoluteButtonRect.height );
setButtonClipping(
'0 0',
'100% 0',
`100% calc(100% - ${ toPx( delta ) })`,
`0 calc(100% - ${ toPx( delta ) }`
);
} else if ( scrollViewportRect && scrollViewportRect.top > absoluteButtonRect.top ) {
// Calculate the delta between the button top and the editable top, and clip the button
// to make it look like it's cut off by the editable scrollable region.
const delta = Math.min(
absoluteButtonRect.height,
scrollViewportRect.top - absoluteButtonRect.top
);

markAsHidden( delta >= absoluteButtonRect.height );
setButtonClipping(
`0 ${ toPx( delta ) }`,
`100% ${ toPx( delta ) }`,
'100% 100%',
'0 100%'
);
} else {
// Reset the clip path if button is fully visible.
markAsHidden( false );
setButtonClipping();
}
}

/**
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

<h2>Scrollable parent:</h2>

<div style="max-height: 300px; max-width: 400px; overflow: auto;">
<div id="editor-scrollable-parent">
<h2>About CKEditor&nbsp;5</h2>

<p>This is <a href="https://ckeditor.com">CKEditor&nbsp;5</a>.</p>

<figure class="image">
<img src="./sample.jpg" alt="Autumn fields" />
</figure>

<p>After more than 2 years of building the next generation editor from scratch and closing over 980 tickets, we created a highly <strong>extensible and flexible architecture</strong> which consists of an <strong>amazing editing framework</strong> and <strong>editing solutions</strong> that will be built on top of it.</p>

<p>We explained this design choice in <a href="https://medium.com/content-uneditable/ckeditor-5-the-future-of-rich-text-editing-2b9300f9df2c">&ldquo;CKEditor 5: The future of rich text editing&ldquo;</a>:</p>

<blockquote><p>(…) we are changing our approach with CKEditor 5. We will no longer have only two solutions available, instead CKEditor will be seen as a framework for editing solutions. At the same time, we will be developing several out-of-the-box solutions based on it, which will be available to use in many different contexts. It will be a real “one size fits all” approach, from little requirements, to super advanced full featured applications.</p></blockquote>

<h3>Notes</h3>

<p><a href="https://ckeditor.com">CKEditor&nbsp;5</a> is <i>under heavy development</i> and this demo is not production-ready software. For example:</p>

<ul>
<li><strong>Only Chrome, Opera and Safari are supported</strong>.</li>
<li>Firefox requires enabling the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/onselectionchange">&ldquo;dom.select_events.enabled&rdquo;</a> option.</li>
<li><a href="https://github.com/ckeditor/ckeditor5/issues/342">Support for pasting</a> is under development (content filtering is unstable).</li>
</ul>

<p>It has <em>bugs</em> that we are aware of &mdash; and that we will be working on in the next few iterations of the project. Stay tuned for some updates soon!</p>
</div>
</div>

<br />
<br />

<h2>Scrollable editable:</h2>

<div id="editor-scrollable" style="max-height: 300px; max-width: 400px; overflow-y: scroll;">
<h2>About CKEditor&nbsp;5</h2>

<p>This is <a href="https://ckeditor.com">CKEditor&nbsp;5</a>.</p>

<figure class="image">
<img src="./sample.jpg" alt="Autumn fields" />
</figure>

<p>After more than 2 years of building the next generation editor from scratch and closing over 980 tickets, we created a highly <strong>extensible and flexible architecture</strong> which consists of an <strong>amazing editing framework</strong> and <strong>editing solutions</strong> that will be built on top of it.</p>

<p>We explained this design choice in <a href="https://medium.com/content-uneditable/ckeditor-5-the-future-of-rich-text-editing-2b9300f9df2c">&ldquo;CKEditor 5: The future of rich text editing&ldquo;</a>:</p>

<blockquote><p>(…) we are changing our approach with CKEditor 5. We will no longer have only two solutions available, instead CKEditor will be seen as a framework for editing solutions. At the same time, we will be developing several out-of-the-box solutions based on it, which will be available to use in many different contexts. It will be a real “one size fits all” approach, from little requirements, to super advanced full featured applications.</p></blockquote>

<h3>Notes</h3>

<p><a href="https://ckeditor.com">CKEditor&nbsp;5</a> is <i>under heavy development</i> and this demo is not production-ready software. For example:</p>

<ul>
<li><strong>Only Chrome, Opera and Safari are supported</strong>.</li>
<li>Firefox requires enabling the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/onselectionchange">&ldquo;dom.select_events.enabled&rdquo;</a> option.</li>
<li><a href="https://github.com/ckeditor/ckeditor5/issues/342">Support for pasting</a> is under development (content filtering is unstable).</li>
</ul>

<p>It has <em>bugs</em> that we are aware of &mdash; and that we will be working on in the next few iterations of the project. Stay tuned for some updates soon!</p>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* globals window, document, console:false */

import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor.js';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js';
import List from '@ckeditor/ckeditor5-list/src/list.js';
import Image from '@ckeditor/ckeditor5-image/src/image.js';
import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption.js';
import { Paragraph, ParagraphButtonUI } from '@ckeditor/ckeditor5-paragraph';
import Heading from '@ckeditor/ckeditor5-heading/src/heading.js';
import HeadingButtonsUI from '@ckeditor/ckeditor5-heading/src/headingbuttonsui.js';
import BlockToolbar from '../../../src/toolbar/block/blocktoolbar.js';

createBlockButtonEditor( '#editor-scrollable-parent' ).then( editor => {
window.editor = editor;
} );

createBlockButtonEditor( '#editor-scrollable' ).then( editor => {
window.editor2 = editor;
} );

function createBlockButtonEditor( element ) {
return BalloonEditor
.create( document.querySelector( element ), {
plugins: [ Essentials, List, Paragraph, Heading, Image, ImageCaption, HeadingButtonsUI, ParagraphButtonUI, BlockToolbar ],
blockToolbar: [
'paragraph', 'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph',
'heading1', 'heading2', 'heading3', 'bulletedList', 'numberedList', 'paragraph', 'heading1', 'heading2', 'heading3',
'bulletedList', 'numberedList'
]
} )
.catch( err => {
console.error( err.stack );
} );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Scrollable block toolbar

The manual test that allows testing various block toolbar behavior within scrollable containers.
Loading

0 comments on commit 498f6f2

Please sign in to comment.