diff --git a/src/component/tooltip/TooltipHTMLContent.ts b/src/component/tooltip/TooltipHTMLContent.ts
index 5f742de56c..80450818a9 100644
--- a/src/component/tooltip/TooltipHTMLContent.ts
+++ b/src/component/tooltip/TooltipHTMLContent.ts
@@ -30,6 +30,7 @@ import type { ZRRawEvent } from 'zrender/src/core/types';
import type { ZRColor } from '../../util/types';
import type CanvasPainter from 'zrender/src/canvas/Painter';
import type SVGPainter from 'zrender/src/svg/Painter';
+import type HandlerDomProxy from 'zrender/src/dom/HandlerProxy';
import {
shouldTooltipConfine,
toCSSVendorPrefix,
@@ -297,6 +298,7 @@ class TooltipHTMLContent {
private _longHideTimeout: number;
private _enableDisplayTransition: boolean;
+ private _clearPendingMouseUp: () => void;
constructor(
api: ExtensionAPI,
@@ -333,6 +335,47 @@ class TooltipHTMLContent {
// the browser do not support `pointer-events: none`.
const self = this;
+ el.addEventListener(env.pointerEventsSupported ? 'pointerup' : 'mouseup', function (e) {
+ const handler = zr.handler;
+ const proxy = handler && handler.proxy as HandlerDomProxy;
+ // The tooltip belongs to zrender, so its document listener skips this event.
+ // Finish only a press started in the renderer, leaving tooltip controls alone.
+ if (!proxy || !proxy.dom || !isFunction(proxy.__togglePointerCapture)
+ || !(proxy.__pointerCapturing || proxy.__mayPointerCapture)
+ ) {
+ return;
+ }
+ const release = function () {
+ if (self.el && zr.handler === handler && handler.proxy === proxy
+ && (proxy.__pointerCapturing || proxy.__mayPointerCapture)
+ ) {
+ normalizeEvent(zr.painter.getViewportRoot(), e as ZRRawEvent, true);
+ proxy.__togglePointerCapture(false);
+ proxy.trigger('mouseup', e);
+ }
+ };
+ if (proxy.dom.contains(el)) {
+ // Let the renderer handle normal bubbling first. A child may stop propagation,
+ // so finish the release afterwards if the renderer did not receive it.
+ self._clearPendingMouseUp && self._clearPendingMouseUp();
+ const clear = self._clearPendingMouseUp = function () {
+ clearTimeout(timer);
+ proxy.off('mousedown', clear);
+ proxy.off('mouseup', clear);
+ self._clearPendingMouseUp = null;
+ };
+ const timer = setTimeout(function () {
+ clear();
+ release();
+ }, 0);
+ // Do not end a new gesture or retain a release after the native path handled it.
+ proxy.on('mousedown', clear);
+ proxy.on('mouseup', clear);
+ }
+ else {
+ release();
+ }
+ }, true);
el.onmouseenter = function () {
// clear the timeout in hideLater and keep showing tooltip
if (self._enterable) {
@@ -547,6 +590,7 @@ class TooltipHTMLContent {
}
dispose() {
+ this._clearPendingMouseUp && this._clearPendingMouseUp();
clearTimeout(this._hideTimeout);
clearTimeout(this._longHideTimeout);
diff --git a/test/tooltip-enterable-roam.html b/test/tooltip-enterable-roam.html
new file mode 100644
index 0000000000..08c4e2048c
--- /dev/null
+++ b/test/tooltip-enterable-roam.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
Hold the mouse on the graph background, move onto the tooltip, then release.
+ Moving back must not pan. Repeat, including release on a tooltip child or outside the chart.
+
+
+
+
+
+
+
+
+
Tooltip button clicks: 0
+
Tooltip link target
+
+
+
diff --git a/test/ut/spec/component/tooltip/enterable.test.ts b/test/ut/spec/component/tooltip/enterable.test.ts
new file mode 100644
index 0000000000..9e8d7a2d82
--- /dev/null
+++ b/test/ut/spec/component/tooltip/enterable.test.ts
@@ -0,0 +1,218 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+import { createChart } from '../../../core/utHelper';
+import env from 'zrender/src/core/env';
+import HandlerDomProxy from 'zrender/src/dom/HandlerProxy';
+import { EChartsType } from '../../../../../src/echarts';
+
+describe('enterable tooltip release', function () {
+ let chart: EChartsType;
+ let external: HTMLDivElement;
+ const originalPointer = env.pointerEventsSupported;
+ const originalTouch = env.touchEventsSupported;
+
+ afterEach(function () {
+ if (chart) {
+ const dom = chart.getDom();
+ chart.dispose();
+ dom.parentNode && dom.parentNode.removeChild(dom);
+ }
+ external && external.remove();
+ env.pointerEventsSupported = originalPointer;
+ env.touchEventsSupported = originalTouch;
+ });
+
+ function setup(renderer: 'canvas' | 'svg', appendTo: boolean, pointer: boolean, enterable = true) {
+ env.pointerEventsSupported = pointer;
+ env.touchEventsSupported = false;
+ external = document.createElement('div');
+ document.body.appendChild(external);
+ chart = createChart({opts: {renderer}});
+ document.body.appendChild(chart.getDom());
+ chart.setOption({
+ animation: false,
+ tooltip: {
+ enterable,
+ appendTo: appendTo ? external : undefined,
+ alwaysShowContent: true,
+ position: [200, 100],
+ formatter: ''
+ },
+ series: [{
+ type: 'graph', layout: 'none', roam: true,
+ data: [{name: 'a', x: 0, y: 0}, {name: 'b', x: 100, y: 100}]
+ }]
+ });
+ chart.dispatchAction({type: 'showTip', seriesIndex: 0, dataIndex: 0});
+ const proxy = chart.getZr().handler.proxy as HandlerDomProxy;
+ let tooltip = (appendTo ? external : chart.getDom()).querySelector('button').parentElement;
+ while (!(tooltip as any).domBelongToZr) {
+ tooltip = tooltip.parentElement;
+ }
+ const pan = jest.fn();
+ const up = jest.fn();
+ chart.on('graphroam', pan);
+ chart.getZr().on('mouseup', up);
+ function send(target: Element, type: string, x = 250, y = 200) {
+ const name = pointer ? type.replace('mouse', 'pointer') : type;
+ const e = new MouseEvent(name, {bubbles: true, cancelable: true, clientX: x, clientY: y});
+ Object.defineProperty(e, 'offsetX', {value: x});
+ Object.defineProperty(e, 'offsetY', {value: y});
+ target.dispatchEvent(e);
+ return e;
+ }
+ function start() {
+ send(proxy.dom, 'mousedown');
+ send(proxy.dom, 'mousemove', 260, 200);
+ expect(proxy.__pointerCapturing).toBe(true);
+ expect(pan).toHaveBeenCalled();
+ }
+ return {proxy, tooltip, pan, up, send, start};
+ }
+
+ (['canvas', 'svg'] as const).forEach(function (renderer) {
+ it(`${renderer}: does not duplicate a release that bubbles through the renderer`, async function () {
+ const {proxy, tooltip, up, send, start} = setup(renderer, false, false);
+ // appendTo may place the tooltip inside the renderer's event root.
+ proxy.dom.appendChild(tooltip);
+ start();
+ send(tooltip.querySelector('span'), 'mouseup');
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(up).toHaveBeenCalledTimes(1);
+ expect(proxy.__pointerCapturing).toBe(false);
+ });
+ [false, true].forEach(function (pointer) {
+ it(`${renderer}, pointer=${pointer}: nested child may stop release bubbling`, async function () {
+ const {proxy, tooltip, up, send, start, pan} = setup(renderer, false, pointer);
+ proxy.dom.appendChild(tooltip);
+ const child = tooltip.querySelector('span');
+ child.addEventListener(pointer ? 'pointerup' : 'mouseup', e => e.stopPropagation());
+ start();
+ send(child, 'mouseup');
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(proxy.__pointerCapturing).toBe(false);
+ expect(up).toHaveBeenCalledTimes(1);
+ const count = pan.mock.calls.length;
+ send(proxy.dom, 'mousemove', 280, 200);
+ expect(pan).toHaveBeenCalledTimes(count);
+ });
+ });
+ it(`${renderer}: pending release cannot end a new gesture`, async function () {
+ const {proxy, tooltip, up, send, start} = setup(renderer, false, false);
+ proxy.dom.appendChild(tooltip);
+ const child = tooltip.querySelector('span');
+ child.addEventListener('mouseup', e => e.stopPropagation());
+ start();
+ send(child, 'mouseup');
+ send(proxy.dom, 'mousedown', 260, 200);
+ send(proxy.dom, 'mousemove', 270, 200);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(proxy.__pointerCapturing).toBe(true);
+ expect(up).not.toHaveBeenCalled();
+ send(proxy.dom, 'mouseup');
+ expect(up).toHaveBeenCalledTimes(1);
+ });
+ it(`${renderer}: dispose cancels a pending release`, async function () {
+ const {proxy, tooltip, up, send, start} = setup(renderer, false, false);
+ proxy.dom.appendChild(tooltip);
+ const child = tooltip.querySelector('span');
+ child.addEventListener('mouseup', e => e.stopPropagation());
+ start();
+ send(child, 'mouseup');
+ const dom = chart.getDom();
+ chart.dispose();
+ dom.remove();
+ chart = null;
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(up).not.toHaveBeenCalled();
+ });
+ it(`${renderer}: a custom proxy without capture methods is left alone`, function () {
+ const {proxy, tooltip, send} = setup(renderer, false, false);
+ const custom = {dom: proxy.dom, __pointerCapturing: true, trigger: jest.fn()};
+ const handler = chart.getZr().handler;
+ const error = jest.fn((e: ErrorEvent) => e.preventDefault());
+ window.addEventListener('error', error);
+ try {
+ handler.proxy = custom as any;
+ send(tooltip.querySelector('span'), 'mouseup');
+ expect(error).not.toHaveBeenCalled();
+ expect(custom.trigger).not.toHaveBeenCalled();
+ expect(custom.__pointerCapturing).toBe(true);
+ }
+ finally {
+ handler.proxy = proxy;
+ window.removeEventListener('error', error);
+ }
+ });
+ [false, true].forEach(function (appendTo) {
+ [false, true].forEach(function (pointer) {
+ it(`${renderer}, external=${appendTo}, pointer=${pointer}: releases and restarts repeatedly`, function () {
+ const {proxy, tooltip, pan, up, send, start} = setup(renderer, appendTo, pointer);
+ // Capture on the tooltip also handles a child which stops bubbling.
+ const child = tooltip.querySelector('span');
+ child.addEventListener(pointer ? 'pointerup' : 'mouseup', e => e.stopPropagation());
+ for (let cycle = 0; cycle < 3; cycle++) {
+ pan.mockClear();
+ start();
+ const event = send(cycle % 2 ? tooltip : child, 'mouseup');
+ expect(event.defaultPrevented).toBe(false);
+ expect(proxy.__pointerCapturing).toBe(false);
+ expect(proxy.__mayPointerCapture).toBeNull();
+ expect(up).toHaveBeenCalledTimes(cycle + 1);
+ const count = pan.mock.calls.length;
+ send(proxy.dom, 'mousemove', 270, 200);
+ send(document.body, 'mousemove', 800, 600);
+ expect(pan).toHaveBeenCalledTimes(count);
+ }
+ // Compatibility mouseup after pointerup must not duplicate the release.
+ child.dispatchEvent(new MouseEvent('mouseup', {bubbles: true}));
+ expect(up).toHaveBeenCalledTimes(3);
+ // A tooltip-only interaction must not dispatch renderer events.
+ send(child, 'mousedown');
+ send(child, 'mouseup');
+ expect(up).toHaveBeenCalledTimes(3);
+ });
+ });
+ it(`${renderer}, external=${appendTo}: releases before capture starts`, function () {
+ const {proxy, tooltip, up, send, pan} = setup(renderer, appendTo, false);
+ send(proxy.dom, 'mousedown');
+ send(tooltip, 'mouseup');
+ expect(proxy.__mayPointerCapture).toBeNull();
+ expect(up).toHaveBeenCalledTimes(1);
+ send(proxy.dom, 'mousemove', 260, 200);
+ expect(pan).not.toHaveBeenCalled();
+ });
+ it(`${renderer}, external=${appendTo}: non-enterable and outside releases`, function () {
+ const {proxy, up, send, start, pan} = setup(renderer, appendTo, false, false);
+ start();
+ // In a browser pointer-events:none targets the renderer underneath.
+ send(proxy.dom, 'mouseup');
+ expect(up).toHaveBeenCalledTimes(1);
+ start();
+ send(document.body, 'mouseup', 800, 600);
+ expect(up).toHaveBeenCalledTimes(2);
+ expect(proxy.__pointerCapturing).toBe(false);
+ const count = pan.mock.calls.length;
+ send(proxy.dom, 'mousemove', 280, 200);
+ expect(pan).toHaveBeenCalledTimes(count);
+ });
+ });
+ });
+});