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', () => {
'
' +
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)
+
+
+Tooltips in editor
+
+
+
Heading 1
+
Paragraph
+
+
Bold Italic Link
+
+ - UL List item 1
+ - UL List item 2
+
+
+ - OL List item 1
+ - OL List item 2
+
+
+
+ Quote
+
+ - Quoted UL List item 1
+ - Quoted UL List item 2
+
+ Quote
+
+
+
+Tooltips in editor with scrollable parent
+
+
+
+
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