UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

513 lines (512 loc) • 20.4 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/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` &ndash; A fixed position in the center of the screen. * * `DialogViewPosition.EDITOR_CENTER` &ndash; A dynamic position in the center of the editor editable area. * * `DialogViewPosition.EDITOR_TOP_SIDE` &ndash; 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` &ndash; A dynamic position at the top-center of the editor editable area. * * `DialogViewPosition.EDITOR_BOTTOM_CENTER` &ndash; A dynamic position at the bottom-center of the editor editable area. * * `DialogViewPosition.EDITOR_ABOVE_CENTER` &ndash; A dynamic position centered above the editor editable area. * * `DialogViewPosition.EDITOR_BELOW_CENTER` &ndash; 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;