diff --git a/CHANGELOG.md b/CHANGELOG.md index d49e58e21eaa1..130c6c0e32385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## v1.39.0 - 06/29/2023 - [debug] added support for conditional exception breakpoints [#12445](https://github.com/eclipse-theia/theia/pull/12445) +- [core] made the `window.tabbar.enhancedPreview` preference an enum with 3 options: [#](https://github.com/eclipse-theia/theia/pull/) - 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.39.0) diff --git a/packages/core/src/browser/core-preferences.ts b/packages/core/src/browser/core-preferences.ts index 5b81d80ea1d48..96f17489d313e 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 4901fc4065796..40ba5d85ed30d 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'; @@ -1184,6 +1185,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 918384027076f..e972a86ef9930 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'; @@ -164,7 +165,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 }; @@ -501,6 +504,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; @@ -511,7 +563,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 21b9b9885a5b2..70aa5915d19cb 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -462,6 +462,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..c241c7c2c15ea --- /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 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(),