/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Any modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. 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 React from 'react'; import { EuiContextMenu, EuiContextMenuPanelDescriptor, EuiPopover } from '@elastic/eui'; import { EventEmitter } from 'events'; import ReactDOM from 'react-dom'; let activeSession: ContextMenuSession | null = null; const CONTAINER_ID = 'contextMenu-container'; /** * Tries to find best position for opening context menu using mousemove and click event * Returned position is relative to document */ export function createInteractionPositionTracker() { let lastMouseX = 0; let lastMouseY = 0; const lastClicks: Array<{ el?: Element; mouseX: number; mouseY: number }> = []; const MAX_LAST_CLICKS = 10; /** * Track both `mouseup` and `click` * `mouseup` is for clicks and brushes with mouse * `click` is a fallback for keyboard interactions */ document.addEventListener('mouseup', onClick, true); document.addEventListener('click', onClick, true); document.addEventListener('mousemove', onMouseUpdate, { passive: true }); document.addEventListener('mouseenter', onMouseUpdate, { passive: true }); function onClick(event: MouseEvent) { lastClicks.push({ el: event.target as Element, mouseX: event.clientX, mouseY: event.clientY, }); if (lastClicks.length > MAX_LAST_CLICKS) { lastClicks.shift(); } } function onMouseUpdate(event: MouseEvent) { lastMouseX = event.clientX; lastMouseY = event.clientY; } return { resolveLastPosition: (): { x: number; y: number } => { const lastClick = [...lastClicks] .reverse() .find(({ el }) => el && document.body.contains(el)); if (!lastClick) { // fallback to last mouse position return { x: lastMouseX, y: lastMouseY, }; } const { top, left, bottom, right } = lastClick.el!.getBoundingClientRect(); const mouseX = lastClick.mouseX; const mouseY = lastClick.mouseY; if (top <= mouseY && bottom >= mouseY && left <= mouseX && right >= mouseX) { // click was inside target element return { x: mouseX, y: mouseY, }; } else { // keyboard edge case. no cursor position. use target element position instead return { x: left + (right - left) / 2, y: bottom, }; } }, }; } const { resolveLastPosition } = createInteractionPositionTracker(); function getOrCreateContainerElement() { let container = document.getElementById(CONTAINER_ID); let { x, y } = resolveLastPosition(); y = y + window.scrollY; x = x + window.scrollX; if (!container) { container = document.createElement('div'); container.style.left = x + 'px'; container.style.top = y + 'px'; container.style.position = 'absolute'; // EUI tooltip uses 9000 // have to make it larger to display menu on top of tooltips from charts container.style.zIndex = '9999'; container.id = CONTAINER_ID; document.body.appendChild(container); } else { container.style.left = x + 'px'; container.style.top = y + 'px'; } return container; } /** * A FlyoutSession describes the session of one opened flyout panel. It offers * methods to close the flyout panel again. If you open a flyout panel you should make * sure you call {@link ContextMenuSession#close} when it should be closed. * Since a flyout could also be closed without calling this method (e.g. because * the user closes it), you must listen to the "closed" event on this instance. * It will be emitted whenever the flyout will be closed and you should throw * away your reference to this instance whenever you receive that event. * @extends EventEmitter */ class ContextMenuSession extends EventEmitter { /** * Closes the opened flyout as long as it's still the open one. * If this is not the active session anymore, this method won't do anything. * If this session was still active and a flyout was closed, the 'closed' * event will be emitted on this FlyoutSession instance. */ public close(): void { if (activeSession === this) { const container = document.getElementById(CONTAINER_ID); if (container) { ReactDOM.unmountComponentAtNode(container); this.emit('closed'); } } } } /** * Opens a flyout panel with the given component inside. You can use * {@link ContextMenuSession#close} on the return value to close the flyout. * * @param flyoutChildren - Mounts the children inside a fly out panel * @return {FlyoutSession} The session instance for the opened flyout panel. */ export function openContextMenu( panels: EuiContextMenuPanelDescriptor[], props: { closeButtonAriaLabel?: string; onClose?: () => void; 'data-test-subj'?: string; } = {} ): ContextMenuSession { // If there is an active inspector session close it before opening a new one. if (activeSession) { activeSession.close(); } const container = getOrCreateContainerElement(); const session = (activeSession = new ContextMenuSession()); const onClose = () => { if (props.onClose) { props.onClose(); } session.close(); }; ReactDOM.render( , container ); return session; } export { ContextMenuSession };