diff --git a/packages/ckeditor5-core/src/editor/editorui.js b/packages/ckeditor5-core/src/editor/editorui.js index bc4b462e0ee..09e3e2951b9 100644 --- a/packages/ckeditor5-core/src/editor/editorui.js +++ b/packages/ckeditor5-core/src/editor/editorui.js @@ -11,6 +11,7 @@ import ComponentFactory from '@ckeditor/ckeditor5-ui/src/componentfactory'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; +import TooltipManager from '@ckeditor/ckeditor5-ui/src/tooltipmanager'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; @@ -54,6 +55,14 @@ export default class EditorUI { */ this.focusTracker = new FocusTracker(); + /** + * Manages the tooltips displayed on mouseover and focus across the UI. + * + * @readonly + * @member {module:ui/tooltipmanager~TooltipManager} + */ + this.tooltipManager = new TooltipManager( editor ); + /** * Stores viewport offsets from every direction. * @@ -157,6 +166,7 @@ export default class EditorUI { this.stopListening(); this.focusTracker.destroy(); + this.tooltipManager.destroy( this.editor ); // Clean–up the references to the CKEditor instance stored in the native editable DOM elements. for ( const domElement of this._editableElementsMap.values() ) { diff --git a/packages/ckeditor5-core/tests/editor/editorui.js b/packages/ckeditor5-core/tests/editor/editorui.js index 85b45506b21..6dd3b7435de 100644 --- a/packages/ckeditor5-core/tests/editor/editorui.js +++ b/packages/ckeditor5-core/tests/editor/editorui.js @@ -7,9 +7,10 @@ import EditorUI from '../../src/editor/editorui'; import Editor from '../../src/editor/editor'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import ComponentFactory from '@ckeditor/ckeditor5-ui/src/componentfactory'; import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import TooltipManager from '@ckeditor/ckeditor5-ui/src/tooltipmanager'; import testUtils from '../_utils/utils'; @@ -42,6 +43,10 @@ describe( 'EditorUI', () => { expect( ui.focusTracker ).to.be.instanceOf( FocusTracker ); } ); + it( 'should create #tooltipManager', () => { + expect( ui.tooltipManager ).to.be.instanceOf( TooltipManager ); + } ); + it( 'should have #element getter', () => { expect( ui.element ).to.null; } ); @@ -121,6 +126,23 @@ describe( 'EditorUI', () => { expect( fooElement.ckeditorInstance ).to.be.null; expect( barElement.ckeditorInstance ).to.be.null; } ); + + it( 'should destroy #focusTracker', () => { + const destroySpy = sinon.spy( ui.focusTracker, 'destroy' ); + + ui.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy #tooltipManager', () => { + const destroySpy = sinon.spy( ui.tooltipManager, 'destroy' ); + + ui.destroy(); + + sinon.assert.calledOnce( destroySpy ); + sinon.assert.calledWithExactly( destroySpy, editor ); + } ); } ); describe( 'setEditableElement()', () => { diff --git a/packages/ckeditor5-media-embed/docs/features/media-embed.md b/packages/ckeditor5-media-embed/docs/features/media-embed.md index df5171ce102..c33bec6f154 100644 --- a/packages/ckeditor5-media-embed/docs/features/media-embed.md +++ b/packages/ckeditor5-media-embed/docs/features/media-embed.md @@ -401,7 +401,6 @@ The HTML structure of every non-previewable media in the editor is as follows: [ URL of the media] - ... diff --git a/packages/ckeditor5-media-embed/src/mediaregistry.js b/packages/ckeditor5-media-embed/src/mediaregistry.js index bed0d212f55..539cbd3fb32 100644 --- a/packages/ckeditor5-media-embed/src/mediaregistry.js +++ b/packages/ckeditor5-media-embed/src/mediaregistry.js @@ -7,7 +7,7 @@ * @module media-embed/mediaregistry */ -import { TooltipView, IconView, Template } from 'ckeditor5/src/ui'; +import { IconView, Template } from 'ckeditor5/src/ui'; import { logWarning, toArray } from 'ckeditor5/src/utils'; import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg'; @@ -185,7 +185,7 @@ class Media { * @see module:utils/locale~Locale#t * @method */ - this._t = locale.t; + this._locale = locale; /** * The output of the `RegExp.match` which validated the {@link #url} of this media. @@ -271,10 +271,9 @@ class Media { * @returns {String} */ _getPlaceholderHtml() { - const tooltip = new TooltipView(); const icon = new IconView(); + const t = this._locale.t; - tooltip.text = this._t( 'Open media in new tab' ); icon.content = mediaPlaceholderIcon; icon.viewBox = mediaPlaceholderIconViewBox; @@ -297,7 +296,8 @@ class Media { class: 'ck-media__placeholder__url', target: '_blank', rel: 'noopener noreferrer', - href: this.url + href: this.url, + 'data-cke-tooltip-text': t( 'Open media in new tab' ) }, children: [ { @@ -306,8 +306,7 @@ class Media { class: 'ck-media__placeholder__url__text' }, children: [ this.url ] - }, - tooltip + } ] } ] diff --git a/packages/ckeditor5-media-embed/tests/mediaembedediting.js b/packages/ckeditor5-media-embed/tests/mediaembedediting.js index 441437eb9ba..06aca5ca784 100644 --- a/packages/ckeditor5-media-embed/tests/mediaembedediting.js +++ b/packages/ckeditor5-media-embed/tests/mediaembedediting.js @@ -1272,11 +1272,14 @@ describe( 'MediaEmbedEditing', () => { ']+>' + '
' + '
.*
' + - `` + - `${ expectedUrl }` + - '' + - 'Open media in new tab' + - '' + + '' + + `${ expectedUrl }` + '' + '
' + '' + diff --git a/packages/ckeditor5-media-embed/theme/mediaembedediting.css b/packages/ckeditor5-media-embed/theme/mediaembedediting.css index 62a30c0abcd..b551168903c 100644 --- a/packages/ckeditor5-media-embed/theme/mediaembedediting.css +++ b/packages/ckeditor5-media-embed/theme/mediaembedediting.css @@ -3,8 +3,6 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -@import "@ckeditor/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css"; - .ck-media__wrapper { & .ck-media__placeholder { display: flex; @@ -12,17 +10,11 @@ align-items: center; & .ck-media__placeholder__url { - @mixin ck-tooltip_enabled; - /* Otherwise the URL will overflow when the content is very narrow. */ max-width: 100%; position: relative; - &:hover { - @mixin ck-tooltip_visible; - } - & .ck-media__placeholder__url__text { overflow: hidden; display: block; diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css index 532e9bdba45..af659e6daf3 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css @@ -3,7 +3,6 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -@import "@ckeditor/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css"; @import "@ckeditor/ckeditor5-ui/theme/mixins/_unselectable.css"; @import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; @import "../mixins/_focus.css"; diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/panel/balloonpanel.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/panel/balloonpanel.css index 281ff7e955f..95841554067 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/panel/balloonpanel.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/panel/balloonpanel.css @@ -7,6 +7,7 @@ @import "../../../mixins/_shadow.css"; :root { + --ck-balloon-border-width: 1px; --ck-balloon-arrow-offset: 2px; --ck-balloon-arrow-height: 10px; --ck-balloon-arrow-half-width: 8px; @@ -20,7 +21,7 @@ min-height: 15px; background: var(--ck-color-panel-background); - border: 1px solid var(--ck-color-panel-border); + border: var(--ck-balloon-border-width) solid var(--ck-color-panel-border); &.ck-balloon-panel_with-arrow { &::before, @@ -39,11 +40,12 @@ &::before { border-color: transparent transparent var(--ck-color-panel-border) transparent; + margin-top: calc( -1 * var(--ck-balloon-border-width) ); } &::after { border-color: transparent transparent var(--ck-color-panel-background) transparent; - margin-top: var(--ck-balloon-arrow-offset); + margin-top: calc( var(--ck-balloon-arrow-offset) - var(--ck-balloon-border-width) ); } } @@ -56,11 +58,46 @@ &::before { border-color: var(--ck-color-panel-border) transparent transparent; filter: drop-shadow(var(--ck-balloon-arrow-drop-shadow)); + margin-bottom: calc( -1 * var(--ck-balloon-border-width) ); } &::after { border-color: var(--ck-color-panel-background) transparent transparent transparent; - margin-bottom: var(--ck-balloon-arrow-offset); + margin-bottom: calc( var(--ck-balloon-arrow-offset) - var(--ck-balloon-border-width) ); + } + } + + &[class*="arrow_e"] { + &::before, + &::after { + border-width: var(--ck-balloon-arrow-half-width) 0 var(--ck-balloon-arrow-half-width) var(--ck-balloon-arrow-height); + } + + &::before { + border-color: transparent transparent transparent var(--ck-color-panel-border); + margin-right: calc( -1 * var(--ck-balloon-border-width) ); + } + + &::after { + border-color: transparent transparent transparent var(--ck-color-panel-background); + margin-right: calc( var(--ck-balloon-arrow-offset) - var(--ck-balloon-border-width) ); + } + } + + &[class*="arrow_w"] { + &::before, + &::after { + border-width: var(--ck-balloon-arrow-half-width) var(--ck-balloon-arrow-height) var(--ck-balloon-arrow-half-width) 0; + } + + &::before { + border-color: transparent var(--ck-color-panel-border) transparent transparent; + margin-left: calc( -1 * var(--ck-balloon-border-width) ); + } + + &::after { + border-color: transparent var(--ck-color-panel-background) transparent transparent; + margin-left: calc( var(--ck-balloon-arrow-offset) - var(--ck-balloon-border-width) ); } } @@ -149,4 +186,22 @@ top: calc(-1 * var(--ck-balloon-arrow-height)); } } + + &.ck-balloon-panel_arrow_e { + &::before, + &::after { + right: calc(-1 * var(--ck-balloon-arrow-height)); + margin-top: calc(-1 * var(--ck-balloon-arrow-half-width)); + top: 50%; + } + } + + &.ck-balloon-panel_arrow_w { + &::before, + &::after { + left: calc(-1 * var(--ck-balloon-arrow-height)); + margin-top: calc(-1 * var(--ck-balloon-arrow-half-width)); + top: 50%; + } + } } diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/tooltip/tooltip.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/tooltip/tooltip.css index 10e476972a4..7388252a5e1 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/tooltip/tooltip.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/tooltip/tooltip.css @@ -5,190 +5,26 @@ @import "../../../mixins/_rounded.css"; -:root { - --ck-tooltip-arrow-size: 5px; -} - -.ck.ck-tooltip { - left: 50%; +.ck.ck-balloon-panel.ck-tooltip { + --ck-balloon-border-width: 0px; + --ck-balloon-arrow-offset: 0px; + --ck-balloon-arrow-half-width: 4px; + --ck-balloon-arrow-height: 4px; + --ck-color-panel-background: var(--ck-color-tooltip-background); - /* - * Prevent blurry tooltips in LoDPI environments. - * See https://github.com/ckeditor/ckeditor5/issues/1802. - */ - top: 0; - - /* - * For the transition to work, the tooltip must be controlled - * using visibility+opacity. A delay prevents a "tooltip avalanche" - * i.e. when scanning the toolbar with mouse cursor. - */ - transition: opacity .2s ease-in-out .2s; + padding: 0 var(--ck-spacing-medium); & .ck-tooltip__text { - @mixin ck-rounded-corners; - font-size: .9em; line-height: 1.5; color: var(--ck-color-tooltip-text); - padding: var(--ck-spacing-small) var(--ck-spacing-medium); - background: var(--ck-color-tooltip-background); - position: relative; - left: -50%; - - &::after { - /* - * For the transition to work, the tooltip must be controlled - * using visibility+opacity. A delay prevents a "tooltip avalanche" - * i.e. when scanning the toolbar with mouse cursor. - */ - transition: opacity .2s ease-in-out .2s; - border-style: solid; - left: 50%; - } - } - - /** - * A class that displays the tooltip south of the element. - * - * [element] - * ^ - * +-----------+ - * | Tooltip | - * +-----------+ - */ - &.ck-tooltip_s, - &.ck-tooltip_sw, - &.ck-tooltip_se { - bottom: calc(-1 * var(--ck-tooltip-arrow-size)); - transform: translateY( 100% ); - - & .ck-tooltip__text::after { - /* 1px addresses gliches in rendering causing gap between the triangle and the text */ - top: calc(-1 * var(--ck-tooltip-arrow-size) + 1px); - transform: translateX( -50% ); - border-color: transparent transparent var(--ck-color-tooltip-background) transparent; - border-width: 0 var(--ck-tooltip-arrow-size) var(--ck-tooltip-arrow-size) var(--ck-tooltip-arrow-size); - } - } - - /** - * A class that displays the tooltip south-west of the element. - * - * [element] - * ^ - * +-----------+ - * | Tooltip | - * +-----------+ - */ - - &.ck-tooltip_sw { - right: 50%; - left: auto; - - & .ck-tooltip__text { - left: auto; - right: calc( -2 * var(--ck-tooltip-arrow-size)); - } - - & .ck-tooltip__text::after { - left: auto; - right: 0; - } } - /** - * A class that displays the tooltip south-east of the element. - * - * [element] - * ^ - * +-----------+ - * | Tooltip | - * +-----------+ - */ - &.ck-tooltip_se { - left: 50%; - right: auto; - - & .ck-tooltip__text { - right: auto; - left: calc( -2 * var(--ck-tooltip-arrow-size)); - } - - & .ck-tooltip__text::after { - right: auto; - left: 0; - transform: translateX( 50% ); - } - } - - /** - * A class that displays the tooltip north of the element. - * - * +-----------+ - * | Tooltip | - * +-----------+ - * V - * [element] - */ - &.ck-tooltip_n { - top: calc(-1 * var(--ck-tooltip-arrow-size)); - transform: translateY( -100% ); - - & .ck-tooltip__text::after { - bottom: calc(-1 * var(--ck-tooltip-arrow-size)); - transform: translateX( -50% ); - border-color: var(--ck-color-tooltip-background) transparent transparent transparent; - border-width: var(--ck-tooltip-arrow-size) var(--ck-tooltip-arrow-size) 0 var(--ck-tooltip-arrow-size); - } - } - - /** - * A class that displays the tooltip east of the element. - * - * +----------+ - * [element] < | east | - * +----------+ - */ - &.ck-tooltip_e { - left: calc(100% + var(--ck-tooltip-arrow-size)); - top: 50%; - - & .ck-tooltip__text { - left: 0; - transform: translateY( -50% ); - - &::after { - left: calc(-1 * var(--ck-tooltip-arrow-size)); - top: calc(50% - 1 * var(--ck-tooltip-arrow-size)); - border-color: transparent var(--ck-color-tooltip-background) transparent transparent; - border-width: var(--ck-tooltip-arrow-size) var(--ck-tooltip-arrow-size) var(--ck-tooltip-arrow-size) 0; - } - } - } - - /** - * A class that displays the tooltip west of the element. - * - * +----------+ - * | west | > [element] - * +----------+ - */ - &.ck-tooltip_w { - right: calc(100% + var(--ck-tooltip-arrow-size)); - left: auto; - top: 50%; - - & .ck-tooltip__text { - left: 0; - transform: translateY( -50% ); + /* Reset balloon panel styles */ + box-shadow: none; - &::after { - left: 100%; - top: calc(50% - 1 * var(--ck-tooltip-arrow-size)); - border-color: transparent transparent transparent var(--ck-color-tooltip-background); - border-width: var(--ck-tooltip-arrow-size) 0 var(--ck-tooltip-arrow-size) var(--ck-tooltip-arrow-size); - } - } + /* Hide the default shadow of the .ck-balloon-panel tip */ + &::before { + display: none; } } diff --git a/packages/ckeditor5-ui/package.json b/packages/ckeditor5-ui/package.json index fd75e0872cc..3d0b4ca99d4 100644 --- a/packages/ckeditor5-ui/package.json +++ b/packages/ckeditor5-ui/package.json @@ -23,6 +23,8 @@ "@ckeditor/ckeditor5-engine": "^35.0.1", "@ckeditor/ckeditor5-enter": "^35.0.1", "@ckeditor/ckeditor5-essentials": "^35.0.1", + "@ckeditor/ckeditor5-font": "^35.0.1", + "@ckeditor/ckeditor5-find-and-replace": "^35.0.1", "@ckeditor/ckeditor5-heading": "^35.0.1", "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-link": "^35.0.1", diff --git a/packages/ckeditor5-ui/src/button/button.jsdoc b/packages/ckeditor5-ui/src/button/button.jsdoc index 7de7f354350..3f848431de2 100644 --- a/packages/ckeditor5-ui/src/button/button.jsdoc +++ b/packages/ckeditor5-ui/src/button/button.jsdoc @@ -51,8 +51,8 @@ */ /** - * (Optional) The position of the tooltip. See {@link module:ui/tooltip/tooltipview~TooltipView#position} - * to learn more about the available position values. + * (Optional) The position of the tooltip. See {@link module:ui/tooltipmanager~TooltipManager} + * to learn more about the tooltip system. * * **Note:** It makes sense only when the {@link #tooltip `tooltip` attribute} is defined. * diff --git a/packages/ckeditor5-ui/src/button/buttonview.js b/packages/ckeditor5-ui/src/button/buttonview.js index 269bc482ebc..b4d44bcc34a 100644 --- a/packages/ckeditor5-ui/src/button/buttonview.js +++ b/packages/ckeditor5-ui/src/button/buttonview.js @@ -9,7 +9,6 @@ import View from '../view'; import IconView from '../icon/iconview'; -import TooltipView from '../tooltip/tooltipview'; import uid from '@ckeditor/ckeditor5-utils/src/uid'; import { getEnvKeystrokeText } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -71,14 +70,6 @@ export default class ButtonView extends View { */ this.children = this.createCollection(); - /** - * Tooltip of the button view. It is configurable using the {@link #tooltip tooltip attribute}. - * - * @readonly - * @member {module:ui/tooltip/tooltipview~TooltipView} #tooltipView - */ - this.tooltipView = this._createTooltipView(); - /** * Label of the button view. It is configurable using the {@link #label label attribute}. * @@ -119,7 +110,7 @@ export default class ButtonView extends View { * @see #_getTooltipString * @private * @observable - * @member {Boolean} #_tooltipString + * @member {String|undefined} #_tooltipString */ this.bind( '_tooltipString' ).to( this, 'tooltip', @@ -146,7 +137,9 @@ export default class ButtonView extends View { tabindex: bind.to( 'tabindex' ), 'aria-labelledby': `ck-editor__aria-label_${ ariaLabelUid }`, 'aria-disabled': bind.if( 'isEnabled', true, value => !value ), - 'aria-pressed': bind.to( 'isOn', value => this.isToggleable ? String( !!value ) : false ) + 'aria-pressed': bind.to( 'isOn', value => this.isToggleable ? String( !!value ) : false ), + 'data-cke-tooltip-text': bind.to( '_tooltipString' ), + 'data-cke-tooltip-position': bind.to( 'tooltipPosition' ) }, children: this.children, @@ -189,7 +182,6 @@ export default class ButtonView extends View { this.children.add( this.iconView ); } - this.children.add( this.tooltipView ); this.children.add( this.labelView ); if ( this.withKeystroke && this.keystroke ) { @@ -204,22 +196,6 @@ export default class ButtonView extends View { this.element.focus(); } - /** - * Creates a {@link module:ui/tooltip/tooltipview~TooltipView} instance and binds it with button - * attributes. - * - * @private - * @returns {module:ui/tooltip/tooltipview~TooltipView} - */ - _createTooltipView() { - const tooltipView = new TooltipView(); - - tooltipView.bind( 'text' ).to( this, '_tooltipString' ); - tooltipView.bind( 'position' ).to( this, 'tooltipPosition' ); - - return tooltipView; - } - /** * Creates a label view instance and binds it with button attributes. * @@ -284,7 +260,7 @@ export default class ButtonView extends View { } /** - * Gets the text for the {@link #tooltipView} from the combination of + * Gets the text for the tooltip from the combination of * {@link #tooltip}, {@link #label} and {@link #keystroke} attributes. * * @private diff --git a/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.js b/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.js index 93b8a79f089..1f3d538e547 100644 --- a/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.js +++ b/packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.js @@ -222,7 +222,10 @@ export default class SplitButtonView extends View { arrowView.extendTemplate( { attributes: { - class: 'ck-splitbutton__arrow', + class: [ + 'ck-splitbutton__arrow' + ], + 'data-cke-tooltip-disabled': bind.to( 'isOn' ), 'aria-haspopup': true, 'aria-expanded': bind.to( 'isOn', value => String( value ) ) } diff --git a/packages/ckeditor5-ui/src/dropdown/dropdownview.js b/packages/ckeditor5-ui/src/dropdown/dropdownview.js index 541009dd241..e9a7b7b05c6 100644 --- a/packages/ckeditor5-ui/src/dropdown/dropdownview.js +++ b/packages/ckeditor5-ui/src/dropdown/dropdownview.js @@ -194,7 +194,8 @@ export default class DropdownView extends View { attributes: { class: [ 'ck-dropdown__button' - ] + ], + 'data-cke-tooltip-disabled': bind.to( 'isOpen' ) } } ); diff --git a/packages/ckeditor5-ui/src/index.js b/packages/ckeditor5-ui/src/index.js index 338f1b74075..8aee05054b1 100644 --- a/packages/ckeditor5-ui/src/index.js +++ b/packages/ckeditor5-ui/src/index.js @@ -53,7 +53,7 @@ export { default as BalloonPanelView } from './panel/balloon/balloonpanelview'; export { default as ContextualBalloon } from './panel/balloon/contextualballoon'; export { default as StickyPanelView } from './panel/sticky/stickypanelview'; -export { default as TooltipView } from './tooltip/tooltipview'; +export { default as TooltipManager } from './tooltipmanager'; export { default as Template } from './template'; export { default as ToolbarView } from './toolbar/toolbarview'; diff --git a/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js index 8e61367f3ad..a0663383e6d 100644 --- a/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js @@ -392,60 +392,74 @@ function getDomElement( object ) { } /** - * A horizontal offset of the arrow tip from the edge of the balloon. Controlled by CSS. - * - * +-----|---------... - * | | - * | | - * | | - * | | - * +--+ | +------... - * \ | / - * \|/ - * >|-----|<---------------- horizontal offset + * A side offset of the arrow tip from the edge of the balloon. Controlled by CSS. + * + * ┌───────────────────────┐ + * │ │ + * │ Balloon │ + * │ Content │ + * │ │ + * └──+ +───────────────┘ + * | \ / + * | \/ + * >┼─────┼< ─────────────────────── side offset + * * * @default 25 - * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowHorizontalOffset + * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowSideOffset */ -BalloonPanelView.arrowHorizontalOffset = 25; +BalloonPanelView.arrowSideOffset = 25; /** - * A vertical offset of the arrow from the edge of the balloon. Controlled by CSS. - * - * +-------------... - * | - * | - * | /-- vertical offset - * | V - * +--+ +-----... --------- - * \ / | - * \/ | - * ------------------------------- - * ^ + * A height offset of the arrow from the edge of the balloon. Controlled by CSS. + * + * ┌───────────────────────┐ + * │ │ + * │ Balloon │ + * │ Content │ ╱-- arrow height offset + * │ │ V + * └──+ +───────────────┘ --- ─┼─────── + * \ / │ + * \/ │ + * ────────────────────────────────┼─────── + * ^ + * + * + * >┼────┼< arrow height offset + * │ │ + * │ ┌────────────────────────┐ + * │ │ │ + * │ ╱ │ + * │ ╱ Balloon │ + * │ ╲ Content │ + * │ ╲ │ + * │ │ │ + * │ └────────────────────────┘ + * * * @default 10 - * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowVerticalOffset + * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowHeightOffset */ -BalloonPanelView.arrowVerticalOffset = 10; +BalloonPanelView.arrowHeightOffset = 10; /** * A vertical offset of the balloon panel from the edge of the viewport if sticky. * It helps in accessing toolbar buttons underneath the balloon panel. * - * +---------------------------------------------------+ - * | Target | - * | | - * | /-- vertical offset | - * +-----------------------------V-------------------------+ - * | Toolbar +-------------+ | - * +--------------------| Balloon |--------------------+ - * | | +-------------+ | | - * | | | | - * | | | | - * | | | | - * | +---------------------------------------------------+ | - * | Viewport | - * +-------------------------------------------------------+ + * ┌───────────────────────────────────────────────────┐ + * │ Target │ + * │ │ + * │ /── vertical offset │ + * ┌─────────────────────────────V─────────────────────────┐ + * │ Toolbar ┌─────────────┐ │ + * ├────────────────────│ Balloon │────────────────────┤ + * │ │ └─────────────┘ │ │ + * │ │ │ │ + * │ │ │ │ + * │ │ │ │ + * │ └───────────────────────────────────────────────────┘ │ + * │ Viewport │ + * └───────────────────────────────────────────────────────┘ * * @default 20 * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.stickyVerticalOffset @@ -697,7 +711,7 @@ BalloonPanelView._getOptimalPosition = getOptimalPosition; * +-----------------+ * | Balloon | * +-----------------+ -* * `southEastArrowNorthMiddleWest` + * * `southEastArrowNorthMiddleWest` * * [ Target ] * ^ @@ -729,6 +743,28 @@ BalloonPanelView._getOptimalPosition = getOptimalPosition; * | Balloon | * +-----------------+ * + * + * + * **West** + * + * * `westArrowEast` + * + * +-----------------+ + * | Balloon |>[ Target ] + * +-----------------+ + * + * **East** + * + * * `eastArrowWest` + * + * +-----------------+ + * [ Target ]<| Balloon | + * +-----------------+ + * + * + * + * **Sticky** + * * * `viewportStickyNorth` * * +---------------------------+ @@ -768,11 +804,11 @@ BalloonPanelView.defaultPositions = generatePositions(); * @protected * @param {Object} [options] Options to generate positions. If not specified, this helper will simply return * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}. - * @param {Number} [options.horizontalOffset] A custom horizontal offset (in pixels) of each position. If - * not specified, {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowHorizontalOffset the default value} + * @param {Number} [options.sideOffset] A custom side offset (in pixels) of each position. If + * not specified, {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowSideOffset the default value} * will be used. - * @param {Number} [options.verticalOffset] A custom vertical offset (in pixels) of each position. If - * not specified, {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowVerticalOffset the default value} + * @param {Number} [options.heightOffset] A custom height offset (in pixels) of each position. If + * not specified, {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowHeightOffset the default value} * will be used. * @param {Number} [options.stickyVerticalOffset] A custom offset (in pixels) of the `viewportStickyNorth` positioning function. * If not specified, {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.stickyVerticalOffset the default value} @@ -783,8 +819,8 @@ BalloonPanelView.defaultPositions = generatePositions(); * @returns {Object.} */ export function generatePositions( { - horizontalOffset = BalloonPanelView.arrowHorizontalOffset, - verticalOffset = BalloonPanelView.arrowVerticalOffset, + sideOffset = BalloonPanelView.arrowSideOffset, + heightOffset = BalloonPanelView.arrowHeightOffset, stickyVerticalOffset = BalloonPanelView.stickyVerticalOffset, config } = {} ) { @@ -793,14 +829,14 @@ export function generatePositions( { northWestArrowSouthWest: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left - horizontalOffset, + left: targetRect.left - sideOffset, name: 'arrow_sw', ...( config && { config } ) } ), northWestArrowSouthMiddleWest: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left - ( balloonRect.width * .25 ) - horizontalOffset, + left: targetRect.left - ( balloonRect.width * .25 ) - sideOffset, name: 'arrow_smw', ...( config && { config } ) } ), @@ -814,14 +850,14 @@ export function generatePositions( { northWestArrowSouthMiddleEast: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left - ( balloonRect.width * .75 ) + horizontalOffset, + left: targetRect.left - ( balloonRect.width * .75 ) + sideOffset, name: 'arrow_sme', ...( config && { config } ) } ), northWestArrowSouthEast: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left - balloonRect.width + horizontalOffset, + left: targetRect.left - balloonRect.width + sideOffset, name: 'arrow_se', ...( config && { config } ) } ), @@ -830,14 +866,14 @@ export function generatePositions( { northArrowSouthWest: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - horizontalOffset, + left: targetRect.left + targetRect.width / 2 - sideOffset, name: 'arrow_sw', ...( config && { config } ) } ), northArrowSouthMiddleWest: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * .25 ) - horizontalOffset, + left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * .25 ) - sideOffset, name: 'arrow_smw', ...( config && { config } ) } ), @@ -851,14 +887,14 @@ export function generatePositions( { northArrowSouthMiddleEast: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * .75 ) + horizontalOffset, + left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * .75 ) + sideOffset, name: 'arrow_sme', ...( config && { config } ) } ), northArrowSouthEast: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - balloonRect.width + horizontalOffset, + left: targetRect.left + targetRect.width / 2 - balloonRect.width + sideOffset, name: 'arrow_se', ...( config && { config } ) } ), @@ -867,14 +903,14 @@ export function generatePositions( { northEastArrowSouthWest: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.right - horizontalOffset, + left: targetRect.right - sideOffset, name: 'arrow_sw', ...( config && { config } ) } ), northEastArrowSouthMiddleWest: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.right - ( balloonRect.width * .25 ) - horizontalOffset, + left: targetRect.right - ( balloonRect.width * .25 ) - sideOffset, name: 'arrow_smw', ...( config && { config } ) } ), @@ -888,14 +924,14 @@ export function generatePositions( { northEastArrowSouthMiddleEast: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.right - ( balloonRect.width * .75 ) + horizontalOffset, + left: targetRect.right - ( balloonRect.width * .75 ) + sideOffset, name: 'arrow_sme', ...( config && { config } ) } ), northEastArrowSouthEast: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), - left: targetRect.right - balloonRect.width + horizontalOffset, + left: targetRect.right - balloonRect.width + sideOffset, name: 'arrow_se', ...( config && { config } ) } ), @@ -904,14 +940,14 @@ export function generatePositions( { southWestArrowNorthWest: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left - horizontalOffset, + left: targetRect.left - sideOffset, name: 'arrow_nw', ...( config && { config } ) } ), southWestArrowNorthMiddleWest: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left - ( balloonRect.width * .25 ) - horizontalOffset, + left: targetRect.left - ( balloonRect.width * .25 ) - sideOffset, name: 'arrow_nmw', ...( config && { config } ) } ), @@ -925,14 +961,14 @@ export function generatePositions( { southWestArrowNorthMiddleEast: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left - ( balloonRect.width * .75 ) + horizontalOffset, + left: targetRect.left - ( balloonRect.width * .75 ) + sideOffset, name: 'arrow_nme', ...( config && { config } ) } ), southWestArrowNorthEast: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left - balloonRect.width + horizontalOffset, + left: targetRect.left - balloonRect.width + sideOffset, name: 'arrow_ne', ...( config && { config } ) } ), @@ -941,14 +977,14 @@ export function generatePositions( { southArrowNorthWest: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - horizontalOffset, + left: targetRect.left + targetRect.width / 2 - sideOffset, name: 'arrow_nw', ...( config && { config } ) } ), southArrowNorthMiddleWest: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * 0.25 ) - horizontalOffset, + left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * 0.25 ) - sideOffset, name: 'arrow_nmw', ...( config && { config } ) } ), @@ -962,14 +998,14 @@ export function generatePositions( { southArrowNorthMiddleEast: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * 0.75 ) + horizontalOffset, + left: targetRect.left + targetRect.width / 2 - ( balloonRect.width * 0.75 ) + sideOffset, name: 'arrow_nme', ...( config && { config } ) } ), southArrowNorthEast: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.left + targetRect.width / 2 - balloonRect.width + horizontalOffset, + left: targetRect.left + targetRect.width / 2 - balloonRect.width + sideOffset, name: 'arrow_ne', ...( config && { config } ) } ), @@ -978,14 +1014,14 @@ export function generatePositions( { southEastArrowNorthWest: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.right - horizontalOffset, + left: targetRect.right - sideOffset, name: 'arrow_nw', ...( config && { config } ) } ), southEastArrowNorthMiddleWest: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.right - ( balloonRect.width * .25 ) - horizontalOffset, + left: targetRect.right - ( balloonRect.width * .25 ) - sideOffset, name: 'arrow_nmw', ...( config && { config } ) } ), @@ -999,18 +1035,36 @@ export function generatePositions( { southEastArrowNorthMiddleEast: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.right - ( balloonRect.width * .75 ) + horizontalOffset, + left: targetRect.right - ( balloonRect.width * .75 ) + sideOffset, name: 'arrow_nme', ...( config && { config } ) } ), southEastArrowNorthEast: ( targetRect, balloonRect ) => ( { top: getSouthTop( targetRect, balloonRect ), - left: targetRect.right - balloonRect.width + horizontalOffset, + left: targetRect.right - balloonRect.width + sideOffset, name: 'arrow_ne', ...( config && { config } ) } ), + // ------- West + + westArrowEast: ( targetRect, balloonRect ) => ( { + top: targetRect.top + targetRect.height / 2 - balloonRect.height / 2, + left: targetRect.left - balloonRect.width - heightOffset, + name: 'arrow_e', + ...( config && { config } ) + } ), + + // ------- East + + eastArrowWest: ( targetRect, balloonRect ) => ( { + top: targetRect.top + targetRect.height / 2 - balloonRect.height / 2, + left: targetRect.right + heightOffset, + name: 'arrow_w', + ...( config && { config } ) + } ), + // ------- Sticky viewportStickyNorth: ( targetRect, balloonRect, viewportRect ) => { @@ -1037,7 +1091,7 @@ export function generatePositions( { // @param {utils/dom/rect~Rect} elementRect A rect of the balloon. // @returns {Number} function getNorthTop( targetRect, balloonRect ) { - return targetRect.top - balloonRect.height - verticalOffset; + return targetRect.top - balloonRect.height - heightOffset; } // Returns the top coordinate for positions starting with `south*`. @@ -1047,6 +1101,6 @@ export function generatePositions( { // @param {utils/dom/rect~Rect} elementRect A rect of the balloon. // @returns {Number} function getSouthTop( targetRect ) { - return targetRect.bottom + verticalOffset; + return targetRect.bottom + heightOffset; } } diff --git a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js index af10fd63532..49c410c0d41 100644 --- a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js +++ b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js @@ -373,8 +373,8 @@ export default class BalloonToolbar extends Plugin { const positions = isSafariIniOS ? generatePositions( { // 20px when zoomed out. Less then 20px when zoomed in; the "radius" of the native selection handle gets // smaller as the user zooms in. No less than the default v-offset, though. - verticalOffset: Math.max( - BalloonPanelView.arrowVerticalOffset, + heightOffset: Math.max( + BalloonPanelView.arrowHeightOffset, Math.round( 20 / global.window.visualViewport.scale ) ) } ) : BalloonPanelView.defaultPositions; diff --git a/packages/ckeditor5-ui/src/tooltip/tooltipview.js b/packages/ckeditor5-ui/src/tooltip/tooltipview.js deleted file mode 100644 index f4e5623b3f0..00000000000 --- a/packages/ckeditor5-ui/src/tooltip/tooltipview.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module ui/tooltip/tooltipview - */ - -import View from '../view'; - -import '../../theme/components/tooltip/tooltip.css'; - -/** - * The tooltip view class. - * - * @extends module:ui/view~View - */ -export default class TooltipView extends View { - /** - * @inheritDoc - */ - constructor( locale ) { - super( locale ); - - /** - * The text of the tooltip visible to the user. - * - * @observable - * @member {String} #text - */ - this.set( 'text', '' ); - - /** - * The position of the tooltip (south, south-west, south-east, or north). - * - * +-----------+ - * | north | - * +-----------+ - * V - * [element] - * - * [element] - * ^ - * +-----------+ - * | south | - * +-----------+ - * - * +----------+ - * [element] < | east | - * +----------+ - * - * +----------+ - * | west | > [element] - * +----------+ - * - * [element] - * ^ - * +--------------+ - * | south west | - * +--------------+ - * - * [element] - * ^ - * +--------------+ - * | south east | - * +--------------+ - - * @observable - * @default 's' - * @member {'s'|'n'|'e'|'w'|'sw'|'se'} #position - */ - this.set( 'position', 's' ); - - const bind = this.bindTemplate; - - this.setTemplate( { - tag: 'span', - attributes: { - class: [ - 'ck', - 'ck-tooltip', - bind.to( 'position', position => 'ck-tooltip_' + position ), - bind.if( 'text', 'ck-hidden', value => !value.trim() ) - ] - }, - children: [ - { - tag: 'span', - - attributes: { - class: [ - 'ck', - 'ck-tooltip__text' - ] - }, - - children: [ - { - text: bind.to( 'text' ) - } - ] - } - ] - } ); - } -} diff --git a/packages/ckeditor5-ui/src/tooltipmanager.js b/packages/ckeditor5-ui/src/tooltipmanager.js new file mode 100644 index 00000000000..faf595bffbb --- /dev/null +++ b/packages/ckeditor5-ui/src/tooltipmanager.js @@ -0,0 +1,430 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/tooltipmanager + */ + +import View from './view'; +import BalloonPanelView, { generatePositions } from './panel/balloon/balloonpanelview'; + +import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; +import { global, isVisible, mix, first } from '@ckeditor/ckeditor5-utils'; +import { isElement, debounce } from 'lodash-es'; + +import '../theme/components/tooltip/tooltip.css'; + +const BALLOON_CLASS = 'ck-tooltip'; + +/** + * A tooltip manager class for the UI of the editor. + * + * **Note**: Most likely you do not have to use the `TooltipManager` API listed below in order to display tooltips. Popular + * {@glink framework/guides/architecture/ui-library UI components} support tooltips out-of-the-box via observable properties + * (see {@link module:ui/button/buttonview~ButtonView#tooltip} and {@link module:ui/button/buttonview~ButtonView#tooltipPosition}). + * + * # Displaying tooltips + * + * To display a tooltip, set `data-cke-tooltip-text` attribute on any DOM element: + * + * domElement.dataset.ckeTooltipText = 'My tooltip'; + * + * The tooltip will show up whenever the user moves the mouse over the element or the element gets focus in DOM. + * + * # Positioning tooltips + * + * To change the position of the tooltip, use the `data-cke-tooltip-position` attribute (`s`, `se`, `sw`, `n`, `e`, or `w`): + * + * domElement.dataset.ckeTooltipText = 'Tooltip to the north'; + * domElement.dataset.ckeTooltipPosition = 'n'; + * + * # Disabling tooltips + * + * In order to disable the tooltip temporarily, use the `data-cke-tooltip-disabled` attribute: + * + * domElement.dataset.ckeTooltipText = 'Disabled. For now.'; + * domElement.dataset.ckeTooltipDisabled = 'true'; + * + * + * # Styling tooltips + * + * By default, the tooltip has `.ck-tooltip` class and its text inner `.ck-tooltip__text`. + * + * If your tooltip requires custom styling, using `data-cke-tooltip-class` attribute will add additional class to the balloon + * displaying the tooltip: + * + * domElement.dataset.ckeTooltipText = 'Tooltip with a red text'; + * domElement.dataset.ckeTooltipClass = 'my-class'; + * + * .ck.ck-tooltip.my-class { color: red } + * + * **Note**: This class is a singleton. All editor instances re-use the same instance loaded by + * {@link module:core/editor/editorui~EditorUI} of the first editor. + * + * @mixes module:utils/domemittermixin~DomEmitterMixin + */ +export default class TooltipManager { + /** + * Creates an instance of the tooltip manager. + * + * @param {module:core/editor/editor~Editor} editor + */ + constructor( editor ) { + TooltipManager._editors.add( editor ); + + // TooltipManager must be a singleton. Multiple instances would mean multiple tooltips attached + // to the same DOM element with data-cke-tooltip-* attributes. + if ( TooltipManager._instance ) { + return TooltipManager._instance; + } + + TooltipManager._instance = this; + + /** + * The view rendering text of the tooltip. + * + * @readonly + * @member {module:ui/view~View} #tooltipTextView + */ + this.tooltipTextView = new View( editor.locale ); + this.tooltipTextView.set( 'text', '' ); + this.tooltipTextView.setTemplate( { + tag: 'span', + attributes: { + class: [ + 'ck', + 'ck-tooltip__text' + ] + }, + children: [ + { + text: this.tooltipTextView.bindTemplate.to( 'text' ) + } + ] + } ); + + /** + * The instance of the balloon panel that renders and positions the tooltip. + * + * @readonly + * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #balloonPanelView + */ + this.balloonPanelView = new BalloonPanelView( editor.locale ); + this.balloonPanelView.class = BALLOON_CLASS; + this.balloonPanelView.content.add( this.tooltipTextView ); + + /** + * Stores the reference to the DOM element the tooltip is attached to. `null` when there's no tooltip + * in the UI. + * + * @private + * @readonly + * @member {HTMLElement|null} #_currentElementWithTooltip + */ + this._currentElementWithTooltip = null; + + /** + * Stores the current tooltip position. `null` when there's no tooltip in the UI. + * + * @private + * @readonly + * @member {String|null} #_currentTooltipPosition + */ + this._currentTooltipPosition = null; + + /** + * A debounced version of {@link #_pinTooltip}. Tooltips show with a delay to avoid flashing and + * to improve the UX. + * + * @private + * @readonly + * @member {Function} #_pinTooltipDebounced + */ + this._pinTooltipDebounced = debounce( this._pinTooltip, 600 ); + + this.listenTo( global.document, 'mouseenter', this._onEnterOrFocus.bind( this ), { useCapture: true } ); + this.listenTo( global.document, 'mouseleave', this._onLeaveOrBlur.bind( this ), { useCapture: true } ); + + this.listenTo( global.document, 'focus', this._onEnterOrFocus.bind( this ), { useCapture: true } ); + this.listenTo( global.document, 'blur', this._onLeaveOrBlur.bind( this ), { useCapture: true } ); + + this.listenTo( global.document, 'scroll', this._onScroll.bind( this ), { useCapture: true } ); + + // Because this class is a singleton, its only instance is shared across all editors and connects them through the reference. + // This causes issues with the ContextWatchdog. When an error is thrown in one editor, the watchdog traverses the references + // and (because of shared tooltip manager) figures that the error affects all editors and restarts them all. + // This flag, excludes tooltip manager instance from the traversal and brings ContextWatchdog back to normal. + // More in https://github.com/ckeditor/ckeditor5/issues/12292. + this._watchdogExcluded = true; + } + + /** + * Destroys the tooltip manager. + * + * **Note**: The manager singleton cannot be destroyed until all editors that use it are destroyed. + * + * @param {module:core/editor/editor~Editor} editor The editor the manager was created for. + */ + destroy( editor ) { + TooltipManager._editors.delete( editor ); + this.stopListening( editor.ui ); + + if ( !TooltipManager._editors.size ) { + this._unpinTooltip(); + this.balloonPanelView.destroy(); + this.stopListening(); + + TooltipManager._instance = null; + } + } + + /** + * Handles displaying tooltips on `mouseenter` and `focus` in DOM. + * + * @private + * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event. + * @param {Event} domEvent The DOM event. + */ + _onEnterOrFocus( evt, { target } ) { + const elementWithTooltipAttribute = getDescendantWithTooltip( target ); + + // Abort when there's no descendant needing tooltip. + if ( !elementWithTooltipAttribute ) { + return; + } + + // Abort to avoid flashing when, for instance: + // * a tooltip is displayed for a focused element, then the same element gets mouseentered, + // * a tooltip is displayed for an element via mouseenter, then the focus moves to the same element. + if ( elementWithTooltipAttribute === this._currentElementWithTooltip ) { + return; + } + + this._unpinTooltip(); + + this._pinTooltipDebounced( elementWithTooltipAttribute, getTooltipData( elementWithTooltipAttribute ) ); + } + + /** + * Handles hiding tooltips on `mouseleave` and `blur` in DOM. + * + * @private + * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event. + * @param {Event} domEvent The DOM event. + */ + _onLeaveOrBlur( evt, { target, relatedTarget } ) { + if ( evt.name === 'mouseleave' ) { + // Don't act when the event does not concern a DOM element (e.g. a mouseleave out of an entire document), + if ( !isElement( target ) ) { + return; + } + + // If a tooltip is currently visible, don't act for a targets other than the one it is attached to. + // For instance, a random mouseleave far away in the page should not unpin the tooltip that was pinned because + // of a previous focus. Only leaving the same element should hide the tooltip. + if ( this._currentElementWithTooltip && target !== this._currentElementWithTooltip ) { + return; + } + + const descendantWithTooltip = getDescendantWithTooltip( target ); + const relatedDescendantWithTooltip = getDescendantWithTooltip( relatedTarget ); + + // Unpin when the mouse was leaving element with a tooltip to a place which does not have or has a different tooltip. + // Note that this should happen whether the tooltip is already visible or not, for instance, it could be invisible but queued + // (debounced): it should get canceled. + if ( descendantWithTooltip && descendantWithTooltip !== relatedDescendantWithTooltip ) { + this._unpinTooltip(); + } + } + else { + // If a tooltip is currently visible, don't act for a targets other than the one it is attached to. + // For instance, a random blur in the web page should not unpin the tooltip that was pinned because of a previous mouseenter. + if ( this._currentElementWithTooltip && target !== this._currentElementWithTooltip ) { + return; + } + + // Note that unpinning should happen whether the tooltip is already visible or not, for instance, it could be invisible but + // queued (debounced): it should get canceled (e.g. quick focus then quick blur using the keyboard). + this._unpinTooltip(); + } + } + + /** + * Handles hiding tooltips on `scroll` in DOM. + * + * @private + * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event. + * @param {Event} domEvent The DOM event. + */ + _onScroll( evt, { target } ) { + // No tooltip, no reason to react on scroll. + if ( !this._currentElementWithTooltip ) { + return; + } + + // When scrolling a container that has both the balloon and the current element (common ancestor), the balloon can remain + // visible (e.g. scrolling ≤body>). Otherwise, to avoid glitches (clipping, lagging) better just hide the tooltip. + // Also, don't do anything when scrolling an unrelated DOM element that has nothing to do with the current element and the balloon. + if ( target.contains( this.balloonPanelView.element ) && target.contains( this._currentElementWithTooltip ) ) { + return; + } + + this._unpinTooltip(); + } + + /** + * Pins the tooltip to a specific DOM element. + * + * @private + * @param {Element} targetDomElement + * @param {Object} options + * @param {String} options.text Text of the tooltip to display. + * @param {String} options.position The position of the tooltip. + * @param {String} options.cssClass Additional CSS class of the balloon with the tooltip. + */ + _pinTooltip( targetDomElement, { text, position, cssClass } ) { + // Use the body collection of the first editor. + const bodyViewCollection = first( TooltipManager._editors.values() ).ui.view.body; + + if ( !bodyViewCollection.has( this.balloonPanelView ) ) { + bodyViewCollection.add( this.balloonPanelView ); + } + + this.tooltipTextView.text = text; + + this.balloonPanelView.pin( { + target: targetDomElement, + positions: TooltipManager.getPositioningFunctions( position ) + } ); + + this.balloonPanelView.class = [ BALLOON_CLASS, cssClass ] + .filter( className => className ) + .join( ' ' ); + + // Start responding to changes in editor UI or content layout. For instance, when collaborators change content + // and a contextual toolbar attached to a content starts to move (and so should move the tooltip). + // Note: Using low priority to let other listeners that position contextual toolbars etc. to react first. + for ( const editor of TooltipManager._editors ) { + this.listenTo( editor.ui, 'update', this._updateTooltipPosition.bind( this ), { priority: 'low' } ); + } + + this._currentElementWithTooltip = targetDomElement; + this._currentTooltipPosition = position; + } + + /** + * Unpins the tooltip and cancels all queued pinning. + * + * @private + */ + _unpinTooltip() { + this._pinTooltipDebounced.cancel(); + + this.balloonPanelView.unpin(); + + for ( const editor of TooltipManager._editors ) { + this.stopListening( editor.ui, 'update' ); + } + + this._currentElementWithTooltip = null; + this._currentTooltipPosition = null; + } + + /** + * Updates the position of the tooltip so it stays in sync with the element it is pinned to. + * + * Hides the tooltip when the element is no longer visible in DOM. + * + * @private + */ + _updateTooltipPosition() { + // This could happen if the tooltip was attached somewhere in a contextual content toolbar and the toolbar + // disappeared (e.g. removed an image). + if ( !isVisible( this._currentElementWithTooltip ) ) { + this._unpinTooltip(); + + return; + } + + this.balloonPanelView.pin( { + target: this._currentElementWithTooltip, + positions: TooltipManager.getPositioningFunctions( this._currentTooltipPosition ) + } ); + } + + /** + * Returns {@link #balloonPanelView} {@link module:utils/dom/position~PositioningFunction positioning functions} for a given position + * name. + * + * @static + * @param {String} position Name of the position (`s`, `se`, `sw`, `n`, `e`, or `w`). + * @returns {Array.} Positioning functions to be used by the {@link #balloonPanelView}. + */ + static getPositioningFunctions( position ) { + const defaultPositions = TooltipManager.defaultBalloonPositions; + + return { + // South is most popular. We can use positioning heuristics to avoid clipping by the viewport with the sane fallback. + s: [ + defaultPositions.southArrowNorth, + defaultPositions.southArrowNorthEast, + defaultPositions.southArrowNorthWest + ], + n: [ defaultPositions.northArrowSouth ], + e: [ defaultPositions.eastArrowWest ], + w: [ defaultPositions.westArrowEast ], + sw: [ defaultPositions.southArrowNorthEast ], + se: [ defaultPositions.southArrowNorthWest ] + }[ position ]; + } +} + +mix( TooltipManager, DomEmitterMixin ); + +/** + * A set of default {@link module:utils/dom/position~PositioningFunction positioning functions} used by the `TooltipManager` + * to pin tooltips in different positions. + * + * @member {Object.} + * module:ui/tooltipmanager~TooltipManager.defaultBalloonPositions + */ +TooltipManager.defaultBalloonPositions = generatePositions( { + heightOffset: 5, + sideOffset: 13 +} ); + +/** + * A reference to the `TooltipManager` instance. The class is a singleton and as such, + * successive attempts at creating instances should return this instance. + * + * @private + * @member {module:ui/tooltipmanager~TooltipManager} module:ui/tooltipmanager~TooltipManager._instance + */ +TooltipManager._instance = null; + +/** + * An array of editors the single tooltip manager instance must listen to. + * This is mostly to handle `EditorUI#update` listeners from individual editors. + * + * @private + * @member {Set.} module:ui/tooltipmanager~TooltipManager._editors + */ +TooltipManager._editors = new Set(); + +function getDescendantWithTooltip( element ) { + if ( !isElement( element ) ) { + return null; + } + + return element.closest( '[data-cke-tooltip-text]:not([data-cke-tooltip-disabled])' ); +} + +function getTooltipData( element ) { + return { + text: element.dataset.ckeTooltipText, + position: element.dataset.ckeTooltipPosition || 's', + cssClass: element.dataset.ckeTooltipClass || '' + }; +} diff --git a/packages/ckeditor5-ui/tests/button/buttonview.js b/packages/ckeditor5-ui/tests/button/buttonview.js index 93c0fe0ea1a..a78796750cd 100644 --- a/packages/ckeditor5-ui/tests/button/buttonview.js +++ b/packages/ckeditor5-ui/tests/button/buttonview.js @@ -8,7 +8,6 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import ButtonView from '../../src/button/buttonview'; import IconView from '../../src/icon/iconview'; -import TooltipView from '../../src/tooltip/tooltipview'; import View from '../../src/view'; import ViewCollection from '../../src/viewcollection'; import env from '@ckeditor/ckeditor5-utils/src/env'; @@ -34,10 +33,6 @@ describe( 'ButtonView', () => { expect( view.children ).to.be.instanceOf( ViewCollection ); } ); - it( 'creates #tooltipView', () => { - expect( view.tooltipView ).to.be.instanceOf( TooltipView ); - } ); - it( 'creates #labelView', () => { expect( view.labelView ).to.be.instanceOf( View ); expect( view.labelView.element.classList.contains( 'ck' ) ).to.be.true; @@ -134,7 +129,8 @@ describe( 'ButtonView', () => { describe( 'tooltip', () => { it( 'is initially set', () => { - expect( view.children.getIndex( view.tooltipView ) ).to.equal( 0 ); + expect( view.element.dataset.ckeTooltipText ).to.be.undefined; + expect( view.element.dataset.ckeTooltipPosition ).to.equal( 's' ); } ); it( 'it reacts to #tooltipPosition attribute', () => { @@ -142,10 +138,10 @@ describe( 'ButtonView', () => { view.icon = ''; expect( view.tooltipPosition ).to.equal( 's' ); - expect( view.tooltipView.position ).to.equal( 's' ); + expect( view.element.dataset.ckeTooltipPosition ).to.equal( 's' ); view.tooltipPosition = 'n'; - expect( view.tooltipView.position ).to.equal( 'n' ); + expect( view.element.dataset.ckeTooltipPosition ).to.equal( 'n' ); } ); describe( 'defined as a Boolean', () => { @@ -154,7 +150,7 @@ describe( 'ButtonView', () => { view.label = 'bar'; view.keystroke = 'A'; - expect( view.tooltipView.text ).to.equal( 'bar (A)' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'bar (A)' ); } ); it( 'not render tooltip text when #tooltip value is false', () => { @@ -162,7 +158,7 @@ describe( 'ButtonView', () => { view.label = 'bar'; view.keystroke = 'A'; - expect( view.tooltipView.text ).to.equal( '' ); + expect( view.element.dataset.ckeTooltipText ).to.be.undefined; } ); it( 'reacts to changes in #label and #keystroke', () => { @@ -170,12 +166,12 @@ describe( 'ButtonView', () => { view.label = 'foo'; view.keystroke = 'B'; - expect( view.tooltipView.text ).to.equal( 'foo (B)' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'foo (B)' ); view.label = 'baz'; view.keystroke = false; - expect( view.tooltipView.text ).to.equal( 'baz' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'baz' ); } ); } ); @@ -185,16 +181,16 @@ describe( 'ButtonView', () => { view.label = 'foo'; view.keystroke = 'A'; - expect( view.tooltipView.text ).to.equal( 'bar' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'bar' ); } ); it( 'reacts to changes of #tooltip', () => { view.tooltip = 'bar'; - expect( view.tooltipView.text ).to.equal( 'bar' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'bar' ); view.tooltip = 'foo'; - expect( view.tooltipView.text ).to.equal( 'foo' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'foo' ); } ); } ); @@ -204,7 +200,7 @@ describe( 'ButtonView', () => { view.label = 'foo'; view.keystroke = 'A'; - expect( view.tooltipView.text ).to.equal( 'foo - A' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'foo - A' ); } ); it( 'reacts to changes of #label and #keystroke', () => { @@ -212,12 +208,12 @@ describe( 'ButtonView', () => { view.label = 'foo'; view.keystroke = 'A'; - expect( view.tooltipView.text ).to.equal( 'foo - A' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'foo - A' ); view.label = 'bar'; view.keystroke = 'B'; - expect( view.tooltipView.text ).to.equal( 'bar - B' ); + expect( view.element.dataset.ckeTooltipText ).to.equal( 'bar - B' ); } ); } ); } ); @@ -344,7 +340,7 @@ describe( 'ButtonView', () => { view = new ButtonView( locale ); view.render(); - expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.element.childNodes ).to.have.length( 1 ); expect( view.iconView.element ).to.be.null; } ); @@ -353,7 +349,7 @@ describe( 'ButtonView', () => { view.icon = ''; view.render(); - expect( view.element.childNodes ).to.have.length( 3 ); + expect( view.element.childNodes ).to.have.length( 2 ); expect( view.element.childNodes[ 0 ] ).to.equal( view.iconView.element ); expect( view.iconView ).to.instanceOf( IconView ); @@ -381,7 +377,7 @@ describe( 'ButtonView', () => { view = new ButtonView( locale ); view.render(); - expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.element.childNodes ).to.have.length( 1 ); expect( view.keystrokeView.element ).to.be.null; } ); @@ -393,8 +389,8 @@ describe( 'ButtonView', () => { view.withKeystroke = true; view.render(); - expect( view.element.childNodes ).to.have.length( 3 ); - expect( view.element.childNodes[ 2 ] ).to.equal( view.keystrokeView.element ); + expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.element.childNodes[ 1 ] ).to.equal( view.keystrokeView.element ); expect( view.keystrokeView.element.classList.contains( 'ck' ) ).to.be.true; expect( view.keystrokeView.element.classList.contains( 'ck-button__keystroke' ) ).to.be.true; @@ -409,7 +405,7 @@ describe( 'ButtonView', () => { view.withKeystroke = true; view.render(); - expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.element.childNodes ).to.have.length( 1 ); expect( view.keystrokeView.element ).to.be.null; } ); diff --git a/packages/ckeditor5-ui/tests/button/switchbuttonview.js b/packages/ckeditor5-ui/tests/button/switchbuttonview.js index 205b7f680c0..9e1626d4243 100644 --- a/packages/ckeditor5-ui/tests/button/switchbuttonview.js +++ b/packages/ckeditor5-ui/tests/button/switchbuttonview.js @@ -32,7 +32,7 @@ describe( 'SwitchButtonView', () => { describe( 'render', () => { it( 'adds #toggleSwitchView to #children', () => { - expect( view.children.get( 2 ) ).to.equal( view.toggleSwitchView ); + expect( view.children.get( 1 ) ).to.equal( view.toggleSwitchView ); } ); } ); diff --git a/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js index 0b061960e94..69dda2781d9 100644 --- a/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals document */ +/* globals document, setTimeout */ import BalloonPanelView from '../../../../src/panel/balloon/balloonpanelview'; @@ -37,12 +37,15 @@ for ( const i in defaultPositions ) { balloon.element.textContent = i; document.body.appendChild( balloon.element ); - balloon.attachTo( { - target, - positions: [ - defaultPositions[ i ] - ] - } ); + // Without it the position could be wrong because the element has just been rendered in DOM. + setTimeout( () => { + balloon.pin( { + target, + positions: [ + defaultPositions[ i ] + ] + } ); + }, 100 ); } function parseHeadingText( text ) { diff --git a/packages/ckeditor5-ui/tests/manual/tooltip/sample.jpg b/packages/ckeditor5-ui/tests/manual/tooltip/sample.jpg new file mode 100644 index 00000000000..b77d07e7bff Binary files /dev/null and b/packages/ckeditor5-ui/tests/manual/tooltip/sample.jpg differ diff --git a/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.html b/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.html new file mode 100644 index 00000000000..783815168d1 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.html @@ -0,0 +1,118 @@ +

Tooltip positions (hover to test)

+
+
S
+
N
+
E
+
W
+
SW
+
SE
+
S: far left
+
S: far right
+
+ +

Tooltips in editor

+ +
+

Heading 1

+

Paragraph

+
+

Bold Italic Link

+
    +
  • UL List item 1
  • +
  • UL List item 2
  • +
+
    +
  1. OL List item 1
  2. +
  3. OL List item 2
  4. +
+
+ bar +
Caption
+
+
+

Quote

+
    +
  • Quoted UL List item 1
  • +
  • Quoted UL List item 2
  • +
+

Quote

+
+
+ +

Tooltips in editor with scrollable parent

+ +
+
+

Heading 1

+

Paragraph

+
+

Bold Italic Link

+
    +
  • UL List item 1
  • +
  • UL List item 2
  • +
+
    +
  1. OL List item 1
  2. +
  3. OL List item 2
  4. +
+
+ bar +
Caption
+
+
+

Quote

+
    +
  • Quoted UL List item 1
  • +
  • Quoted UL List item 2
  • +
+

Quote

+
+
+
+ + diff --git a/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.js b/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.js new file mode 100644 index 00000000000..9b34ee4e49b --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.js @@ -0,0 +1,69 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document, CKEditorInspector */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; + +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import ListProperties from '@ckeditor/ckeditor5-list/src/listproperties'; +import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor'; +import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace'; + +initEditor( '#editor' ); +initEditor( '#editor-scrollable-parent' ); + +function initEditor( elementId ) { + ClassicEditor + .create( document.querySelector( elementId ), { + plugins: [ ArticlePluginSet, ListProperties, FontColor, FindAndReplace ], + toolbar: [ + 'heading', + '|', + 'bold', + 'italic', + 'link', + '|', + 'fontColor', + '|', + 'bulletedList', + 'numberedList', + '|', + 'outdent', + 'indent', + '|', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + '|', + 'findAndReplace', + '|', + 'undo', + 'redo' + ], + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + } + } ) + .then( editor => { + if ( !window.editors ) { + window.editors = {}; + } + + window.editors[ elementId ] = editor; + + CKEditorInspector.attach( { [ elementId ]: editor } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); +} diff --git a/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.md b/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.md new file mode 100644 index 00000000000..53e0fa6e04d --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tooltip/tooltip.md @@ -0,0 +1,32 @@ +# Tooltip playground + +This manual test allows testing various tooltip scenarios in the UI (testing `TooltipManager`). + +## Tooltip positions + +1. Hover each box (N, S, E, etc.). +2. A tooltip should show up at a position corresponding to the name of the box. +3. Make sure tooltip arrows ("tips") are always centered with respect to the box. + +## Tooltips in the editor + +### With mouse + +1. Hover various editor buttons to display their tooltips. +2. Tooltips should show up after a delay. +3. There should always be a single tooltip at a time on a web page. +4. Make sure that moving mouse between buttons quickly does not result in orphaned tooltips attached to elements no longer being hovered. + +### With keyboard + +1. Use `Alt+F10` to focus the toolbar and navigate it using the keyboard. +2. Tooltips should show up when items are focused and hide when they get blurred. +3. Moving mouse to another toolbar item should hide a tooltip attached to the item focused using the keyboard (and vice-versa). + +## Tooltips when editor has a scrollable ancestor + +1. Make any tooltip show up using either mouse or keyboard. +2. Scroll the box. +3. The tooltip should hide and not return. + +Repeat the same scenario but scroll the entire webpage (not just the box containing the editor). The tooltip should remain visible in this case. diff --git a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js index 6544d3663de..2d3a208294a 100644 --- a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js @@ -350,7 +350,7 @@ describe( 'BalloonPanelView', () => { view.attachTo( { target, limiter } ); - expect( view.top ).to.equal( BalloonPanelView.arrowVerticalOffset ); + expect( view.top ).to.equal( BalloonPanelView.arrowHeightOffset ); expect( view.left ).to.equal( -100 ); positionedAncestor.remove(); @@ -375,7 +375,7 @@ describe( 'BalloonPanelView', () => { view.attachTo( { target, limiter } ); - expect( view.top ).to.equal( BalloonPanelView.arrowVerticalOffset + 100 ); + expect( view.top ).to.equal( BalloonPanelView.arrowHeightOffset + 100 ); expect( view.left ).to.equal( 0 ); positionedAncestor.remove(); @@ -726,8 +726,8 @@ describe( 'BalloonPanelView', () => { beforeEach( () => { positions = BalloonPanelView.defaultPositions; - arrowHOffset = BalloonPanelView.arrowHorizontalOffset; - arrowVOffset = BalloonPanelView.arrowVerticalOffset; + arrowHOffset = BalloonPanelView.arrowSideOffset; + arrowVOffset = BalloonPanelView.arrowHeightOffset; viewportRect = new Rect( { top: 0, @@ -758,7 +758,7 @@ describe( 'BalloonPanelView', () => { } ); it( 'should have a proper length', () => { - expect( Object.keys( positions ) ).to.have.length( 31 ); + expect( Object.keys( positions ) ).to.have.length( 33 ); } ); // ------- North @@ -1013,6 +1013,26 @@ describe( 'BalloonPanelView', () => { } ); } ); + // ------- West + + it( 'should define the "westArrowEast" position', () => { + expect( positions.westArrowEast( targetRect, balloonRect ) ).to.deep.equal( { + top: 125, + left: 50 - arrowVOffset, + name: 'arrow_e' + } ); + } ); + + // ------- East + + it( 'should define the "eastArrowWest" position', () => { + expect( positions.eastArrowWest( targetRect, balloonRect ) ).to.deep.equal( { + top: 125, + left: 200 + arrowVOffset, + name: 'arrow_w' + } ); + } ); + // ------- Sticky it( 'should define the "viewportStickyNorth" position and return null if not sticky', () => { @@ -1135,9 +1155,9 @@ describe( 'BalloonPanelView', () => { } } ); - it( 'should respect the "horizontalOffset" option', () => { + it( 'should respect the "sideOffset" option', () => { const generatedPositions = generatePositions( { - horizontalOffset: BalloonPanelView.arrowHorizontalOffset + 100 + sideOffset: BalloonPanelView.arrowSideOffset + 100 } ); for ( const name in generatedPositions ) { @@ -1155,20 +1175,30 @@ describe( 'BalloonPanelView', () => { } } ); - it( 'should respect the "verticalOffset" option', () => { + it( 'should respect the "heightOffset" option', () => { const generatedPositions = generatePositions( { - verticalOffset: BalloonPanelView.arrowVerticalOffset + 100 + heightOffset: BalloonPanelView.arrowHeightOffset + 100 } ); for ( const name in generatedPositions ) { const generatedResult = generatedPositions[ name ]( targetRect, balloonRect, viewportRect ); - if ( name.match( /^south/ ) ) { + if ( name.startsWith( 'south' ) ) { generatedResult.top -= 100; - } else if ( name.match( /^north/ ) ) { + } + + if ( name.startsWith( 'north' ) ) { generatedResult.top += 100; } + if ( name.startsWith( 'west' ) ) { + generatedResult.left += 100; + } + + if ( name.startsWith( 'east' ) ) { + generatedResult.left -= 100; + } + const defaultResult = defaultPositions[ name ]( targetRect, balloonRect, viewportRect ); expect( generatedResult ).to.deep.equal( defaultResult, name ); diff --git a/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js b/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js new file mode 100644 index 00000000000..c8e7d8daded --- /dev/null +++ b/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js @@ -0,0 +1,842 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document, MouseEvent, Event */ + +import View from '../../src/view'; +import BalloonPanelView from '../../src/panel/balloon/balloonpanelview'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import TooltipManager from '../../src/tooltipmanager'; + +describe( 'TooltipManager', () => { + let editor, element, tooltipManager; + + const utils = getUtils(); + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + // TooltipManager is a singleton shared across editor instances. If any other test didn't + // kill its editor, this will affect assertions in tests here. + TooltipManager._editors = new Set(); + + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, Bold, Italic ], + balloonToolbar: [ 'bold', 'italic' ] + } ); + + tooltipManager = editor.ui.tooltipManager; + } ); + + afterEach( async () => { + await editor.destroy(); + + element.remove(); + } ); + + describe( 'constructor()', () => { + describe( 'singleton', () => { + it( 'should be created once for all editor instances', async () => { + const secondEditor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, Bold, Italic ], + balloonToolbar: [ 'bold', 'italic' ] + } ); + + expect( editor.ui.tooltipManager ).to.equal( secondEditor.ui.tooltipManager ); + + await secondEditor.destroy(); + } ); + } ); + + it( 'should have #tooltipTextView', () => { + expect( tooltipManager.tooltipTextView ).to.be.instanceOf( View ); + expect( tooltipManager.tooltipTextView.text ).to.equal( '' ); + expect( Array.from( tooltipManager.tooltipTextView.element.classList ) ).to.have.members( [ 'ck', 'ck-tooltip__text' ] ); + } ); + + it( 'should have #balloonPanelView', () => { + expect( tooltipManager.balloonPanelView ).to.be.instanceOf( BalloonPanelView ); + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip' ); + expect( tooltipManager.balloonPanelView.content.first ).to.equal( tooltipManager.tooltipTextView ); + } ); + } ); + + describe( 'destroy()', () => { + describe( 'singleton', () => { + it( 'should no be destroyed until the last editor instance gets destroyed', async () => { + const secondEditor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, Bold, Italic ], + balloonToolbar: [ 'bold', 'italic' ] + } ); + + const stopListeningSpy = sinon.spy( editor.ui.tooltipManager, 'stopListening' ); + + await editor.destroy(); + + sinon.assert.calledOnce( stopListeningSpy ); + sinon.assert.calledWithExactly( stopListeningSpy.firstCall, editor.ui ); + + await secondEditor.destroy(); + + sinon.assert.calledWithExactly( stopListeningSpy.secondCall, secondEditor.ui ); + sinon.assert.calledWithExactly( stopListeningSpy.thirdCall ); + } ); + } ); + + it( 'should unpin the #balloonPanelView', () => { + const unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + + tooltipManager.destroy( editor ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + + it( 'should destroy #balloonPanelView', () => { + const destroySpy = sinon.spy( tooltipManager.balloonPanelView, 'destroy' ); + + tooltipManager.destroy( editor ); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should stop listening to events', () => { + const stopListeningSpy = sinon.spy( tooltipManager, 'stopListening' ); + + tooltipManager.destroy( editor ); + + sinon.assert.called( stopListeningSpy ); + } ); + + it( 'should cancel any queued pinning', () => { + const cancelSpy = sinon.spy( tooltipManager._pinTooltipDebounced, 'cancel' ); + + tooltipManager.destroy( editor ); + + sinon.assert.called( cancelSpy ); + } ); + } ); + + describe( 'displaying tooltips', () => { + let clock, elements, pinSpy, unpinSpy, defaultPositions; + + beforeEach( () => { + clock = sinon.useFakeTimers(); + defaultPositions = TooltipManager.defaultBalloonPositions; + + elements = getElementsWithTooltips( { + a: { + text: 'A' + }, + + b: { + text: 'B' + }, + + disabled: { + text: 'DISABLED', + isDisabled: true + }, + + customClass: { + text: 'CUSTOM_CLASS', + class: 'foo-bar' + }, + + unrelated: {}, + + positionS: { + text: 'POSITION_S', + position: 's' + }, + + positionN: { + text: 'POSITION_N', + position: 'n' + }, + + positionE: { + text: 'POSITION_E', + position: 'e' + }, + + positionW: { + text: 'POSITION_W', + position: 'w' + }, + + positionSW: { + text: 'POSITION_SW', + position: 'sw' + }, + + positionSE: { + text: 'POSITION_SE', + position: 'se' + } + } ); + + pinSpy = sinon.spy( tooltipManager.balloonPanelView, 'pin' ); + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + } ); + + afterEach( () => { + destroyElements( elements ); + clock.restore(); + } ); + + describe( 'on mouseenter', () => { + it( 'should not work for elements that have no descendant with the data-attribute', () => { + utils.dispatchMouseEnter( elements.unrelated ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.notCalled( pinSpy ); + } ); + + it( 'should not work if an element already has a tooltip', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + } ); + + it( 'should not work for elements with a data-cke-tooltip-disabled attribute', () => { + utils.dispatchMouseEnter( elements.disabled ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.notCalled( pinSpy ); + } ); + + describe( 'when all conditions are met', () => { + it( 'should unpin the tooltip first', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.callOrder( unpinSpy, pinSpy ); + } ); + + it( 'should pin a tooltip with a delay', () => { + utils.dispatchMouseEnter( elements.a ); + + sinon.assert.notCalled( pinSpy ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.a, + positions: sinon.match.array + } ); + } ); + + it( 'should pin just a single tooltip (singleton)', async () => { + const secondEditor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, Bold, Italic ], + balloonToolbar: [ 'bold', 'italic' ] + } ); + + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + expect( Array.from( document.querySelectorAll( '.ck-tooltip' ) ) ).to.have.length( 1 ); + + await secondEditor.destroy(); + } ); + + it( 'should add a custom class to the #balloonPanelView if specified in the data attribute', () => { + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip' ); + + utils.dispatchMouseEnter( elements.customClass ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip foo-bar' ); + + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledTwice( pinSpy ); + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip' ); + } ); + + it( 'should show up for the last element the mouse entered (last element has tooltip)', () => { + utils.dispatchMouseEnter( elements.a ); + utils.dispatchMouseEnter( elements.b ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.b, + positions: sinon.match.array + } ); + } ); + + it( 'should show up for the first element the mouse entered (last element has no tooltip)', () => { + utils.dispatchMouseEnter( elements.a ); + utils.dispatchMouseEnter( elements.unrelated ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.a, + positions: sinon.match.array + } ); + } ); + } ); + } ); + + describe( 'on focus', () => { + it( 'should not work for elements that have no descendant with the data-attribute', () => { + utils.dispatchFocus( elements.unrelated ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.notCalled( pinSpy ); + } ); + + it( 'should not work if an element already has a tooltip', () => { + utils.dispatchFocus( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + utils.dispatchFocus( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + } ); + + it( 'should not work for elements with a data-cke-tooltip-disabled', () => { + utils.dispatchFocus( elements.disabled ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.notCalled( pinSpy ); + } ); + + describe( 'when all conditions are met', () => { + it( 'should unpin the tooltip first', () => { + utils.dispatchFocus( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.callOrder( unpinSpy, pinSpy ); + } ); + + it( 'should pin a tooltip with a delay', () => { + utils.dispatchFocus( elements.a ); + + sinon.assert.notCalled( pinSpy ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.a, + positions: sinon.match.array + } ); + } ); + + it( 'should add a custom class to the #balloonPanelView if specified in the data attribute', () => { + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip' ); + + utils.dispatchFocus( elements.customClass ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip foo-bar' ); + + utils.dispatchFocus( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledTwice( pinSpy ); + expect( tooltipManager.balloonPanelView.class ).to.equal( 'ck-tooltip' ); + } ); + + it( 'should show up for the last element the mouse entered (last element has tooltip)', () => { + utils.dispatchFocus( elements.a ); + utils.dispatchFocus( elements.b ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.b, + positions: sinon.match.array + } ); + } ); + + it( 'should show up for the first element the mouse entered (last element has no tooltip)', () => { + utils.dispatchFocus( elements.a ); + utils.dispatchFocus( elements.unrelated ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.a, + positions: sinon.match.array + } ); + } ); + } ); + } ); + + it( 'should put the #balloonPanelView in the body collection once on demand', () => { + expect( tooltipManager.balloonPanelView.element ).to.be.null; + + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + expect( editor.ui.view.body.has( tooltipManager.balloonPanelView ) ).to.be.true; + + utils.dispatchMouseEnter( elements.b ); + utils.waitForTheTooltipToShow( clock ); + + expect( editor.ui.view.body.has( tooltipManager.balloonPanelView ) ).to.be.true; + } ); + + describe( 'translation of position name into BalloonPanelView positioning function', () => { + it( 'should be defined for "s" position', () => { + utils.dispatchMouseEnter( elements.positionS ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.positionS, + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.southArrowNorthEast, + defaultPositions.southArrowNorthWest + ] + } ); + } ); + + it( 'should be defined for "n" position', () => { + utils.dispatchMouseEnter( elements.positionN ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.positionN, + positions: [ + defaultPositions.northArrowSouth + ] + } ); + } ); + + it( 'should be defined for "e" position', () => { + utils.dispatchMouseEnter( elements.positionE ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.positionE, + positions: [ + defaultPositions.eastArrowWest + ] + } ); + } ); + + it( 'should be defined for "w" position', () => { + utils.dispatchMouseEnter( elements.positionW ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.positionW, + positions: [ + defaultPositions.westArrowEast + ] + } ); + } ); + + it( 'should be defined for "sw" position', () => { + utils.dispatchMouseEnter( elements.positionSW ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.positionSW, + positions: [ + defaultPositions.southArrowNorthEast + ] + } ); + } ); + + it( 'should be defined for "se" position', () => { + utils.dispatchMouseEnter( elements.positionSE ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.positionSE, + positions: [ + defaultPositions.southArrowNorthWest + ] + } ); + } ); + } ); + } ); + + describe( 'hiding tooltips', () => { + let clock, elements, pinSpy, unpinSpy; + + beforeEach( () => { + clock = sinon.useFakeTimers(); + + elements = getElementsWithTooltips( { + a: { + text: 'A' + }, + + b: { + text: 'B' + }, + + childOfA: { + text: 'CHILD_OF_A' + }, + + unrelated: {} + } ); + + pinSpy = sinon.spy( tooltipManager.balloonPanelView, 'pin' ); + + elements.a.appendChild( elements.childOfA ); + } ); + + afterEach( () => { + destroyElements( elements ); + clock.restore(); + } ); + + describe( 'on mouseleave', () => { + it( 'should not work for unrelated event targets such as DOM document', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchMouseLeave( document ); + + sinon.assert.notCalled( unpinSpy ); + } ); + + it( 'should not work if the tooltip is currently pinned and the event target is different than the current element', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchMouseLeave( elements.b ); + + sinon.assert.notCalled( unpinSpy ); + } ); + + it( 'should not work if the tooltip is not visible and leaving an element that has nothing to do with tooltips', () => { + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchMouseLeave( elements.unrelated ); + + sinon.assert.notCalled( unpinSpy ); + } ); + + it( 'should unpin the tooltip when moving from one element with a tooltip to another element with a tooltip quickly' + + 'before the tooltip shows for the first tooltip (cancellin the queued pinning)', () => { + utils.dispatchMouseEnter( elements.a ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchMouseLeave( elements.childOfA, elements.a ); + + sinon.assert.calledOnce( unpinSpy ); + + utils.waitForTheTooltipToShow( clock ); + sinon.assert.notCalled( pinSpy ); + } ); + + it( 'should immediatelly unpin the tooltip otherwise', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchMouseLeave( elements.a ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + } ); + + describe( 'on blur', () => { + it( 'should not work if a tooltip is pinned but blur ocurred in an unrelated place', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchBlur( elements.unrelated ); + + sinon.assert.notCalled( unpinSpy ); + } ); + + it( 'should unpin if the tooltip was pinned and the blur ocurred on the same element', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchBlur( elements.a ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + + it( 'should unpin if the tooltip was not pinned (cancels the queued pinning)', () => { + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchBlur( elements.unrelated ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + } ); + + describe( 'on scroll', () => { + it( 'should not unpin the tooltip if not pinned in the first place', () => { + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchScroll( elements.a ); + + sinon.assert.notCalled( unpinSpy ); + } ); + + it( 'should not unpin the tooltip if the scrolled element is a common ancestor of the #balloonPanelView ' + + 'and the element with tooltip', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchScroll( document ); + + sinon.assert.notCalled( unpinSpy ); + } ); + + it( 'should unpin if the scrolled element does not contain the #balloonPanelView', () => { + utils.dispatchMouseEnter( elements.childOfA ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchScroll( elements.a ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + + it( 'should unpin if the scrolled element does not contain the current element with a tooltip', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchScroll( elements.unrelated ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + } ); + } ); + + describe( 'updating tooltip position on EditorUI#update', () => { + let clock, elements, pinSpy, unpinSpy; + + beforeEach( () => { + clock = sinon.useFakeTimers(); + + elements = getElementsWithTooltips( { + a: { + text: 'A' + } + } ); + + pinSpy = sinon.spy( tooltipManager.balloonPanelView, 'pin' ); + } ); + + afterEach( () => { + destroyElements( elements ); + clock.restore(); + } ); + + it( 'should start when the tooltip gets pinned', () => { + utils.dispatchMouseEnter( elements.a ); + + editor.ui.update(); + sinon.assert.notCalled( pinSpy ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy.firstCall, { + target: elements.a, + positions: sinon.match.array + } ); + + editor.ui.update(); + sinon.assert.calledTwice( pinSpy ); + sinon.assert.calledWith( pinSpy.secondCall, { + target: elements.a, + positions: sinon.match.array + } ); + } ); + + it( 'should work for all editors (singleton)', async () => { + const secondEditor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, Bold, Italic ], + balloonToolbar: [ 'bold', 'italic' ] + } ); + + expect( editor.ui.tooltipManager ).to.equal( secondEditor.ui.tooltipManager ); + + utils.dispatchMouseEnter( elements.a ); + + editor.ui.update(); + sinon.assert.notCalled( pinSpy ); + + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + editor.ui.update(); + sinon.assert.calledTwice( pinSpy ); + + await secondEditor.destroy(); + } ); + + it( 'should stop when the tooltip gets unpinned', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + + editor.ui.update(); + sinon.assert.calledTwice( pinSpy ); + + utils.dispatchMouseLeave( elements.a ); + + editor.ui.update(); + sinon.assert.calledTwice( pinSpy ); + } ); + + it( 'should unpin the tooltip when the target element disappeared', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + + elements.a.style.display = 'none'; + + editor.ui.update(); + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledOnce( unpinSpy ); + } ); + + it( 'should unpin the tooltip when the target element was removed from DOM', () => { + utils.dispatchMouseEnter( elements.a ); + utils.waitForTheTooltipToShow( clock ); + + sinon.assert.calledOnce( pinSpy ); + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + + elements.a.remove(); + + editor.ui.update(); + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledOnce( unpinSpy ); + } ); + } ); + + describe( '#defaultPositions', () => { + it( 'should be defined', () => { + + } ); + } ); +} ); + +function getElementsWithTooltips( definitions ) { + const elements = {}; + + for ( const name in definitions ) { + const element = document.createElement( 'div' ); + const def = definitions[ name ]; + + if ( def.text ) { + element.dataset.ckeTooltipText = def.text; + } + + if ( def.position ) { + element.dataset.ckeTooltipPosition = def.position; + } + + if ( def.class ) { + element.dataset.ckeTooltipClass = def.class; + } + + if ( def.isDisabled ) { + element.setAttribute( 'data-cke-tooltip-disabled', 'true' ); + } + + element.id = name; + + document.body.appendChild( element ); + + elements[ name ] = element; + } + + return elements; +} + +function destroyElements( elements ) { + for ( const name in elements ) { + elements[ name ].remove(); + } +} + +function getUtils() { + return { + waitForTheTooltipToShow: clock => { + clock.tick( 650 ); + }, + + dispatchMouseEnter: element => { + element.dispatchEvent( new MouseEvent( 'mouseenter' ) ); + }, + + dispatchMouseLeave: ( element, relatedTarget ) => { + element.dispatchEvent( new MouseEvent( 'mouseleave', { relatedTarget } ) ); + }, + + dispatchFocus: element => { + element.dispatchEvent( new Event( 'focus' ) ); + }, + + dispatchBlur: element => { + element.dispatchEvent( new Event( 'blur' ) ); + }, + + dispatchScroll: element => { + element.dispatchEvent( new Event( 'scroll' ) ); + } + }; +} diff --git a/packages/ckeditor5-ui/tests/tooltip/tooltipview.js b/packages/ckeditor5-ui/tests/tooltip/tooltipview.js deleted file mode 100644 index 38d9256cc48..00000000000 --- a/packages/ckeditor5-ui/tests/tooltip/tooltipview.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -import TooltipView from '../../src/tooltip/tooltipview'; - -describe( 'TooltipView', () => { - let view, text; - - beforeEach( () => { - view = new TooltipView(); - view.render(); - text = view.element.firstChild; - } ); - - describe( 'constructor()', () => { - it( 'should create element from template', () => { - expect( view.element.tagName ).to.equal( 'SPAN' ); - expect( view.element.classList.contains( 'ck' ) ).to.be.true; - expect( view.element.classList.contains( 'ck-tooltip' ) ).to.be.true; - expect( view.element.childNodes ).to.have.length( 1 ); - - expect( text.tagName ).to.equal( 'SPAN' ); - expect( text.classList.contains( 'ck' ) ).to.be.true; - expect( text.classList.contains( 'ck-tooltip__text' ) ).to.be.true; - } ); - - it( 'should set default #position', () => { - expect( view.position ).to.equal( 's' ); - } ); - } ); - - describe( 'DOM bindings', () => { - beforeEach( () => { - view.text = 'foo'; - } ); - - describe( 'text content', () => { - it( 'should react on view#text', () => { - expect( text.textContent ).to.equal( 'foo' ); - - view.text = 'baz'; - - expect( text.textContent ).to.equal( 'baz' ); - } ); - } ); - - describe( 'class', () => { - it( 'should react on view#text', () => { - expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false; - - view.text = ''; - - expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true; - } ); - - it( 'should react on view#position', () => { - expect( view.element.classList.contains( 'ck-tooltip_n' ) ).to.be.false; - expect( view.element.classList.contains( 'ck-tooltip_s' ) ).to.be.true; - - view.position = 'n'; - - expect( view.element.classList.contains( 'ck-tooltip_n' ) ).to.be.true; - expect( view.element.classList.contains( 'ck-tooltip_s' ) ).to.be.false; - } ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-ui/theme/components/button/button.css b/packages/ckeditor5-ui/theme/components/button/button.css index 920fad0f63c..4a34f5541f6 100644 --- a/packages/ckeditor5-ui/theme/components/button/button.css +++ b/packages/ckeditor5-ui/theme/components/button/button.css @@ -4,12 +4,10 @@ */ @import "../../mixins/_unselectable.css"; -@import "../tooltip/mixins/_tooltip.css"; .ck.ck-button, a.ck.ck-button { @mixin ck-unselectable; - @mixin ck-tooltip_enabled; position: relative; display: inline-flex; @@ -30,13 +28,4 @@ a.ck.ck-button { &:not(.ck-button_with-text) { justify-content: center; } - - &:hover { - @mixin ck-tooltip_visible; - } - - /* Get rid of the native focus outline around the tooltip when focused (but not :hover). */ - &:focus:not(:hover) { - @mixin ck-tooltip_disabled; - } } diff --git a/packages/ckeditor5-ui/theme/components/dropdown/dropdown.css b/packages/ckeditor5-ui/theme/components/dropdown/dropdown.css index ee146a8ddc2..c7cc50b0c1f 100644 --- a/packages/ckeditor5-ui/theme/components/dropdown/dropdown.css +++ b/packages/ckeditor5-ui/theme/components/dropdown/dropdown.css @@ -3,8 +3,6 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -@import "../tooltip/mixins/_tooltip.css"; - :root { --ck-dropdown-max-width: 75vw; } @@ -21,19 +19,9 @@ /* Dropdown button should span horizontally, e.g. in vertical toolbars */ & .ck-button.ck-dropdown__button { width: 100%; - - /* Disable main button's tooltip when the dropdown is open. Otherwise the panel may - partially cover the tooltip */ - &.ck-on { - @mixin ck-tooltip_disabled; - } } & .ck-dropdown__panel { - /* This is to get rid of flickering when the tooltip is shown under the panel, - which looks like the panel moves vertically a pixel down and up. */ - -webkit-backface-visibility: hidden; - display: none; z-index: var(--ck-z-modal); max-width: var(--ck-dropdown-max-width); diff --git a/packages/ckeditor5-ui/theme/components/dropdown/splitbutton.css b/packages/ckeditor5-ui/theme/components/dropdown/splitbutton.css index 8eb016bf1af..75123e2dc3f 100644 --- a/packages/ckeditor5-ui/theme/components/dropdown/splitbutton.css +++ b/packages/ckeditor5-ui/theme/components/dropdown/splitbutton.css @@ -3,8 +3,6 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -@import "../tooltip/mixins/_tooltip.css"; - .ck.ck-splitbutton { /* Enable font size inheritance, which allows fluid UI scaling. */ font-size: inherit; @@ -12,10 +10,5 @@ & .ck-splitbutton__action:focus { z-index: calc(var(--ck-z-default) + 1); } - - /* Disable tooltips for the buttons when the button is "open" */ - &.ck-splitbutton_open > .ck-button { - @mixin ck-tooltip_disabled; - } } diff --git a/packages/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css b/packages/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css deleted file mode 100644 index 3811dd95f3c..00000000000 --- a/packages/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * Enables the tooltip, which is the tooltip is in DOM but - * not yet displayed. - */ -@define-mixin ck-tooltip_enabled { - & .ck-tooltip { - display: block; - - /* - * Don't display tooltips in devices which don't support :hover. - * In fact, it's all about iOS, which forces user to click UI elements twice to execute - * the primary action, when tooltips are enabled. - * - * Q: OK, but why not the following query? - * - * @media (hover) { - * display: block; - * } - * - * A: Because FF does not support it and it would completely disable tooltips - * in that browser. - * - * More in https://github.com/ckeditor/ckeditor5/issues/920. - */ - @media (hover:none) { - display: none; - } - } -} - -/** - * Disables the tooltip making it disappear from DOM. - */ -@define-mixin ck-tooltip_disabled { - & .ck-tooltip { - display: none; - } -} - -/** - * Shows the tooltip, which is already in DOM. - * Requires `ck-tooltip_enabled` first. - */ -@define-mixin ck-tooltip_visible { - & .ck-tooltip { - visibility: visible; - opacity: 1; - } -} diff --git a/packages/ckeditor5-ui/theme/components/tooltip/tooltip.css b/packages/ckeditor5-ui/theme/components/tooltip/tooltip.css index b3044eaee55..0b43693216f 100644 --- a/packages/ckeditor5-ui/theme/components/tooltip/tooltip.css +++ b/packages/ckeditor5-ui/theme/components/tooltip/tooltip.css @@ -3,32 +3,9 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -.ck.ck-tooltip, -.ck.ck-tooltip .ck-tooltip__text::after { - position: absolute; - - /* Without this, hovering the tooltip could keep it visible. */ +.ck.ck-balloon-panel.ck-tooltip { + /* Keep tooltips transparent for any interactions. */ pointer-events: none; - /* This is to get rid of flickering when transitioning opacity in Chrome. - It's weird but it works. */ - -webkit-backface-visibility: hidden; -} - -.ck.ck-tooltip { - /* Tooltip is hidden by default. */ - visibility: hidden; - opacity: 0; - display: none; - z-index: var(--ck-z-modal); - - & .ck-tooltip__text { - display: inline-block; - - &::after { - content: ""; - width: 0; - height: 0; - } - } + z-index: calc( var(--ck-z-modal) + 100 ); } diff --git a/packages/ckeditor5-watchdog/src/utils/getsubnodes.js b/packages/ckeditor5-watchdog/src/utils/getsubnodes.js index bca9c677488..3a1fcd4b9be 100644 --- a/packages/ckeditor5-watchdog/src/utils/getsubnodes.js +++ b/packages/ckeditor5-watchdog/src/utils/getsubnodes.js @@ -84,6 +84,12 @@ function shouldNodeBeSkipped( node ) { node === undefined || node === null || + // This flag is meant to exclude singletons shared across editor instances. So when an error is thrown in one editor, + // the other editors connected through the reference to the same singleton are not restarted. This is a temporary workaround + // until a better solution is found. + // More in https://github.com/ckeditor/ckeditor5/issues/12292. + node._watchdogExcluded === true || + // Skip native DOM objects, e.g. Window, nodes, events, etc. node instanceof EventTarget || node instanceof Event