@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
513 lines (512 loc) • 20.4 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/dialog/dialogview
*/
import { KeystrokeHandler, FocusTracker, Rect, global, toUnit } from '@ckeditor/ckeditor5-utils';
import { IconCancel } from '@ckeditor/ckeditor5-icons';
import ViewCollection from '../viewcollection.js';
import View from '../view.js';
import FormHeaderView from '../formheader/formheaderview.js';
import ButtonView from '../button/buttonview.js';
import FocusCycler, { isViewWithFocusCycler, isFocusable } from '../focuscycler.js';
import DraggableViewMixin from '../bindings/draggableviewmixin.js';
import DialogActionsView from './dialogactionsview.js';
import DialogContentView from './dialogcontentview.js';
import '../../theme/components/dialog/dialog.css';
// @if CK_DEBUG_DIALOG // const RectDrawer = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ).default;
/**
* Available dialog view positions:
*
* * `DialogViewPosition.SCREEN_CENTER` – A fixed position in the center of the screen.
* * `DialogViewPosition.EDITOR_CENTER` – A dynamic position in the center of the editor editable area.
* * `DialogViewPosition.EDITOR_TOP_SIDE` – A dynamic position at the top-right (for the left-to-right languages)
* or top-left (for right-to-left languages) corner of the editor editable area.
* * `DialogViewPosition.EDITOR_TOP_CENTER` – A dynamic position at the top-center of the editor editable area.
* * `DialogViewPosition.EDITOR_BOTTOM_CENTER` – A dynamic position at the bottom-center of the editor editable area.
* * `DialogViewPosition.EDITOR_ABOVE_CENTER` – A dynamic position centered above the editor editable area.
* * `DialogViewPosition.EDITOR_BELOW_CENTER` – A dynamic position centered below the editor editable area.
*
* The position of a dialog is specified by a {@link module:ui/dialog/dialog~DialogDefinition#position `position` property} of a
* definition passed to the {@link module:ui/dialog/dialog~Dialog#show} method.
*/
export const DialogViewPosition = {
SCREEN_CENTER: 'screen-center',
EDITOR_CENTER: 'editor-center',
EDITOR_TOP_SIDE: 'editor-top-side',
EDITOR_TOP_CENTER: 'editor-top-center',
EDITOR_BOTTOM_CENTER: 'editor-bottom-center',
EDITOR_ABOVE_CENTER: 'editor-above-center',
EDITOR_BELOW_CENTER: 'editor-below-center'
};
const toPx = /* #__PURE__ */ toUnit('px');
/**
* A dialog view class.
*/
class DialogView extends /* #__PURE__ */ DraggableViewMixin(View) {
/**
* A collection of the child views inside of the dialog.
* A dialog can have 3 optional parts: header, content, and actions.
*/
parts;
/**
* A header view of the dialog. It is also a drag handle of the dialog.
*/
headerView;
/**
* A close button view. It is automatically added to the header view if present.
*/
closeButtonView;
/**
* A view with the action buttons available to the user.
*/
actionsView;
/**
* A default dialog element offset from the reference element (e.g. editor editable area).
*/
static defaultOffset = 15;
/**
* A view with the dialog content.
*/
contentView;
/**
* A keystroke handler instance.
*/
keystrokes;
/**
* A focus tracker instance.
*/
focusTracker;
/**
* A flag indicating if the dialog was moved manually. If so, its position
* will not be updated automatically upon window resize or document scroll.
*/
wasMoved = false;
/**
* A callback returning the DOM root that requested the dialog.
*/
_getCurrentDomRoot;
/**
* A callback returning the configured editor viewport offset.
*/
_getViewportOffset;
/**
* The list of the focusable elements inside the dialog view.
*/
_focusables;
/**
* The focus cycler instance.
*/
_focusCycler;
/**
* @inheritDoc
*/
constructor(locale, { getCurrentDomRoot, getViewportOffset, keystrokeHandlerOptions }) {
super(locale);
const bind = this.bindTemplate;
const t = locale.t;
this.set('className', '');
this.set('ariaLabel', t('Editor dialog'));
this.set('isModal', false);
this.set('position', DialogViewPosition.SCREEN_CENTER);
this.set('_isVisible', false);
this.set('_isTransparent', false);
this.set('_top', 0);
this.set('_left', 0);
this._getCurrentDomRoot = getCurrentDomRoot;
this._getViewportOffset = getViewportOffset;
this.decorate('moveTo');
this.parts = this.createCollection();
this.keystrokes = new KeystrokeHandler();
this.focusTracker = new FocusTracker();
this._focusables = new ViewCollection();
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
// Navigate form fields backwards using the Shift + Tab keystroke.
focusPrevious: 'shift + tab',
// Navigate form fields forwards using the Tab key.
focusNext: 'tab'
},
keystrokeHandlerOptions
});
this.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-dialog-overlay',
bind.if('isModal', 'ck-dialog-overlay__transparent', isModal => !isModal),
bind.if('_isVisible', 'ck-hidden', value => !value)
],
// Prevent from editor losing focus when clicking on the modal overlay.
tabindex: '-1'
},
children: [
{
tag: 'div',
attributes: {
tabindex: '-1',
class: [
'ck',
'ck-dialog',
bind.if('isModal', 'ck-dialog_modal'),
bind.to('className')
],
role: 'dialog',
'aria-label': bind.to('ariaLabel'),
style: {
top: bind.to('_top', top => toPx(top)),
left: bind.to('_left', left => toPx(left)),
visibility: bind.if('_isTransparent', 'hidden')
}
},
children: this.parts
}
]
});
}
/**
* @inheritDoc
*/
render() {
super.render();
this.keystrokes.set('Esc', (data, cancel) => {
// Do not react to the Esc key if the event has already been handled and defaultPrevented
// by some logic of the dialog guest (child) view (https://github.com/ckeditor/ckeditor5/issues/17343).
if (!data.defaultPrevented) {
this.fire('close', { source: 'escKeyPress' });
cancel();
}
});
// Support for dragging the modal.
this.on('drag', (evt, { deltaX, deltaY }) => {
this.wasMoved = true;
this.moveBy(deltaX, deltaY);
});
// Update dialog position upon window resize, if the position was not changed manually.
this.listenTo(global.window, 'resize', () => {
if (this._isVisible && !this.wasMoved) {
this.updatePosition();
}
});
// Update dialog position upon document scroll, if the position was not changed manually.
this.listenTo(global.document, 'scroll', () => {
if (this._isVisible && !this.wasMoved) {
this.updatePosition();
}
});
this.on('change:_isVisible', (evt, name, isVisible) => {
if (isVisible) {
// Let the content render first, then apply the position. Otherwise, the calculated DOM Rects
// will not reflect the final look of the dialog. Note that we're not using #_moveOffScreen() here because
// it causes a violent movement of the viewport on iOS (because the dialog still keeps the DOM focus).
this._isTransparent = true;
// FYI: RAF is too short. We need to wait a bit longer.
setTimeout(() => {
this.updatePosition();
this._isTransparent = false;
// The view must get the focus after it gets visible. But this is only possible
// after the dialog is no longer transparent.
this.focus();
}, 10);
}
});
this.keystrokes.listenTo(this.element);
}
/**
* Returns the element that should be used as a drag handle.
*/
get dragHandleElement() {
// Modals should not be draggable.
if (this.headerView && !this.isModal) {
return this.headerView.element;
}
else {
return null;
}
}
/**
* Creates the dialog parts. Which of them are created depends on the arguments passed to the method.
* There are no rules regarding the dialog construction, that is, no part is mandatory.
* Each part can only be created once.
*
* @internal
*/
setupParts({ icon, title, hasCloseButton = true, content, actionButtons }) {
if (title) {
this.headerView = new FormHeaderView(this.locale, { icon });
if (hasCloseButton) {
this.closeButtonView = this._createCloseButton();
this.headerView.children.add(this.closeButtonView);
}
this.headerView.label = title;
this.ariaLabel = title;
this.parts.add(this.headerView, 0);
}
if (content) {
// Normalize the content specified in the arguments.
if (content instanceof View) {
content = [content];
}
this.contentView = new DialogContentView(this.locale);
this.contentView.children.addMany(content);
this.parts.add(this.contentView);
}
if (actionButtons) {
this.actionsView = new DialogActionsView(this.locale);
this.actionsView.setButtons(actionButtons);
this.parts.add(this.actionsView);
}
this._updateFocusCyclableItems();
}
/**
* Focuses the first focusable element inside the dialog.
*/
focus() {
this._focusCycler.focusFirst();
}
/**
* Normalizes the passed coordinates to make sure the dialog view
* is displayed within the visible viewport and moves it there.
*
* @internal
*/
moveTo(left, top) {
const viewportRect = this._getViewportRect();
const dialogRect = this._getDialogRect();
// Don't let the dialog go beyond the right edge of the viewport.
if (left + dialogRect.width > viewportRect.right) {
left = viewportRect.right - dialogRect.width;
}
// Don't let the dialog go beyond the left edge of the viewport.
if (left < viewportRect.left) {
left = viewportRect.left;
}
// Don't let the dialog go beyond the top edge of the viewport.
if (top < viewportRect.top) {
top = viewportRect.top;
}
// Note: We don't do the same for the bottom edge to allow users to resize the window vertically
// and let the dialog to stay put instead of covering the editing root.
this._moveTo(left, top);
}
/**
* Moves the dialog to the specified coordinates.
*/
_moveTo(left, top) {
this._left = left;
this._top = top;
}
/**
* Moves the dialog by the specified offset.
*
* @internal
*/
moveBy(left, top) {
this.moveTo(this._left + left, this._top + top);
}
/**
* Moves the dialog view to the off-screen position.
* Used when there is no space to display the dialog.
*/
_moveOffScreen() {
this._moveTo(-9999, -9999);
}
/**
* Recalculates the dialog according to the set position and viewport,
* and moves it to the new position.
*/
updatePosition() {
if (!this.element || !this.element.parentNode) {
return;
}
const viewportRect = this._getViewportRect();
// Actual position may be different from the configured one if there's no DOM root.
let configuredPosition = this.position;
let domRootRect;
if (!this._getCurrentDomRoot()) {
configuredPosition = DialogViewPosition.SCREEN_CENTER;
}
else {
domRootRect = this._getVisibleDomRootRect(viewportRect);
}
const defaultOffset = DialogView.defaultOffset;
const dialogRect = this._getDialogRect();
// @if CK_DEBUG_DIALOG // RectDrawer.clear();
// @if CK_DEBUG_DIALOG // RectDrawer.draw( viewportRect, { outlineColor: 'blue' }, 'Viewport' );
switch (configuredPosition) {
case DialogViewPosition.EDITOR_TOP_SIDE: {
// @if CK_DEBUG_DIALOG // if ( domRootRect ) {
// @if CK_DEBUG_DIALOG // RectDrawer.draw( domRootRect, { outlineColor: 'red', zIndex: 9999999 }, 'DOM ROOT' );
// @if CK_DEBUG_DIALOG // }
if (domRootRect) {
const leftCoordinate = this.locale.contentLanguageDirection === 'ltr' ?
domRootRect.right - dialogRect.width - defaultOffset :
domRootRect.left + defaultOffset;
this.moveTo(leftCoordinate, domRootRect.top + defaultOffset);
}
else {
this._moveOffScreen();
}
break;
}
case DialogViewPosition.EDITOR_CENTER: {
if (domRootRect) {
this.moveTo(Math.round(domRootRect.left + domRootRect.width / 2 - dialogRect.width / 2), Math.round(domRootRect.top + domRootRect.height / 2 - dialogRect.height / 2));
}
else {
this._moveOffScreen();
}
break;
}
case DialogViewPosition.SCREEN_CENTER: {
this.moveTo(Math.round((viewportRect.width - dialogRect.width) / 2), Math.round((viewportRect.height - dialogRect.height) / 2));
break;
}
case DialogViewPosition.EDITOR_TOP_CENTER: {
// @if CK_DEBUG_DIALOG // if ( domRootRect ) {
// @if CK_DEBUG_DIALOG // RectDrawer.draw( domRootRect, { outlineColor: 'red', zIndex: 9999999 }, 'DOM ROOT' );
// @if CK_DEBUG_DIALOG // }
if (domRootRect) {
this.moveTo(Math.round(domRootRect.left + domRootRect.width / 2 - dialogRect.width / 2), domRootRect.top + defaultOffset);
}
else {
this._moveOffScreen();
}
break;
}
case DialogViewPosition.EDITOR_BOTTOM_CENTER: {
// @if CK_DEBUG_DIALOG // if ( domRootRect ) {
// @if CK_DEBUG_DIALOG // RectDrawer.draw( domRootRect, { outlineColor: 'red', zIndex: 9999999 }, 'DOM ROOT' );
// @if CK_DEBUG_DIALOG // }
if (domRootRect) {
this.moveTo(Math.round(domRootRect.left + domRootRect.width / 2 - dialogRect.width / 2), domRootRect.bottom - dialogRect.height - defaultOffset);
}
else {
this._moveOffScreen();
}
break;
}
case DialogViewPosition.EDITOR_ABOVE_CENTER: {
// @if CK_DEBUG_DIALOG // if ( domRootRect ) {
// @if CK_DEBUG_DIALOG // RectDrawer.draw( domRootRect, { outlineColor: 'red', zIndex: 9999999 }, 'DOM ROOT' );
// @if CK_DEBUG_DIALOG // }
if (domRootRect) {
this.moveTo(Math.round(domRootRect.left + domRootRect.width / 2 - dialogRect.width / 2), domRootRect.top - dialogRect.height - defaultOffset);
}
else {
this._moveOffScreen();
}
break;
}
case DialogViewPosition.EDITOR_BELOW_CENTER: {
// @if CK_DEBUG_DIALOG // if ( domRootRect ) {
// @if CK_DEBUG_DIALOG // RectDrawer.draw( domRootRect, { outlineColor: 'red', zIndex: 9999999 }, 'DOM ROOT' );
// @if CK_DEBUG_DIALOG // }
if (domRootRect) {
this.moveTo(Math.round(domRootRect.left + domRootRect.width / 2 - dialogRect.width / 2), domRootRect.bottom + defaultOffset);
}
else {
this._moveOffScreen();
}
break;
}
}
}
/**
* Calculates the visible DOM root part.
*/
_getVisibleDomRootRect(viewportRect) {
let visibleDomRootRect = new Rect(this._getCurrentDomRoot()).getVisible();
if (!visibleDomRootRect) {
return null;
}
else {
visibleDomRootRect = viewportRect.getIntersection(visibleDomRootRect);
if (!visibleDomRootRect) {
return null;
}
}
return visibleDomRootRect;
}
/**
* Calculates the dialog element rect.
*/
_getDialogRect() {
return new Rect(this.element.firstElementChild);
}
/**
* Returns a viewport `Rect` shrunk by the viewport offset config from all sides.
*
* TODO: This is a duplicate from position.ts module. It should either be exported there or land somewhere in utils.
*/
_getViewportRect() {
const viewportRect = new Rect(global.window);
// Modals should not be restricted by the viewport offsets as they are always displayed on top of the page.
if (this.isModal) {
return viewportRect;
}
const viewportOffset = {
top: 0,
bottom: 0,
left: 0,
right: 0,
...this._getViewportOffset()
};
viewportRect.top += viewportOffset.top;
viewportRect.height -= viewportOffset.top;
viewportRect.bottom -= viewportOffset.bottom;
viewportRect.height -= viewportOffset.bottom;
viewportRect.left += viewportOffset.left;
viewportRect.right -= viewportOffset.right;
viewportRect.width -= viewportOffset.left + viewportOffset.right;
return viewportRect;
}
/**
* Collects all focusable elements inside the dialog parts
* and adds them to the focus tracker and focus cycler.
*/
_updateFocusCyclableItems() {
const focusables = [];
if (this.contentView) {
for (const child of this.contentView.children) {
if (isFocusable(child)) {
focusables.push(child);
}
}
}
if (this.actionsView) {
focusables.push(this.actionsView);
}
if (this.closeButtonView) {
focusables.push(this.closeButtonView);
}
focusables.forEach(focusable => {
this._focusables.add(focusable);
this.focusTracker.add(focusable.element);
if (isViewWithFocusCycler(focusable)) {
this._focusCycler.chain(focusable.focusCycler);
}
});
}
/**
* Creates the close button view that is displayed in the header view corner.
*/
_createCloseButton() {
const buttonView = new ButtonView(this.locale);
const t = this.locale.t;
buttonView.set({
label: t('Close'),
tooltip: true,
icon: IconCancel
});
buttonView.on('execute', () => this.fire('close', { source: 'closeButton' }));
return buttonView;
}
}
export default DialogView;