// ***************************************************************************** // Copyright (C) 2023 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 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // only type imports are allowed here since this runs in an iframe. All other code is not accessible import type * as webviewCommunication from './webview-communication'; import type * as rendererApi from 'vscode-notebook-renderer'; import type { Disposable, Event } from '@theia/core'; declare const acquireVsCodeApi: () => ({ getState(): { [key: string]: unknown }; setState(data: { [key: string]: unknown }): void; postMessage: (msg: unknown) => void; }); declare function __import(path: string): Promise; interface Listener { fn: (evt: T) => void; thisArg: unknown }; interface EmitterLike { fire(data: T): void; event: Event; } interface RendererContext extends rendererApi.RendererContext { readonly onDidChangeSettings: Event; readonly settings: RenderOptions; } interface NotebookRendererEntrypoint { readonly path: string; readonly extends?: string }; export interface RenderOptions { readonly lineLimit: number; readonly outputScrolling: boolean; readonly outputWordWrap: boolean; } export interface PreloadContext { readonly isWorkspaceTrusted: boolean; readonly rendererData: readonly webviewCommunication.RendererMetadata[]; readonly renderOptions: RenderOptions; readonly staticPreloadsData: readonly string[]; } interface KernelPreloadContext { readonly onDidReceiveKernelMessage: Event; postKernelMessage(data: unknown): void; } interface KernelPreloadModule { activate(ctx: KernelPreloadContext): Promise | void; } export async function outputWebviewPreload(ctx: PreloadContext): Promise { // workaround to allow rendering of links in outputs for non chromium browsers // see https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API#cross-browser_support_for_trusted_types if (!window.trustedTypes) { window.trustedTypes = { createPolicy: (name: string, rules: unknown) => rules } as unknown as typeof window.trustedTypes; } const theia = acquireVsCodeApi(); const renderFallbackErrorName = 'vscode.fallbackToNextRenderer'; document.body.style.overflow = 'hidden'; const container = document.createElement('div'); container.id = 'container'; container.classList.add('widgetarea'); document.body.appendChild(container); function createEmitter(listenerChange: (listeners: Set>) => void = () => undefined): EmitterLike { const listeners = new Set>(); return { fire(data: T): void { for (const listener of [...listeners]) { listener.fn.call(listener.thisArg, data); } }, event(fn, thisArg, disposables): Disposable { const listenerObj = { fn, thisArg }; const disposable: Disposable = { dispose: () => { listeners.delete(listenerObj); listenerChange(listeners); }, }; listeners.add(listenerObj); listenerChange(listeners); if (disposables) { if ('push' in disposables) { disposables.push(disposable); } else { disposables.add(disposable); } } return disposable; } }; }; const settingChange: EmitterLike = createEmitter(); const onDidReceiveKernelMessage = createEmitter(); function createKernelContext(): KernelPreloadContext { return Object.freeze({ onDidReceiveKernelMessage: onDidReceiveKernelMessage.event, postKernelMessage: (data: unknown) => { theia.postMessage({ type: 'customKernelMessage', message: data }); } }); } async function runKernelPreload(url: string): Promise { try { return activateModuleKernelPreload(url); } catch (e) { console.error(e); throw e; } } async function activateModuleKernelPreload(url: string): Promise { const baseUri = window.location.href.replace(/\/webview\/index\.html.*/, ''); const module: KernelPreloadModule = (await __import(`${baseUri}/${url}`)) as KernelPreloadModule; if (!module.activate) { console.error(`Notebook preload '${url}' was expected to be a module but it does not export an 'activate' function`); return; } return module.activate(createKernelContext()); } class OutputCell { readonly element: HTMLElement; readonly outputElements: OutputContainer[] = []; private cellHeight: number = 0; constructor(public cellHandle: number, cellIndex?: number) { this.element = document.createElement('div'); this.element.style.outline = '0'; this.element.id = `cellHandle${cellHandle}`; this.element.classList.add('cell_container'); this.element.addEventListener('focusin', e => { theia.postMessage({ type: 'cellFocusChanged', cellHandle: cellHandle }); }); if (cellIndex !== undefined && cellIndex < container.children.length) { container.insertBefore(this.element, container.children[cellIndex]); } else { container.appendChild(this.element); } this.element = this.element; theia.postMessage({ type: 'cellHeightRequest', cellHandle: cellHandle }); } public dispose(): void { this.element.remove(); } calcTotalOutputHeight(): number { return this.outputElements.reduce((acc, output) => acc + output.element.getBoundingClientRect().height, 0) + 5; } createOutputElement(index: number, output: webviewCommunication.Output, items: rendererApi.OutputItem[]): OutputContainer { let outputContainer = this.outputElements.find(o => o.outputId === output.id); if (!outputContainer) { outputContainer = new OutputContainer(output, items, this); this.element.appendChild(outputContainer.containerElement); this.outputElements.splice(index, 0, outputContainer); this.updateCellHeight(this.cellHeight); } return outputContainer; } public clearOutputs(start: number, deleteCount: number): void { for (const output of this.outputElements.splice(start, deleteCount)) { output?.clear(); output.containerElement.remove(); } } public show(outputId: string, top: number): void { const outputContainer = this.outputElements.find(o => o.outputId === outputId); if (!outputContainer) { return; } } public hide(): void { this.element.style.visibility = 'hidden'; } public updateCellHeight(height: number): void { this.cellHeight = height; let additionalHeight = 54.5; additionalHeight -= cells[0] === this ? 2.5 : 0; // first cell additionalHeight -= this.outputElements.length ? 0 : 5.5; // no outputs this.element.style.paddingTop = `${height + additionalHeight}px`; } public outputVisibilityChanged(visible: boolean): void { this.outputElements.forEach(output => { output.element.style.display = visible ? 'initial' : 'none'; output.containerElement.style.minHeight = visible ? '20px' : '0px'; }); if (visible) { this.element.getElementsByClassName('output-hidden')?.[0].remove(); window.requestAnimationFrame(() => this.outputElements.forEach(output => sendDidRenderMessage(this, output))); } else { const outputHiddenElement = document.createElement('div'); outputHiddenElement.classList.add('output-hidden'); outputHiddenElement.style.height = '16px'; this.element.appendChild(outputHiddenElement); } } // public updateScroll(request: webviewCommunication.IContentWidgetTopRequest): void { // this.element.style.top = `${request.cellTop}px`; // const outputElement = this.outputElements.get(request.outputId); // if (outputElement) { // outputElement.updateScroll(request.outputOffset); // if (request.forceDisplay && outputElement.element) { // // TODO @rebornix @mjbvz, there is a misalignment here. // // We set output visibility on cell container, other than output container or output node itself. // outputElement.element.style.visibility = ''; // } // } // if (request.forceDisplay) { // this.element.style.visibility = ''; // } } const cells: OutputCell[] = []; class OutputContainer { readonly outputId: string; readonly cellId: string; renderedItem?: rendererApi.OutputItem; allItems: rendererApi.OutputItem[]; renderer: Renderer; element: HTMLElement; containerElement: HTMLElement; constructor(output: webviewCommunication.Output, items: rendererApi.OutputItem[], private cell: OutputCell) { this.outputId = output.id; this.createHtmlElement(); this.allItems = items; } findItemToRender(preferredMimetype?: string): rendererApi.OutputItem { if (preferredMimetype) { const itemToRender = this.allItems.find(item => item.mime === preferredMimetype); if (itemToRender) { return itemToRender; } } return this.renderedItem ?? this.allItems[0]; } clear(): void { this.renderer?.disposeOutputItem?.(this.renderedItem?.id); this.element.innerHTML = ''; } preferredMimeTypeChange(mimeType: string): void { this.containerElement.remove(); this.createHtmlElement(); this.cell.element.appendChild(this.containerElement); renderers.render(this.cell, this, mimeType, undefined, new AbortController().signal); } private createHtmlElement(): void { this.containerElement = document.createElement('div'); this.containerElement.classList.add('output-container'); this.containerElement.style.minHeight = '20px'; this.element = document.createElement('div'); this.element.id = this.outputId; this.element.classList.add('output'); this.containerElement.appendChild(this.element); } } class Renderer { entrypoint: NotebookRendererEntrypoint; private rendererApi?: rendererApi.RendererApi; private onMessageEvent: EmitterLike = createEmitter(); constructor( public readonly data: webviewCommunication.RendererMetadata ) { } public receiveMessage(message: unknown): void { this.onMessageEvent.fire(message); } public disposeOutputItem(id?: string): void { this.rendererApi?.disposeOutputItem?.(id); } async getOrLoad(): Promise { if (this.rendererApi) { return this.rendererApi; } // Preloads need to be loaded before loading renderers. await kernelPreloads.waitForAllCurrent(); const baseUri = window.location.href.replace(/\/webview\/index\.html.*/, ''); const rendererModule = await __import(`${baseUri}/${this.data.entrypoint.uri}`) as { activate: rendererApi.ActivationFunction }; this.rendererApi = await rendererModule.activate(this.createRendererContext()); return this.rendererApi; } protected createRendererContext(): RendererContext { const context: RendererContext = { setState: newState => theia.setState({ ...theia.getState(), [this.data.id]: newState }), getState: () => { const state = theia.getState(); return typeof state === 'object' && state ? state[this.data.id] as T : undefined; }, getRenderer: async (id: string) => { const renderer = renderers.getRenderer(id); if (!renderer) { return undefined; } if (renderer.rendererApi) { return renderer.rendererApi; } return renderer.getOrLoad(); }, workspace: { get isTrusted(): boolean { return true; } // TODO use Workspace trust service }, settings: { get lineLimit(): number { return ctx.renderOptions.lineLimit; }, get outputScrolling(): boolean { return ctx.renderOptions.outputScrolling; }, get outputWordWrap(): boolean { return ctx.renderOptions.outputWordWrap; }, }, get onDidChangeSettings(): Event { return settingChange.event; }, }; if (this.data.requiresMessaging) { context.onDidReceiveMessage = this.onMessageEvent.event; context.postMessage = message => { theia.postMessage({ type: 'customRendererMessage', rendererId: this.data.id, message }); }; } return Object.freeze(context); } } const renderers = new class { private readonly renderers = new Map(); constructor() { for (const renderer of ctx.rendererData) { this.addRenderer(renderer); } } public getRenderer(id: string): Renderer | undefined { return this.renderers.get(id); } private rendererEqual(a: webviewCommunication.RendererMetadata, b: webviewCommunication.RendererMetadata): boolean { if (a.id !== b.id || a.entrypoint.uri !== b.entrypoint.uri || a.entrypoint.extends !== b.entrypoint.extends || a.requiresMessaging !== b.requiresMessaging) { return false; } if (a.mimeTypes.length !== b.mimeTypes.length) { return false; } for (let i = 0; i < a.mimeTypes.length; i++) { if (a.mimeTypes[i] !== b.mimeTypes[i]) { return false; } } return true; } public updateRendererData(rendererData: readonly webviewCommunication.RendererMetadata[]): void { const oldKeys = new Set(this.renderers.keys()); const newKeys = new Set(rendererData.map(d => d.id)); for (const renderer of rendererData) { const existing = this.renderers.get(renderer.id); if (existing && this.rendererEqual(existing.data, renderer)) { continue; } this.addRenderer(renderer); } for (const key of oldKeys) { if (!newKeys.has(key)) { this.renderers.delete(key); } } } private addRenderer(renderer: webviewCommunication.RendererMetadata): void { this.renderers.set(renderer.id, new Renderer(renderer)); } public clearAll(): void { for (const renderer of this.renderers.values()) { renderer.disposeOutputItem(); } } public clearOutput(rendererId: string, outputId: string): void { // outputRunner.cancelOutput(outputId); this.renderers.get(rendererId)?.disposeOutputItem(outputId); } public async render(cell: OutputCell, output: OutputContainer, preferredMimeType: string | undefined, preferredRendererId: string | undefined, signal: AbortSignal): Promise { const item = output.findItemToRender(preferredMimeType); const primaryRenderer = this.findRenderer(preferredRendererId, item); if (!primaryRenderer) { this.showRenderError(item, output.element, 'No renderer found for output type.'); return; } // Try primary renderer first if (!(await this.doRender(item, output.element, primaryRenderer, signal)).continue) { output.renderer = primaryRenderer; this.onRenderCompleted(cell, output); return; } // Primary renderer failed in an expected way. Fallback to render the next mime types for (const additionalItem of output.allItems) { if (additionalItem.mime === item.mime) { continue; } if (signal.aborted) { return; } if (additionalItem) { const renderer = this.findRenderer(undefined, additionalItem); if (renderer) { if (!(await this.doRender(additionalItem, output.element, renderer, signal)).continue) { output.renderer = renderer; this.onRenderCompleted(cell, output); return; // We rendered successfully } } } } // All renderers have failed and there is nothing left to fallback to this.showRenderError(item, output.element, 'No fallback renderers found or all fallback renderers failed.'); } private onRenderCompleted(cell: OutputCell, output: OutputContainer): void { // we need to check for all images are loaded. Otherwise we can't determine the correct height of the output const images = Array.from(document.images); if (images.length > 0) { Promise.all(images .filter(img => !img.complete && !img.dataset.waiting) .map(img => { img.dataset.waiting = 'true'; // mark to avoid overriding onload a second time return new Promise(resolve => { img.onload = img.onerror = resolve; }); })).then(() => { sendDidRenderMessage(cell, output); new ResizeObserver(() => sendDidRenderMessage(cell, output)).observe(cell.element); }); } else { sendDidRenderMessage(cell, output); new ResizeObserver(() => sendDidRenderMessage(cell, output)).observe(cell.element); } } private async doRender(item: rendererApi.OutputItem, element: HTMLElement, renderer: Renderer, signal: AbortSignal): Promise<{ continue: boolean }> { try { await (await renderer.getOrLoad())?.renderOutputItem(item, element, signal); return { continue: false }; // We rendered successfully } catch (e) { if (signal.aborted) { return { continue: false }; } if (e instanceof Error && e.name === renderFallbackErrorName) { return { continue: true }; } else { throw e; // Bail and let callers handle unknown errors } } } private findRenderer(preferredRendererId: string | undefined, info: rendererApi.OutputItem): Renderer | undefined { let foundRenderer: Renderer | undefined; if (typeof preferredRendererId === 'string') { foundRenderer = Array.from(this.renderers.values()) .find(renderer => renderer.data.id === preferredRendererId); } else { const rendererList = Array.from(this.renderers.values()) .filter(renderer => renderer.data.mimeTypes.includes(info.mime) && !renderer.data.entrypoint.extends); if (rendererList.length) { // De-prioritize built-in renderers // rendererList.sort((a, b) => +a.data.isBuiltin - +b.data.isBuiltin); // Use first renderer we find in sorted list foundRenderer = rendererList[0]; } } return foundRenderer; } private showRenderError(info: rendererApi.OutputItem, element: HTMLElement, errorMessage: string): void { const errorContainer = document.createElement('div'); const error = document.createElement('div'); error.className = 'no-renderer-error'; error.innerText = errorMessage; const cellText = document.createElement('div'); cellText.innerText = info.text(); errorContainer.appendChild(error); errorContainer.appendChild(cellText); element.innerText = ''; element.appendChild(errorContainer); } }(); function sendDidRenderMessage(cell: OutputCell, output: OutputContainer): void { theia.postMessage({ type: 'didRenderOutput', cellHandle: cell.cellHandle, outputId: output.outputId, outputHeight: cell.calcTotalOutputHeight(), bodyHeight: document.body.clientHeight }); } const kernelPreloads = new class { private readonly preloads = new Map>(); /** * Returns a promise that resolves when the given preload is activated. */ public waitFor(uri: string): Promise { return this.preloads.get(uri) || Promise.resolve(new Error(`Preload not ready: ${uri}`)); } /** * Loads a preload. * @param uri URI to load from * @param originalUri URI to show in an error message if the preload is invalid. */ public load(uri: string): Promise { const promise = Promise.all([ runKernelPreload(uri), this.waitForAllCurrent(), ]); this.preloads.set(uri, promise); return promise; } /** * Returns a promise that waits for all currently-registered preloads to * activate before resolving. */ public waitForAllCurrent(): Promise { return Promise.all([...this.preloads.values()].map(p => p.catch(err => err))); } }; await Promise.all(ctx.staticPreloadsData.map(preload => kernelPreloads.load(preload))); async function outputsChanged(changedEvent: webviewCommunication.OutputChangedMessage): Promise { for (const cellChange of changedEvent.changes) { let cell = cells.find(c => c.cellHandle === cellChange.cellHandle); if (!cell) { cell = new OutputCell(cellChange.cellHandle); cells.push(cell); } cell.clearOutputs(cellChange.start, cellChange.deleteCount); for (const outputData of cellChange.newOutputs ?? []) { const apiItems: rendererApi.OutputItem[] = outputData.items.map((item, index) => ({ id: `${outputData.id}-${index}`, mime: item.mime, metadata: outputData.metadata, data(): Uint8Array { return item.data; }, text(): string { return new TextDecoder().decode(this.data()); }, json(): unknown { return JSON.parse(this.text()); }, blob(): Blob { return new Blob([new Uint8Array(this.data())], { type: this.mime }); }, })); const output = cell.createOutputElement(cellChange.start, outputData, apiItems); await renderers.render(cell, output, undefined, undefined, new AbortController().signal); theia.postMessage({ type: 'didRenderOutput', cellHandle: cell.cellHandle, outputId: outputData.id, outputHeight: document.getElementById(output.outputId)?.clientHeight ?? 0, bodyHeight: document.body.clientHeight }); } } } function cellsChanged(changes: (webviewCommunication.CellsMoved | webviewCommunication.CellsSpliced)[]): void { for (const change of changes) { if (change.type === 'cellMoved') { const currentIndex = cells.findIndex(c => c.cellHandle === change.cellHandle); const cell = cells[currentIndex]; cells.splice(change.toIndex, 0, cells.splice(currentIndex, 1)[0]); if (change.toIndex < cells.length - 1) { container.insertBefore(cell.element, container.children[change.toIndex + (change.toIndex > currentIndex ? 1 : 0)]); } else { container.appendChild(cell.element); } } else if (change.type === 'cellsSpliced') { // if startCellHandle is negative, it means we should add a trailing new cell const startCellIndex = change.startCellHandle < 0 ? cells.length : cells.findIndex(c => c.cellHandle === change.startCellHandle); if (startCellIndex === -1) { console.error(`Can't find cell output to splice. Cells: ${cells.length}, startCellHandle: ${change.startCellHandle}`); } else { const deletedCells = cells.splice( startCellIndex, change.deleteCount, ...change.newCells.map((cellHandle, i) => new OutputCell(cellHandle, startCellIndex + i)) ); deletedCells.forEach(cell => cell.dispose()); } } } } function shouldHandleScroll(event: WheelEvent): boolean { for (let node = event.target as Node | null; node; node = node.parentNode) { if (!(node instanceof Element)) { return false; } // scroll up if (event.deltaY < 0 && node.scrollTop > 0) { // there is still some content to scroll return true; } // scroll down if (event.deltaY > 0 && node.scrollTop + node.clientHeight < node.scrollHeight) { // per https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight // scrollTop is not rounded but scrollHeight and clientHeight are // so we need to check if the difference is less than some threshold if (node.scrollHeight - node.scrollTop - node.clientHeight < 2) { continue; } // if the node is not scrollable, we can continue. We don't check the computed style always as it's expensive if (window.getComputedStyle(node).overflowY === 'hidden' || window.getComputedStyle(node).overflowY === 'visible') { continue; } return true; } } return false; } const handleWheel = (event: WheelEvent & { wheelDeltaX?: number; wheelDeltaY?: number; wheelDelta?: number }) => { if (event.defaultPrevented || shouldHandleScroll(event)) { return; } theia.postMessage({ type: 'did-scroll-wheel', deltaY: event.deltaY, deltaX: event.deltaX, }); }; window.addEventListener('message', async rawEvent => { const event = rawEvent as ({ data: webviewCommunication.ToWebviewMessage }); let cellHandle: number | undefined; switch (event.data.type) { case 'updateRenderers': renderers.updateRendererData(event.data.rendererData); break; case 'outputChanged': outputsChanged(event.data); break; case 'cellsChanged': cellsChanged(event.data.changes); break; case 'customRendererMessage': renderers.getRenderer(event.data.rendererId)?.receiveMessage(event.data.message); break; case 'changePreferredMimetype': cellHandle = event.data.cellHandle; const mimeType = event.data.mimeType; cells.find(c => c.cellHandle === cellHandle) ?.outputElements.forEach(o => o.preferredMimeTypeChange(mimeType)); break; case 'customKernelMessage': onDidReceiveKernelMessage.fire(event.data.message); break; case 'preload': const resources = event.data.resources; for (const uri of resources) { kernelPreloads.load(uri); } break; case 'notebookStyles': const documentStyle = window.document.documentElement.style; for (let i = documentStyle.length - 1; i >= 0; i--) { const property = documentStyle[i]; // Don't remove properties that the webview might have added separately if (property && property.startsWith('--notebook-')) { documentStyle.removeProperty(property); } } // Re-add new properties for (const [name, value] of Object.entries(event.data.styles)) { documentStyle.setProperty(`--${name}`, value); } break; case 'cellHeightUpdate': cellHandle = event.data.cellHandle; const cell = cells.find(c => c.cellHandle === cellHandle); if (cell) { cell.updateCellHeight(event.data.height); } break; case 'outputVisibilityChanged': cellHandle = event.data.cellHandle; cells.find(c => c.cellHandle === cellHandle)?.outputVisibilityChanged(event.data.visible); break; } }); window.addEventListener('wheel', handleWheel); (document.head as HTMLHeadElement & { originalAppendChild: typeof document.head.appendChild }).originalAppendChild = document.head.appendChild; (document.head as HTMLHeadElement & { originalAppendChild: typeof document.head.appendChild }).appendChild = function appendChild(node: T): T { if (node instanceof HTMLScriptElement && node.src.includes('webviewuuid')) { node.src = node.src.replace('webviewuuid', location.hostname.split('.')[0]); } return this.originalAppendChild(node); }; const focusChange = (event: FocusEvent, focus: boolean) => { if (event.target instanceof HTMLInputElement) { theia.postMessage({ type: 'inputFocusChanged', focused: focus } as webviewCommunication.InputFocusChange); } }; window.addEventListener('focusin', (event: FocusEvent) => focusChange(event, true)); window.addEventListener('focusout', (event: FocusEvent) => focusChange(event, false)); const webviewFocuseChange = (focus: boolean) => { theia.postMessage({ type: 'webviewFocusChanged', focused: focus } as webviewCommunication.WebviewFocusChange); }; window.addEventListener('focus', () => webviewFocuseChange(true)); window.addEventListener('blur', () => webviewFocuseChange(false)); new ResizeObserver(() => { theia.postMessage({ type: 'bodyHeightChange', height: document.body.clientHeight } as webviewCommunication.BodyHeightChange); }).observe(document.body); theia.postMessage({ type: 'initialized' }); }