UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

387 lines • 19.3 kB
"use strict"; // ***************************************************************************** // Copyright (C) 2022 Ericsson and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** var HoverService_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.HoverService = exports.HoverPosition = void 0; const tslib_1 = require("tslib"); const inversify_1 = require("inversify"); const common_1 = require("../common"); const browser_1 = require("./browser"); const markdown_link_handler_1 = require("./markdown-rendering/markdown-link-handler"); const markdown_renderer_1 = require("./markdown-rendering/markdown-renderer"); const opener_service_1 = require("./opener-service"); require("../../src/browser/style/hover-service.css"); // Threshold, in milliseconds, over which a mouse movement is not considered // quick enough as to be ignored const quickMouseThresholdMillis = 200; var HoverPosition; (function (HoverPosition) { function invertIfNecessary(position, target, host, totalWidth, totalHeight) { if (position === 'left') { if (target.left - host.width - 5 < 0) { return 'right'; } } else if (position === 'right') { if (target.right + host.width + 5 > totalWidth) { return 'left'; } } else if (position === 'top') { if (target.top - host.height - 5 < 0) { return 'bottom'; } } else if (position === 'bottom') { if (target.bottom + host.height + 5 > totalHeight) { return 'top'; } } return position; } HoverPosition.invertIfNecessary = invertIfNecessary; /** * Tests whether a hover of the given dimensions fits next to its target in the given * position without extending beyond the window bounds. */ function fits(position, target, host, totalWidth, totalHeight) { if (position === 'left') { return target.left - host.width - 5 >= 0; } else if (position === 'right') { return target.right + host.width + 5 <= totalWidth; } else if (position === 'top') { return target.top - host.height - 5 >= 0; } else { return target.bottom + host.height + 5 <= totalHeight; } } HoverPosition.fits = fits; })(HoverPosition || (exports.HoverPosition = HoverPosition = {})); let HoverService = class HoverService { constructor() { /** Identifies the latest render so that superseded renders can detect they are stale. */ this.renderSequence = 0; this.lastHidHover = Date.now(); this.disposeOnHide = new common_1.DisposableCollection(); } static { HoverService_1 = this; } static { this.hostClassName = 'theia-hover'; } static { this.styleSheetId = 'theia-hover-style'; } /** * The host of the current hover, which may live in a secondary window's document. * Resolving against the main document here instead would silently replace the host * whenever the current hover lives in another document, detaching the dismissal * listeners and `unRenderHover` from the host that is actually rendered. */ get hoverHost() { return this._hoverHost ?? this.getOrCreateHoverHost(document); } /** * Returns the host element to render hovers into for the given document, creating it if the * current one belongs to a different document. A host is always created in the document it is * shown in: adopting a host into another document would let it outlive its window, and touching * it after that window was closed breaks (and in Electron crashes) the application. */ getOrCreateHoverHost(targetDocument) { if (!this._hoverHost || this._hoverHost.ownerDocument !== targetDocument) { this._hoverHost = targetDocument.createElement('div'); this._hoverHost.classList.add(HoverService_1.hostClassName); this._hoverHost.style.position = 'absolute'; this._hoverHost.setAttribute('popover', 'hint'); } return this._hoverHost; } requestHover(request) { this.cancelHover(); const targetWindow = request.target.ownerDocument.defaultView; if (!targetWindow || targetWindow.closed) { // the window hosting the target is already gone, e.g. a closed secondary window: // its document must not be touched anymore return; } const delay = request.skipHoverDelay ? 0 : this.getHoverDelay(); this.pendingTimeout = (0, common_1.disposableTimeout)(() => this.renderHover(request), delay); this.hoverTarget = request.target; // resolve the host for the target's document up front so that the listeners below attach to the host that will be rendered this.getOrCreateHoverHost(request.target.ownerDocument); this.listenForWindowClose(request.target); this.listenForMouseOut(); this.listenForMouseClick(request); } /** * Cancels the hover when the window hosting the target element goes away, e.g. when the * secondary window containing the target is closed: neither the hover host nor any listeners * may outlive the document they belong to. */ listenForWindowClose(target) { const targetWindow = target.ownerDocument.defaultView; if (!targetWindow || targetWindow === window) { return; } const handlePageHide = () => this.cancelHover(); targetWindow.addEventListener('pagehide', handlePageHide); this.disposeOnHide.push({ dispose: () => targetWindow.removeEventListener('pagehide', handlePageHide) }); } getHoverDelay() { return Date.now() - this.lastHidHover < quickMouseThresholdMillis ? 0 : this.preferences.get('workbench.hover.delay', common_1.isOSX ? 1500 : 500); } async renderHover(request) { const { target, content, position, cssClasses, interactive, onHide } = request; const targetWindow = target.ownerDocument.defaultView; if (!targetWindow || targetWindow.closed) { // the window hosting the target is already gone, e.g. a closed secondary window: // its document must not be touched anymore return; } const host = this.getOrCreateHoverHost(target.ownerDocument); const sequence = ++this.renderSequence; let firstChild; if (onHide) { this.disposeOnHide.push({ dispose: onHide.bind(request) }); } if (cssClasses) { host.classList.add(...cssClasses); } if (content instanceof HTMLElement) { host.appendChild(content); firstChild = content; } else if (typeof content === 'string') { host.textContent = content; } else { const renderedContent = this.markdownRenderer.render(content); this.disposeOnHide.push(renderedContent); host.appendChild(renderedContent.element); firstChild = renderedContent.element; this.disposeOnHide.push((0, markdown_link_handler_1.wireMarkdownLinkHandler)(renderedContent.element, content, this.openerService)); } // browsers might insert linebreaks when the hover appears at the edge of the window // resetting the position prevents that host.style.left = '0px'; host.style.top = '0px'; // Keep the host hidden while it is parked at (0,0) for measurement. A visible popover there // would, for a target in the upper-left of the viewport (e.g. a view docked in the left panel), // flash over the target for one frame. That steals the pointer from the target, whose mouse-leave // handler then cancels the hover, and the cycle repeats: the tooltip flickers and never settles. // `visibility: hidden` still lays the host out (so it can be measured) but is not hit-tested. host.style.visibility = 'hidden'; // Render the hover in the document of the target: it may be hosted in a secondary window, // whose coordinate space is unrelated to the main window's. target.ownerDocument.body.append(host); if (!host.matches(':popover-open')) { host.showPopover(); } if (interactive) { // Add a click handler to the hover host to ensure clicks within the hover area work properly const clickHandler = (e) => { // Let click events within the hover area be processed by their handlers // but prevent them from triggering document handlers that might dismiss the tooltip e.stopImmediatePropagation(); }; host.addEventListener('click', clickHandler); this.disposeOnHide.push({ dispose: () => host.removeEventListener('click', clickHandler) }); } if (request.visualPreview) { // If just a string is being rendered use the size of the outer box const width = firstChild ? firstChild.offsetWidth : this.hoverHost.offsetWidth; const visualPreview = request.visualPreview(width); if (visualPreview) { host.appendChild(visualPreview); } } await this.hostAnimationFrame(target); // Allow the browser to size the host if (sequence !== this.renderSequence || !host.isConnected) { // this hover was cancelled or superseded by a newer one while waiting for the animation // frame: it must neither reposition nor reveal the host return; } const updatedPosition = this.setHostPosition(target, host, position); // Reveal the host only once it sits at its final position, so it never overlaps the target while // parked at (0,0). Dropping the declaration rather than assigning a value hands `visibility` back // to the stylesheet, which is where it comes from for every hover but the one being measured. host.style.removeProperty('visibility'); this.disposeOnHide.push({ dispose: () => { this.lastHidHover = Date.now(); host.classList.remove(updatedPosition); if (cssClasses) { host.classList.remove(...cssClasses); } } }); } /** * Waits for an animation frame in the window hosting the given target element, * which may be a secondary window whose rendering is independent of the main window's. */ hostAnimationFrame(target) { const targetWindow = target.ownerDocument.defaultView; if (!targetWindow || targetWindow === window) { return (0, browser_1.animationFrame)(); } return new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); } setHostPosition(target, host, position) { const hostDocument = target.ownerDocument; const targetDimensions = target.getBoundingClientRect(); const hostDimensions = host.getBoundingClientRect(); const documentWidth = hostDocument.body.getBoundingClientRect().width; // document.body.getBoundingClientRect().height doesn't work as expected // scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777 const documentHeight = hostDocument.documentElement.scrollHeight; position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight); if (!HoverPosition.fits(position, targetDimensions, hostDimensions, documentWidth, documentHeight)) { // The hover fits on neither side of the target in the requested direction, e.g. when the // target spans the full width of a narrow secondary window. Try the perpendicular direction // so that the target and the hover both remain visible. If that does not fit either, // keep the requested direction; the clamping below keeps the hover inside the viewport. const fallback = HoverPosition.invertIfNecessary(position === 'left' || position === 'right' ? 'bottom' : 'right', targetDimensions, hostDimensions, documentWidth, documentHeight); if (HoverPosition.fits(fallback, targetDimensions, hostDimensions, documentWidth, documentHeight)) { position = fallback; } } if (position === 'top' || position === 'bottom') { const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2); const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2); const furthestRight = Math.min(documentWidth - hostDimensions.width, middleAlignment); const left = Math.max(0, furthestRight); const top = position === 'top' ? targetDimensions.top - hostDimensions.height - 5 : targetDimensions.bottom + 5; host.style.setProperty('--theia-hover-before-position', `${targetMiddleWidth - left - 5}px`); host.style.top = `${top}px`; host.style.left = `${left}px`; } else { const targetMiddleHeight = targetDimensions.top + (targetDimensions.height / 2); const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2); const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment); const top = Math.max(0, furthestTop); const desiredLeft = position === 'left' ? targetDimensions.left - hostDimensions.width - 5 : targetDimensions.right + 5; // the hover may not fit on either side of the target, e.g. when the target // spans the full width of a narrow (secondary) window: keep it in the viewport const furthestRight = Math.min(documentWidth - hostDimensions.width, desiredLeft); const left = Math.max(0, furthestRight); host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`); host.style.left = `${left}px`; host.style.top = `${top}px`; } host.classList.add(position); return position; } listenForMouseOut() { const handleMouseLeave = (e) => { this.disposeOnHide.push((0, common_1.disposableTimeout)(() => { if (!this.hoverHost.matches(':hover') && !this.hoverTarget?.matches(':hover')) { this.cancelHover(); } }, quickMouseThresholdMillis)); }; this.hoverTarget?.addEventListener('mouseout', handleMouseLeave); this.hoverHost.addEventListener('mouseout', handleMouseLeave); this.disposeOnHide.push({ dispose: () => { this.hoverTarget?.removeEventListener('mouseout', handleMouseLeave); this.hoverHost.removeEventListener('mouseout', handleMouseLeave); } }); } cancelHover() { this.pendingTimeout?.dispose(); this.unRenderHover(); this.disposeOnHide.dispose(); this.hoverTarget = undefined; } /** * Listen for mouse click (mousedown) events and handle them based on hover interactivity. * For non-interactive hovers, any mousedown cancels the hover immediately. * For interactive hovers, the hover remains visible to allow interaction with its elements. */ listenForMouseClick(request) { const handleMouseDown = (e) => { if (this.hoverHost.contains(e.target)) { return; } if (!request.interactive) { this.cancelHover(); } }; // Listen in the document of the target, which may be hosted in a secondary window const targetDocument = request.target.ownerDocument; targetDocument.addEventListener('mousedown', handleMouseDown, true); this.disposeOnHide.push({ dispose: () => targetDocument.removeEventListener('mousedown', handleMouseDown, true) }); } unRenderHover() { const host = this._hoverHost; if (!host) { return; } const hostWindow = host.ownerDocument.defaultView; if (!hostWindow || hostWindow.closed) { // the window hosting the hover is already gone, e.g. a closed secondary window: // its DOM must not be touched anymore; abandon the host and start from scratch this._hoverHost = undefined; return; } try { if (host.matches(':popover-open')) { host.hidePopover(); } } catch { // hidePopover throws for popovers in documents that are no longer fully active, // e.g. while the secondary window hosting the hover is being closed } host.remove(); host.replaceChildren(); // drop the classes added for the rendered hover (position and request classes): a render // aborted because it was superseded must not leak its classes into the next hover host.className = HoverService_1.hostClassName; // The host is reused across hovers; drop the transient hidden state set during measurement so a // hover cancelled before it was revealed does not leave the next one invisible. host.style.removeProperty('visibility'); if (host.ownerDocument !== document) { // never keep a host from another document: it must not outlive its window this._hoverHost = undefined; } } }; exports.HoverService = HoverService; tslib_1.__decorate([ (0, inversify_1.inject)(common_1.PreferenceService), tslib_1.__metadata("design:type", Object) ], HoverService.prototype, "preferences", void 0); tslib_1.__decorate([ (0, inversify_1.inject)(markdown_renderer_1.CoreMarkdownRenderer), tslib_1.__metadata("design:type", Object) ], HoverService.prototype, "markdownRenderer", void 0); tslib_1.__decorate([ (0, inversify_1.inject)(opener_service_1.OpenerService), tslib_1.__metadata("design:type", Object) ], HoverService.prototype, "openerService", void 0); exports.HoverService = HoverService = HoverService_1 = tslib_1.__decorate([ (0, inversify_1.injectable)() ], HoverService); //# sourceMappingURL=hover-service.js.map