From 21d7946e3718336eb6833ade22c7abe2c18a77f8 Mon Sep 17 00:00:00 2001 From: Simon Graband Date: Thu, 22 Jun 2023 10:41:43 +0200 Subject: [PATCH] Add preference for visualPreview on hover Made window.tabbar.enhancedPreview an enum preference with these options: - classic: a simple unstyled preview, containing the name - enhanced: a styled preview containing title and caption (#12350) - visual: the enhanced preview + visual preview of the view Extended the hover service so that it supports the visual preview. Added the `PreviewableWidget` interface. Widgets, implementing this interface, can be previewed once they were loaded. The loaded flag is set to true when a widget is set to active. Widgets implementing the interface can specify how the preview should look like. The default is simply the node of the widget. Webviews are currently not previewable, as they raise some challenges. For example, scripts in the webviews would need to be handled/blocked. Therefore, an approach for webviews should be tackled in a follow up. Fixes #12646 Contributed on behalf of STMicroelectronics --- CHANGELOG.md | 4 ++ packages/core/src/browser/core-preferences.ts | 14 +++-- packages/core/src/browser/hover-service.ts | 18 ++++++ .../src/browser/shell/application-shell.ts | 4 ++ packages/core/src/browser/shell/tab-bars.ts | 57 ++++++++++++++++++- packages/core/src/browser/style/tabs.css | 10 ++++ .../src/browser/widgets/previewable-widget.ts | 30 ++++++++++ packages/core/src/browser/widgets/widget.ts | 7 ++- .../src/browser/views/preference-widget.tsx | 4 ++ 9 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/browser/widgets/previewable-widget.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 909ae1c4dbfc8..86c1a97be6758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - Show command shortcuts in toolbar item tooltips. [#12660](https://github.com/eclipse-theia/theia/pull/12660) - Contributed on behalf of STMicroelectronics - [cli] added `check:theia-extensions` which checks the uniqueness of Theia extension versions [#12596](https://github.com/eclipse-theia/theia/pull/12596) - Contributed on behalf of STMicroelectronics - [vscode] Add support for the TaskPresentationOptions close property [#12749](https://github.com/eclipse-theia/theia/pull/12749) - Contributed on behalf of STMicroelectronics +- [core] made the `window.tabbar.enhancedPreview` preference an enum with 3 options: [#12648](https://github.com/eclipse-theia/theia/pull/12648) - Contributed on behalf of STMicroelectronics + - `classic`: Display a simple preview about the view, containing the name. + - `enhanced`: Display an enhanced preview containing the name and a caption. (The behavior introduced in [#12350](https://github.com/eclipse-theia/theia/pull/12350)) + - `visual`: Display the enhanced preview together with a visual preview of the view. (The preview support was added with this PR) [Breaking Changes:](#breaking_changes_1.40.0) diff --git a/packages/core/src/browser/core-preferences.ts b/packages/core/src/browser/core-preferences.ts index b1073189db4ec..47b018fef3cda 100644 --- a/packages/core/src/browser/core-preferences.ts +++ b/packages/core/src/browser/core-preferences.ts @@ -81,9 +81,15 @@ export const corePreferenceSchema: PreferenceSchema = { markdownDescription: nls.localizeByDefault('Controls the dispatching logic for key presses to use either `code` (recommended) or `keyCode`.') }, 'window.tabbar.enhancedPreview': { - type: 'boolean', - default: false, - description: nls.localize('theia/core/enhancedPreview', 'Controls whether more information about the tab should be displayed in horizontal tab bars.') + type: 'string', + enum: ['classic', 'enhanced', 'visual'], + markdownEnumDescriptions: [ + nls.localize('theia/core/enhancedPreview/classic', 'Display a simple preview about the view, containing the name.'), + nls.localize('theia/core/enhancedPreview/enhanced', 'Display an enhanced preview containing the name and a caption.'), + nls.localize('theia/core/enhancedPreview/visual', 'Display the enhanced preview together with a visual preview of the view.'), + ], + default: 'classic', + description: nls.localize('theia/core/enhancedPreview', 'Controls what information about the tab should be displayed in horizontal tab bars, when hovering.') }, 'window.menuBarVisibility': { type: 'string', @@ -263,7 +269,7 @@ export interface CoreConfiguration { 'breadcrumbs.enabled': boolean; 'files.encoding': string; 'keyboard.dispatch': 'code' | 'keyCode'; - 'window.tabbar.enhancedPreview': boolean; + 'window.tabbar.enhancedPreview': 'classic' | 'enhanced' | 'visual'; 'window.menuBarVisibility': 'classic' | 'visible' | 'hidden' | 'compact'; 'window.title': string; 'window.titleSeparator': string; diff --git a/packages/core/src/browser/hover-service.ts b/packages/core/src/browser/hover-service.ts index 360a004dd6b32..ac8d1576381a1 100644 --- a/packages/core/src/browser/hover-service.ts +++ b/packages/core/src/browser/hover-service.ts @@ -62,6 +62,11 @@ export interface HoverRequest { * Used to style certain boxes different e.g. for the extended tab preview. */ cssClasses?: string[] + /** + * A function to render a visual preview on the hover. + * Function that takes the desired width and returns a HTMLElement to be rendered. + */ + visualPreview?: (width: number) => HTMLElement | undefined; } @injectable() @@ -106,6 +111,7 @@ export class HoverService { protected async renderHover(request: HoverRequest): Promise { const host = this.hoverHost; + let firstChild: HTMLElement | undefined; const { target, content, position, cssClasses } = request; if (cssClasses) { host.classList.add(...cssClasses); @@ -113,18 +119,30 @@ export class HoverService { this.hoverTarget = target; 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; } // 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'; document.body.append(host); + + 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(Math.max(250, width)); + if (visualPreview) { + host.appendChild(visualPreview); + } + } + await animationFrame(); // Allow the browser to size the host const updatedPosition = this.setHostPosition(target, host, position); diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 646883f929ac7..5e9449ef5f2d7 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -43,6 +43,7 @@ import { nls } from '../../common/nls'; import { SecondaryWindowHandler } from '../secondary-window-handler'; import URI from '../../common/uri'; import { OpenerService } from '../opener-service'; +import { PreviewableWidget } from '../widgets/previewable-widget'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -1186,6 +1187,9 @@ export class ApplicationShell extends Widget { newValue['onCloseRequest'](msg); }; this.toDisposeOnActiveChanged.push(Disposable.create(() => newValue['onCloseRequest'] = onCloseRequest)); + if (PreviewableWidget.is(newValue)) { + newValue.loaded = true; + } } this.onDidChangeActiveWidgetEmitter.fire(args); } diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 8e01b66d3dc83..eda266f9d3b40 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -37,6 +37,7 @@ import { HoverService } from '../hover-service'; import { Root, createRoot } from 'react-dom/client'; import { SelectComponent } from '../widgets/select-component'; import { createElement } from 'react'; +import { PreviewableWidget } from '../widgets/previewable-widget'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -165,7 +166,9 @@ export class TabBarRenderer extends TabBar.Renderer { ? nls.localizeByDefault('Unpin') : nls.localizeByDefault('Close'); - const hover = this.tabBar && (this.tabBar.orientation === 'horizontal' && !this.corePreferences?.['window.tabbar.enhancedPreview']) ? { title: title.caption } : { + const hover = this.tabBar && (this.tabBar.orientation === 'horizontal' && this.corePreferences?.['window.tabbar.enhancedPreview'] === 'classic') + ? { title: title.caption } + : { onmouseenter: this.handleMouseEnterEvent }; @@ -510,6 +513,55 @@ export class TabBarRenderer extends TabBar.Renderer { return hoverBox; }; + protected renderVisualPreview(desiredWidth: number, title: Title): HTMLElement | undefined { + const widget = title.owner; + // Check that the widget is not currently shown, is a PreviewableWidget and it was already loaded before + if (this.tabBar && this.tabBar.currentTitle !== title && PreviewableWidget.isPreviewable(widget)) { + const html = document.getElementById(widget.id); + if (html) { + const previewNode: Node | undefined = widget.getPreviewNode(); + if (previewNode) { + const clonedNode = previewNode.cloneNode(true); + const visualPreviewDiv = document.createElement('div'); + visualPreviewDiv.classList.add('enhanced-preview-div'); + // Add the clonedNode and get it from the children to have a HTMLElement instead of a Node + visualPreviewDiv.append(clonedNode); + const visualPreview = visualPreviewDiv.children.item(visualPreviewDiv.children.length - 1); + if (visualPreview instanceof HTMLElement) { + visualPreview.classList.remove('p-mod-hidden'); + visualPreview.classList.add('enhanced-preview'); + visualPreview.id = `preview:${widget.id}`; + + // Use the current visible editor as a fallback if not available + const height: number = visualPreview.style.height === '' ? this.tabBar.currentTitle!.owner.node.offsetHeight : parseFloat(visualPreview.style.height); + const width: number = visualPreview.style.width === '' ? this.tabBar.currentTitle!.owner.node.offsetWidth : parseFloat(visualPreview.style.width); + const ratio = height / width; + visualPreviewDiv.style.width = `${desiredWidth}px`; + visualPreviewDiv.style.height = `${desiredWidth * ratio}px`; + + const scale = desiredWidth / width; + visualPreview.style.transform = `scale(${scale},${scale})`; + visualPreview.style.removeProperty('top'); + visualPreview.style.removeProperty('left'); + + // Copy canvases (They are cloned empty) + const originalCanvases = html.getElementsByTagName('canvas'); + const previewCanvases = visualPreview.getElementsByTagName('canvas'); + // If this is not given, something went wrong during the cloning + if (originalCanvases.length === previewCanvases.length) { + for (let i = 0; i < originalCanvases.length; i++) { + previewCanvases[i].getContext('2d')?.drawImage(originalCanvases[i], 0, 0); + } + } + + return visualPreviewDiv; + } + } + } + } + return undefined; + } + protected handleMouseEnterEvent = (event: MouseEvent) => { if (this.tabBar && this.hoverService && event.currentTarget instanceof HTMLElement) { const id = event.currentTarget.id; @@ -520,7 +572,8 @@ export class TabBarRenderer extends TabBar.Renderer { content: this.renderEnhancedPreview(title), target: event.currentTarget, position: 'bottom', - cssClasses: ['extended-tab-preview'] + cssClasses: ['extended-tab-preview'], + visualPreview: this.corePreferences?.['window.tabbar.enhancedPreview'] === 'visual' ? width => this.renderVisualPreview(width, title) : undefined }); } else { this.hoverService.requestHover({ diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 047a623eb5346..10ba782a50c6d 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -470,6 +470,16 @@ margin: 0px 4px; } +.enhanced-preview-div { + margin: 4px 4px; + pointer-events: none; + background: var(--theia-editor-background); +} + +.enhanced-preview { + transform-origin: top left; +} + .theia-horizontal-tabBar-hover-title { font-weight: bolder; font-size: medium; diff --git a/packages/core/src/browser/widgets/previewable-widget.ts b/packages/core/src/browser/widgets/previewable-widget.ts new file mode 100644 index 0000000000000..26b78873164f5 --- /dev/null +++ b/packages/core/src/browser/widgets/previewable-widget.ts @@ -0,0 +1,30 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics 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 +// ***************************************************************************** +import { isFunction, isObject } from '../../common'; + +export interface PreviewableWidget { + loaded?: boolean; + getPreviewNode(): Node | undefined; +} + +export namespace PreviewableWidget { + export function is(arg: unknown): arg is PreviewableWidget { + return isObject(arg) && isFunction(arg.getPreviewNode); + } + export function isPreviewable(arg: unknown): arg is PreviewableWidget { + return isObject(arg) && isFunction(arg.getPreviewNode) && arg.loaded === true; + } +} diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index f9afe9d6e77c5..8ea9b5959e141 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -23,6 +23,7 @@ import { Emitter, Event, Disposable, DisposableCollection, MaybePromise, isObjec import { KeyCode, KeysOrKeyCodes } from '../keyboard/keys'; import PerfectScrollbar from 'perfect-scrollbar'; +import { PreviewableWidget } from '../widgets/previewable-widget'; decorate(injectable(), Widget); decorate(unmanaged(), Widget, 0); @@ -93,7 +94,7 @@ export namespace UnsafeWidgetUtilities { } @injectable() -export class BaseWidget extends Widget { +export class BaseWidget extends Widget implements PreviewableWidget { protected readonly onScrollYReachEndEmitter = new Emitter(); readonly onScrollYReachEnd: Event = this.onScrollYReachEndEmitter.event; @@ -216,6 +217,10 @@ export class BaseWidget extends Widget { this.toDisposeOnDetach.push(addClipboardListener(element, type, listener)); } + getPreviewNode(): Node | undefined { + return this.node; + } + override setFlag(flag: Widget.Flag): void { super.setFlag(flag); if (flag === Widget.Flag.IsVisible) { diff --git a/packages/preferences/src/browser/views/preference-widget.tsx b/packages/preferences/src/browser/views/preference-widget.tsx index 2256fec5737e9..709e29a2a9fcf 100644 --- a/packages/preferences/src/browser/views/preference-widget.tsx +++ b/packages/preferences/src/browser/views/preference-widget.tsx @@ -97,6 +97,10 @@ export class PreferencesWidget extends Panel implements StatefulWidget { this.update(); } + getPreviewNode(): Node | undefined { + return this.node; + } + storeState(): PreferencesWidgetState { return { scopeTabBarState: this.tabBarWidget.storeState(),