From a13cbeb2c5933ba01ee82bdccc4580354d046124 Mon Sep 17 00:00:00 2001 From: sharon Date: Thu, 9 Jan 2025 13:25:59 -0500 Subject: [PATCH] inject webview-events.js script into preview html (#5766) ## Summary - addresses https://github.com/posit-dev/positron/issues/4276 - page load detection, title display for HTML files, copy/paste, back/forward navigation, etc. should now work in the Viewer in Positron Server Web / Positron on Workbench ### Release Notes #### New Features - Support page load detection, title display for HTML files, copy-paste, back/forward navigation, page refresh, etc. in the Viewer for Positron Server Web / Positron on Workbench (#4276) #### Bug Fixes - N/A ## Implementation Mainly, I used the "Create some sort of proxy layer of our own to inject the script" idea from #4276, with a bit of "[Run away from home and live in the woods](https://www.wikihow.com/Run-Away-from-Home-and-Live-in-the-Woods)" sprinkled in the process. ### Positron Proxy - We now have a `scripts_preview.html` file that serves a similar purpose for the Viewer as the `scripts_help.html` for the Help Pane, which is used as a "template" to inject resources like styles and scripts into the HTML content being presented in the Viewer - In particular, we inject the `webview-events.js` script (see extensions/positron-proxy/resources/webview-events.js) into this template. The webview-events.js script is a copy of the existing Electron version of the script (see src/vs/workbench/contrib/webview/browser/pre/webview-events.js), with a couple modifications for running in a Web context. Changes are fenced between `// --- Start Positron Proxy Changes ---` and `// --- End Positron Proxy Changes ---` comments. - The HTMLProxy does this injection only when we are running in Web, so that we don't interfere with the Electron webview-events.js script that is injected from the core Positron code. - Refactored Positron Proxy to load and inject Help or Preview resources, move util functions into the util.ts file and type-related declarations to a types.ts file. - Moved all content rewriting functions to util.ts ### Webview land - updated `previewOverlayWebview.ts:loadUri()` to align more closely with the HTML structure of src/vs/workbench/contrib/positronHelp/browser/resources/help.html - the HTML script in `previewOverlayWebview.ts:loadUri()` no longer handles reload/navigation and instead forwards messages along to the Webview or the iframe containing the content as applicable - when forwarding messages to the Webview, the flag `__positron_preview_message` is passed - added some handling to the `onmessage` handler in `webviewElement.ts` to unwrap messages flagged with `__positron_preview_message` and pass them to the appropriate handlers ### HTML Nesting Structure Here's a summary of the HTML nesting situation, with some pseudo-HTML. ```html ``` ## QA Notes The changes in this PR should only impact the Viewer in Positron Server Web / Workbench. The following keyboard actions should work: - Copy/Cut/Paste - Select All In the Viewer menu bar, the following buttons should work: - forward/back navigation buttons - refresh/reload button - page title should show for HTML files instead of the HTML file path ### Preview an HTML file directly 1. From the qa-examples-content directory, locate the [OilandGasMetadata.html](https://github.com/posit-dev/qa-example-content/blob/main/workspaces/dash-py-example/data/OilandGasMetadata.html) file 2. Open the HTML file in the Viewer by doing one of the following - Right-click on the file in the File Explorer and select "Open in Viewer" image - Open the file in an editor and click the View eye icon image #### Things to verify - The file's title should show in the Viewer bar, instead of the file path image - Make a change to the `OilandGasMetadata.html` file, save the changes and click the Refresh button in the Viewer. The page in the Viewer should refresh and show the change you made. image - Copy/Cut/Paste/Select All should all work image ### Preview an HTML page being served Any of the qa-examples-content directory app frameworks should do, but I tested with the [flask_example](https://github.com/posit-dev/qa-example-content/tree/main/workspaces/python_apps/flask_example), as it's easier to test the back/forward navigation and copy/paste keyboard actions. 1. From the qa-examples-content directory, locate the [__init__.py](https://github.com/posit-dev/qa-example-content/blob/main/workspaces/python_apps/flask_example/__init__.py) file 2. Open the `__init__.py` file in an editor 3. Click the Launch App button image #### Things to verify - Back/Forward navigation buttons work https://github.com/user-attachments/assets/06fcca91-230b-4beb-92c2-f8c53b9a8561 1. Click on "Registration" 2. Click on the back arrow -- we should get back to the "Posts" page 3. Click on the forward arrow -- we should go to the "Registration" page - Refresh button works https://github.com/user-attachments/assets/f7b6d016-8195-45c3-94a7-112e9b88c8fa 1. Click on "Registration" 2. Enter some text in one of the input boxes 3. Click the Viewer refresh button -- input should be cleared - Copy/Cut/Paste/Select All should all work --- .../resources/scripts_help.html | 16 + .../resources/scripts_preview.html | 64 +++ .../resources/webview-events.js | 408 ++++++++++++++++++ extensions/positron-proxy/src/htmlProxy.ts | 29 +- .../positron-proxy/src/positronProxy.ts | 270 ++++++------ extensions/positron-proxy/src/types.ts | 95 ++++ extensions/positron-proxy/src/util.ts | 121 +++++- .../common/positron/extHostPreviewPanels.ts | 2 +- .../browser/positronPreviewServiceImpl.ts | 2 +- .../browser/previewOverlayWebview.ts | 61 +-- .../webview/browser/pre/webview-events.js | 7 +- .../contrib/webview/browser/webviewElement.ts | 15 + 12 files changed, 906 insertions(+), 184 deletions(-) create mode 100644 extensions/positron-proxy/resources/scripts_preview.html create mode 100644 extensions/positron-proxy/resources/webview-events.js create mode 100644 extensions/positron-proxy/src/types.ts diff --git a/extensions/positron-proxy/resources/scripts_help.html b/extensions/positron-proxy/resources/scripts_help.html index 3e13c486d9d..2fae91e8d87 100644 --- a/extensions/positron-proxy/resources/scripts_help.html +++ b/extensions/positron-proxy/resources/scripts_help.html @@ -4,10 +4,18 @@ + + + + diff --git a/extensions/positron-proxy/resources/webview-events.js b/extensions/positron-proxy/resources/webview-events.js new file mode 100644 index 00000000000..e54654641f0 --- /dev/null +++ b/extensions/positron-proxy/resources/webview-events.js @@ -0,0 +1,408 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * This file is derived from the event handlers in the `index.html` file from + * src/vs/workbench/contrib/webview/browser/pre/index.html. Its job is to absorb + * vents from the inner iframe and forward them to the host as window messages. + * + * This allows the host to dispatch events such as copy/cut/paste commands and + * context menus to the webview. + * + * The other side of the communication is in `index.html`; it receives + * messages sent from this file and forwards them to the webview host, where + * they are processed and dispatched. + * + * Differences between this file and the original are code-fenced with the comment format: + * // --- Start Positron Proxy Changes --- + * ... + * // --- End Positron Proxy Changes --- + * + * Original: src/vs/workbench/contrib/webview/browser/pre/webview-events.js + * + * This file is intended for the browser context of Positron, and should not used in the + * Electron context. Please see the original webview-events.js file for the Electron context. + */ + +/** + * Send a message to the host; this simulates the `hostMessaging` object in the + * webview. + */ +const hostMessaging = { + postMessage: (type, data) => { + // OK to be promiscuous here, as this script is only used in an Electron + // webview context we already control. + window.parent.postMessage({ + channel: type, + data: data, + }, '*'); + } +}; + +/** + * Handles a message sent from the host. + */ +const handlePostMessage = (event) => { + // Execute a command in the document if requested + if (event.data.channel === 'execCommand') { + const command = event.data.data; + // Check for special Positron commands. + if (command === 'navigate-back') { + window.history.back(); + return; + } else if (command === 'navigate-forward') { + window.history.forward(); + return; + } else if (command === 'reload-window') { + window.location.reload(); + return; + } + + // Otherwise, execute the command in the document. + document.execCommand(command); + } +}; + +/** + * @param {MouseEvent} event + */ +const handleAuxClick = (event) => { + // Prevent middle clicks opening a broken link in the browser + if (!event?.view?.document) { + return; + } + + if (event.button === 1) { + for (const pathElement of event.composedPath()) { + /** @type {any} */ + const node = pathElement; + if ( + node.tagName && + node.tagName.toLowerCase() === "a" && + node.href + ) { + event.preventDefault(); + return; + } + } + } +}; + +// --- Start Positron Proxy Changes --- +/** + * This is a copy of the handleInnerKeydown function from src/vs/workbench/contrib/webview/browser/pre/index.html, + * with some modifications for Positron in a browser context. + * @param {KeyboardEvent} e + */ +const handleInnerKeydown = (e) => { + // If the keypress would trigger a browser event, such as copy or paste, + // make sure we block the browser from dispatching it. Instead VS Code + // handles these events and will dispatch a copy/paste back to the webview + // if needed + if (isPrint(e) || isFindEvent(e) || isSaveEvent(e)) { + e.preventDefault(); + } else if (isUndoRedo(e) || isCopyPasteOrCut(e)) { + return; // let the browser handle this + } else if (isCloseTab(e) || isNewWindow(e) || isHelp(e) || isRefresh(e)) { + // Prevent Ctrl+W closing window / Ctrl+N opening new window in PWA. + // (No effect in a regular browser tab.) + e.preventDefault(); + } + + hostMessaging.postMessage('did-keydown', { + key: e.key, + keyCode: e.keyCode, + code: e.code, + shiftKey: e.shiftKey, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + repeat: e.repeat + }); +}; +// --- End Positron Proxy Changes --- + +/** + * @param {KeyboardEvent} e + */ +const handleInnerKeyup = (e) => { + hostMessaging.postMessage("did-keyup", { + key: e.key, + keyCode: e.keyCode, + code: e.code, + shiftKey: e.shiftKey, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + repeat: e.repeat, + }); +}; + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isCopyPasteOrCut(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 45: keyCode of "Insert" + const shiftInsert = e.shiftKey && e.keyCode === 45; + // 67, 86, 88: keyCode of "C", "V", "X" + return (hasMeta && [67, 86, 88].includes(e.keyCode)) || shiftInsert; +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isUndoRedo(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 90, 89: keyCode of "Z", "Y" + return hasMeta && [90, 89].includes(e.keyCode); +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isPrint(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 80: keyCode of "P" + return hasMeta && e.keyCode === 80; +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isFindEvent(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 70: keyCode of "F" + return hasMeta && e.keyCode === 70; +} + +let isHandlingScroll = false; + +/** + * @param {WheelEvent} event + */ +const handleWheel = (event) => { + if (isHandlingScroll) { + return; + } + + hostMessaging.postMessage("did-scroll-wheel", { + deltaMode: event.deltaMode, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaZ: event.deltaZ, + detail: event.detail, + type: event.type, + }); +}; + +/** + * @param {Event} event + */ +const handleInnerScroll = (event) => { + if (isHandlingScroll) { + return; + } + + const target = /** @type {HTMLDocument | null} */ (event.target); + const currentTarget = /** @type {Window | null} */ ( + event.currentTarget + ); + if (!currentTarget || !target?.body) { + return; + } + + const progress = currentTarget.scrollY / target.body.clientHeight; + if (isNaN(progress)) { + return; + } + + isHandlingScroll = true; + window.requestAnimationFrame(() => { + try { + hostMessaging.postMessage("did-scroll", { + scrollYPercentage: progress, + }); + } catch (e) { + // noop + } + isHandlingScroll = false; + }); +}; + +function handleInnerDragStartEvent(/** @type {DragEvent} */ e) { + if (e.defaultPrevented) { + // Extension code has already handled this event + return; + } + + if (!e.dataTransfer || e.shiftKey) { + return; + } + + // Only handle drags from outside editor for now + if ( + e.dataTransfer.items.length && + Array.prototype.every.call( + e.dataTransfer.items, + (item) => item.kind === "file", + ) + ) { + hostMessaging.postMessage("drag-start", undefined); + } +} +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isSaveEvent(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 83: keyCode of "S" + return hasMeta && e.keyCode === 83; +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isCloseTab(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 87: keyCode of "W" + return hasMeta && e.keyCode === 87; +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isNewWindow(e) { + const hasMeta = e.ctrlKey || e.metaKey; + // 78: keyCode of "N" + return hasMeta && e.keyCode === 78; +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isHelp(e) { + // 112: keyCode of "F1" + return e.keyCode === 112; +} + +/** + * @param {KeyboardEvent} e + * @return {boolean} + */ +function isRefresh(e) { + // 116: keyCode of "F5" + return e.keyCode === 116; +} + +window.addEventListener('message', handlePostMessage); +window.addEventListener('dragenter', handleInnerDragStartEvent); +window.addEventListener('dragover', handleInnerDragStartEvent); +window.addEventListener('scroll', handleInnerScroll); +window.addEventListener('wheel', handleWheel); +window.addEventListener('auxclick', handleAuxClick); +window.addEventListener('keydown', handleInnerKeydown); +window.addEventListener('keyup', handleInnerKeyup); +window.addEventListener('contextmenu', (e) => { + if (e.defaultPrevented) { + // Extension code has already handled this event + return; + } + + e.preventDefault(); + + /** @type { Record} */ + let context = {}; + + /** @type {HTMLElement | null} */ + let el = e.target; + while (true) { + if (!el) { + break; + } + + // Search self/ancestors for the closest context data attribute + el = el.closest("[data-vscode-context]"); + if (!el) { + break; + } + + try { + context = { + ...JSON.parse(el.dataset.vscodeContext), + ...context, + }; + } catch (e) { + console.error( + `Error parsing 'data-vscode-context' as json`, + el, + e, + ); + } + + el = el.parentElement; + } + + hostMessaging.postMessage('did-context-menu', { + clientX: e.clientX, + clientY: e.clientY, + context: context, + }); +}); + +// Ask Positron to open a link instead of handling it internally +function openLinkInHost(link) { + link.addEventListener('click', function (event) { + hostMessaging.postMessage('did-click-link', { uri: link.href }); + event.preventDefault(); + return false; + }); +} + +// When the window loads, look for all links and add a click handler to each +// external link (i.e. links that point to a different origin) that will ask +// Positron to open them instead of handling them internally. +window.addEventListener('load', () => { + const links = document.getElementsByTagName('a'); + const origin = window.location.origin; + for (let i = 0; i < links.length; i++) { + const link = links[i]; + if (link.href && !link.href.startsWith(origin)) { + openLinkInHost(link); + } + } + + // Notify the host that the webview has loaded its content + hostMessaging.postMessage('did-load-window', { + title: document.title, + }); +}); + +// Override the prompt function to return the default value or 'Untitled' if one isnt provided. +// This is needed because the prompt function is not supported in webviews and the prompt function +// is commonly used by libraries like bokeh to provide names for files to save. The main file save +// dialog that positron shows will already provide the ability to change the file name so we're +// just providing a default value here. +window.prompt = (message, _default) => { + return _default ?? 'Untitled'; +}; + +// Override the window.open function to send a message to the host to open the link instead. +// Save the old window.open function so we can call it after sending the message in case there's +// some other behavior that was depended upon that we're not aware of. +const oldOpen = window.open; +window.open = (url, target, features) => { + const uri = url instanceof URL ? url.href : url; + hostMessaging.postMessage('did-click-link', { uri }); + return oldOpen(uri, target, features); +}; diff --git a/extensions/positron-proxy/src/htmlProxy.ts b/extensions/positron-proxy/src/htmlProxy.ts index 5faa19168fe..288086867e4 100644 --- a/extensions/positron-proxy/src/htmlProxy.ts +++ b/extensions/positron-proxy/src/htmlProxy.ts @@ -3,13 +3,14 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; import express from 'express'; import path = require('path'); import fs = require('fs'); import { Disposable, Uri } from 'vscode'; -import { PromiseHandles } from './util'; -import { isAddressInfo } from './positronProxy'; +import { injectPreviewResources, PromiseHandles } from './util'; +import { isAddressInfo, ProxyServerHtml } from './types'; /** * HtmlProxyServer class. @@ -44,7 +45,10 @@ export class HtmlProxyServer implements Disposable { * to the URL. * @returns A URL that serves the content at the specified path. */ - public async createHtmlProxy(targetPath: string): Promise { + public async createHtmlProxy( + targetPath: string, + htmlConfig?: ProxyServerHtml + ): Promise { // Wait for the server to be ready. await this._ready.promise; @@ -82,7 +86,24 @@ export class HtmlProxyServer implements Disposable { } // Create a new path entry. - this._app.use(`/${serverPath}`, express.static(targetPath)); + if (vscode.env.uiKind !== vscode.UIKind.Web) { + this._app.use(`/${serverPath}`, express.static(targetPath)); + } else { + // If we're running in the web, we need to inject resources for the preview HTML. + this._app.use(`/${serverPath}`, async (req, res, next) => { + const filePath = path.join(targetPath, req.path); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + let content = fs.readFileSync(filePath, 'utf8'); + // If there is an HTML configuration, use it to rewrite the content. + if (htmlConfig) { + content = injectPreviewResources(content, htmlConfig); + } + res.send(content); + } else { + next(); + } + }); + } const address = this._server.address(); if (!isAddressInfo(address)) { throw new Error(`Server address is not available; cannot serve ${targetPath}`); diff --git a/extensions/positron-proxy/src/positronProxy.ts b/extensions/positron-proxy/src/positronProxy.ts index a02809209f5..cc0fb2a06af 100644 --- a/extensions/positron-proxy/src/positronProxy.ts +++ b/extensions/positron-proxy/src/positronProxy.ts @@ -7,13 +7,14 @@ import * as vscode from 'vscode'; import fs = require('fs'); import path = require('path'); import express from 'express'; -import { AddressInfo, Server } from 'net'; +import { Server } from 'net'; import { log, ProxyServerStyles } from './extension'; // eslint-disable-next-line no-duplicate-imports import { Disposable, ExtensionContext } from 'vscode'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import { HtmlProxyServer } from './htmlProxy'; -import { htmlContentRewriter, rewriteUrlsWithProxyPath } from './util'; +import { helpContentRewriter, htmlContentRewriter } from './util'; +import { ContentRewriter, isAddressInfo, MaybeAddressInfo, PendingProxyServer, ProxyServerHtml, ProxyServerHtmlConfig, ProxyServerType } from './types'; /** * Constants. @@ -25,6 +26,7 @@ const HOST = 'localhost'; * + * Noted in the resources/scripts_{TYPE}.html files. * @param script The script. * @param id The element id. * @returns The element, if found; otherwise, undefined. @@ -37,6 +39,7 @@ const getStyleElement = (script: string, id: string) => * + * Noted in the resources/scripts_{TYPE}.html files. * @param script The script. * @param id The element id. * @returns The element, if found; otherwise, undefined. @@ -44,43 +47,6 @@ const getStyleElement = (script: string, id: string) => const getScriptElement = (script: string, id: string) => script.match(new RegExp(` - - - `); } diff --git a/src/vs/workbench/contrib/webview/browser/pre/webview-events.js b/src/vs/workbench/contrib/webview/browser/pre/webview-events.js index d739173f2d8..22de2b75b2b 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/webview-events.js +++ b/src/vs/workbench/contrib/webview/browser/pre/webview-events.js @@ -8,12 +8,17 @@ * door. Its job is to absorb events from the inner iframe and forward them to * the host as window messages. * - * This allows the host to dispatach events that can't be handled natively in + * This allows the host to dispatch events that can't be handled natively in * the frame on Electron, such as copy/cut/paste commands and context menus. * * The other side of the communication is in `index-external.html`; it receives * messages sent from this file and forwards them to the webview host, where * they are processed and dispatched. + * + * NOTE: Please propagate updates from this file to extensions/positron-proxy/resources/webview-events.js + * if they are relevant. The Positron Proxy copy of this file contains some modifications to handle + * events in a web browser context (as opposed to an Electron context, which this file is + * involved in). */ /** diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 8e99cc16e3d..565045b972f 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -210,6 +210,21 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD })); this._register(this.on('onmessage', ({ message, transfer }) => { + // --- Start Positron --- + // If the message has the __positron_preview_message flag, we can unwrap it and send it + // directly to the webview instead of processing it as a generic message. This is similar + // to the onmessage handling in src/vs/workbench/contrib/positronHelp/browser/helpEntry.ts + if (message.__positron_preview_message) { + const handlers = this._messageHandlers.get(message.channel); + if (handlers) { + handlers.forEach(handler => handler(message.data, message)); + return; + } else { + this._logService.error(`No handlers found for Positron Preview message: '${message.channel}'`); + // Fall through to fire the generic message event + } + } + // --- End Positron --- this._onMessage.fire({ message, transfer }); }));