// ***************************************************************************** // Copyright (C) 2018 TypeFox 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 throttle = require('@theia/core/shared/lodash.throttle'); import { inject, injectable } from '@theia/core/shared/inversify'; import { Resource, MaybePromise } from '@theia/core'; import { Navigatable } from '@theia/core/lib/browser/navigatable'; import { BaseWidget, Message, addEventListener, codicon } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { Event, Emitter } from '@theia/core/lib/common'; import { PreviewHandler, PreviewHandlerProvider } from './preview-handler'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { EditorPreferences } from '@theia/editor/lib/common/editor-preferences'; import { Disposable } from '@theia/core/lib/common/disposable'; import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace'; import { Range, Location } from '@theia/core/shared/vscode-languageserver-protocol'; export const PREVIEW_WIDGET_CLASS = 'theia-preview-widget'; const DEFAULT_ICON = codicon('eye'); let widgetCounter: number = 0; export const PreviewWidgetOptions = Symbol('PreviewWidgetOptions'); export interface PreviewWidgetOptions { resource: Resource } @injectable() export class PreviewWidget extends BaseWidget implements Navigatable { readonly uri: URI; protected readonly resource: Resource; protected previewHandler: PreviewHandler | undefined; protected firstUpdate: (() => void) | undefined = undefined; protected readonly onDidScrollEmitter = new Emitter(); protected readonly onDidDoubleClickEmitter = new Emitter(); protected scrollBeyondLastLine: boolean; constructor( @inject(PreviewWidgetOptions) protected readonly options: PreviewWidgetOptions, @inject(PreviewHandlerProvider) protected readonly previewHandlerProvider: PreviewHandlerProvider, @inject(ThemeService) protected readonly themeService: ThemeService, @inject(MonacoWorkspace) protected readonly workspace: MonacoWorkspace, @inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences, ) { super(); this.resource = this.options.resource; this.uri = this.resource.uri; this.id = 'preview-widget-' + widgetCounter++; this.title.closable = true; this.title.label = `Preview ${this.uri.path.base}`; this.title.caption = this.title.label; this.title.closable = true; this.toDispose.push(this.onDidScrollEmitter); this.toDispose.push(this.onDidDoubleClickEmitter); this.addClass(PREVIEW_WIDGET_CLASS); this.node.tabIndex = 0; const previewHandler = this.previewHandler = this.previewHandlerProvider.findContribution(this.uri)[0]; if (!previewHandler) { return; } this.title.iconClass = previewHandler.iconClass || DEFAULT_ICON; this.initialize(); } async initialize(): Promise { this.scrollBeyondLastLine = !!this.editorPreferences['editor.scrollBeyondLastLine']; this.toDispose.push(this.editorPreferences.onPreferenceChanged(e => { if (e.preferenceName === 'editor.scrollBeyondLastLine') { this.scrollBeyondLastLine = !!this.editorPreferences['editor.scrollBeyondLastLine']; this.forceUpdate(); } })); this.toDispose.push(this.resource); if (this.resource.onDidChangeContents) { this.toDispose.push(this.resource.onDidChangeContents(() => this.update())); } const updateIfAffected = (affectedUri?: string) => { if (!affectedUri || affectedUri === this.uri.toString()) { this.update(); } }; this.toDispose.push(this.workspace.onDidOpenTextDocument(document => updateIfAffected(document.uri))); this.toDispose.push(this.workspace.onDidChangeTextDocument(params => updateIfAffected(params.model.uri))); this.toDispose.push(this.workspace.onDidCloseTextDocument(document => updateIfAffected(document.uri))); this.toDispose.push(this.themeService.onDidColorThemeChange(() => this.update())); this.firstUpdate = () => { this.revealFragment(this.uri); }; this.update(); } protected override onBeforeAttach(msg: Message): void { super.onBeforeAttach(msg); this.toDispose.push(this.startScrollSync()); this.toDispose.push(this.startDoubleClickListener()); } protected preventScrollNotification: boolean = false; protected startScrollSync(): Disposable { return addEventListener(this.node, 'scroll', throttle((event: UIEvent) => { if (this.preventScrollNotification) { return; } const scrollTop = this.node.scrollTop; this.didScroll(scrollTop); }, 50)); } protected startDoubleClickListener(): Disposable { return addEventListener(this.node, 'dblclick', (event: MouseEvent) => { if (!(event.target instanceof HTMLElement)) { return; } const target = event.target as HTMLElement; let node: HTMLElement | null = target; while (node && node instanceof HTMLElement) { if (node.tagName === 'A') { return; } node = node.parentElement; } const offsetParent = target.offsetParent as HTMLElement; const offset = offsetParent.classList.contains(PREVIEW_WIDGET_CLASS) ? target.offsetTop : offsetParent.offsetTop; this.didDoubleClick(offset); }); } getUri(): URI { return this.uri; } getResourceUri(): URI | undefined { return this.uri; } createMoveToUri(resourceUri: URI): URI | undefined { return this.uri.withPath(resourceUri.path); } override onActivateRequest(msg: Message): void { super.onActivateRequest(msg); this.node.focus(); this.update(); } override onUpdateRequest(msg: Message): void { super.onUpdateRequest(msg); this.performUpdate(); } protected forceUpdate(): void { this.previousContent = undefined; this.update(); } protected previousContent: string | undefined = undefined; protected async performUpdate(): Promise { if (!this.resource) { return; } const uri = this.resource.uri; const document = this.workspace.textDocuments.find(d => d.uri === uri.toString()); const content: MaybePromise = document ? document.getText() : await this.resource.readContents(); if (content === this.previousContent) { return; } this.previousContent = content; const contentElement = await this.render(content, uri); this.node.innerHTML = ''; if (contentElement) { if (this.scrollBeyondLastLine) { contentElement.classList.add('scrollBeyondLastLine'); } this.node.appendChild(contentElement); if (this.firstUpdate) { this.firstUpdate(); this.firstUpdate = undefined; } } } protected async render(content: string, originUri: URI): Promise { if (!this.previewHandler || !this.resource) { return undefined; } return this.previewHandler.renderContent({ content, originUri }); } protected revealFragment(uri: URI): void { if (uri.fragment === '' || !this.previewHandler || !this.previewHandler.findElementForFragment) { return; } const elementToReveal = this.previewHandler.findElementForFragment(this.node, uri.fragment); if (elementToReveal) { this.preventScrollNotification = true; elementToReveal.scrollIntoView(); window.setTimeout(() => { this.preventScrollNotification = false; }, 50); } } revealForSourceLine(sourceLine: number): void { this.internalRevealForSourceLine(sourceLine); } protected readonly internalRevealForSourceLine: (sourceLine: number) => void = throttle((sourceLine: number) => { if (!this.previewHandler || !this.previewHandler.findElementForSourceLine) { return; } const elementToReveal = this.previewHandler.findElementForSourceLine(this.node, sourceLine); if (elementToReveal) { this.preventScrollNotification = true; elementToReveal.scrollIntoView(); window.setTimeout(() => { this.preventScrollNotification = false; }, 50); } }, 50); get onDidScroll(): Event { return this.onDidScrollEmitter.event; } protected fireDidScrollToSourceLine(line: number): void { this.onDidScrollEmitter.fire(line); } protected didScroll(scrollTop: number): void { if (!this.previewHandler || !this.previewHandler.getSourceLineForOffset) { return; } const offset = scrollTop; const line = this.previewHandler.getSourceLineForOffset(this.node, offset); if (line) { this.fireDidScrollToSourceLine(line); } } get onDidDoubleClick(): Event { return this.onDidDoubleClickEmitter.event; } protected fireDidDoubleClickToSourceLine(line: number): void { if (!this.resource) { return; } this.onDidDoubleClickEmitter.fire({ uri: this.resource.uri.toString(), range: Range.create({ line, character: 0 }, { line, character: 0 }) }); } protected didDoubleClick(offsetTop: number): void { if (!this.previewHandler || !this.previewHandler.getSourceLineForOffset) { return; } const line = this.previewHandler.getSourceLineForOffset(this.node, offsetTop) || 0; this.fireDidDoubleClickToSourceLine(line); } }