From c49028f430ea719890990537f0dd29e0b5c8043b Mon Sep 17 00:00:00 2001 From: George Stagg Date: Fri, 21 Jun 2024 10:00:04 +0100 Subject: [PATCH 1/5] Resize canvas device in empty environment Avoids leaking `devices` and `idx` into the global environment. --- src/repl/components/Plot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repl/components/Plot.tsx b/src/repl/components/Plot.tsx index bbb890b7..d4d41de1 100644 --- a/src/repl/components/Plot.tsx +++ b/src/repl/components/Plot.tsx @@ -56,7 +56,7 @@ export function Plot({ } # Set canvas size for future devices options(webr.fig.width = ${plotSize.current.width}, webr.fig.height = ${plotSize.current.height}) - `); + `, { env: {} }); }); }; }, [plotInterface]); From e6ee74003a9ce8c47410b001fc51153cf4084e1b Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jun 2024 08:39:18 +0100 Subject: [PATCH 2/5] Switch to COEP: credentialless --- src/esbuild.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/esbuild.ts b/src/esbuild.ts index 2ff93a32..0b6be698 100644 --- a/src/esbuild.ts +++ b/src/esbuild.ts @@ -86,7 +86,7 @@ if (serve) { res.writeHead(proxyRes.statusCode!, { ...proxyRes.headers, 'cross-origin-opener-policy': 'same-origin', - 'cross-origin-embedder-policy': 'require-corp', + 'cross-origin-embedder-policy': 'credentialless', 'cross-origin-resource-policy': 'cross-origin', }); proxyRes.pipe(res, { end: true }); From 3e924785a826234fe67b411e031c4a7e034c2722 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jun 2024 09:07:03 +0100 Subject: [PATCH 3/5] Add viewer output message to webr support package --- packages/webr/NAMESPACE | 1 + packages/webr/R/viewer.R | 23 +++++++++++++++++++++++ packages/webr/man/viewer_install.Rd | 15 +++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 packages/webr/R/viewer.R create mode 100644 packages/webr/man/viewer_install.Rd diff --git a/packages/webr/NAMESPACE b/packages/webr/NAMESPACE index 25c1ee09..e9e3367f 100644 --- a/packages/webr/NAMESPACE +++ b/packages/webr/NAMESPACE @@ -17,4 +17,5 @@ export(shim_install) export(syncfs) export(test_package) export(unmount) +export(viewer_install) useDynLib(webr, .registration = TRUE) diff --git a/packages/webr/R/viewer.R b/packages/webr/R/viewer.R new file mode 100644 index 00000000..69d67629 --- /dev/null +++ b/packages/webr/R/viewer.R @@ -0,0 +1,23 @@ +#' Generate an output message when a URL is browsed to +#' +#' @description +#' When enabled, the R `viewer` option is set so that a request to display +#' a URL generates a webR output message. The request is forwarded to the main +#' thread to be handled by the application loading webR. +#' +#' This does the equivalent of the base R function `utils::browseURL()`. +#' +#' @export +viewer_install <- function() { + options( + viewer = function(url, ...) { + webr::eval_js(paste0( + "chan.write({", + " type: 'browse',", + " data: { url: '", url, "' },", + "});" + )) + invisible(NULL) + } + ) +} diff --git a/packages/webr/man/viewer_install.Rd b/packages/webr/man/viewer_install.Rd new file mode 100644 index 00000000..80be1a2f --- /dev/null +++ b/packages/webr/man/viewer_install.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/viewer.R +\name{viewer_install} +\alias{viewer_install} +\title{Generate an output message when a URL is browsed to} +\usage{ +viewer_install() +} +\description{ +When enabled, the R \code{viewer} option is set so that a request to display +a URL generates a webR output message. The request is forwarded to the main +thread to be handled by the application loading webR. + +This does the equivalent of the base R function \code{utils::browseURL()}. +} From d9a6f67536c64cbd812744989125cf06eb8461b1 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jun 2024 09:12:37 +0100 Subject: [PATCH 4/5] Add HTML widget viewer to webR application --- src/repl/App.tsx | 72 ++++++++++++++- src/repl/components/Editor.css | 11 +++ src/repl/components/Editor.tsx | 164 ++++++++++++++++++++++----------- src/webR/utils.ts | 11 +++ src/webR/webr-chan.ts | 5 + 5 files changed, 205 insertions(+), 58 deletions(-) diff --git a/src/repl/App.tsx b/src/repl/App.tsx index 7e9fcd50..5e951443 100644 --- a/src/repl/App.tsx +++ b/src/repl/App.tsx @@ -6,7 +6,8 @@ import Plot from './components/Plot'; import Files from './components/Files'; import { Readline } from 'xterm-readline'; import { WebR } from '../webR/webr-main'; -import { CanvasMessage, PagerMessage, ViewMessage } from '../webR/webr-chan'; +import { bufferToBase64 } from '../webR/utils'; +import { CanvasMessage, PagerMessage, ViewMessage, BrowseMessage } from '../webR/webr-chan'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import './App.css'; import { NamedObject, WebRDataJsAtomic } from '../webR/robj'; @@ -32,6 +33,7 @@ export interface FilesInterface { refreshFilesystem: () => Promise; openFileInEditor: (name: string, path: string, readOnly: boolean) => Promise; openDataInEditor: (title: string, data: NamedObject> ) => void; + openHtmlInEditor: (src: string, path: string) => void; } export interface PlotInterface { @@ -50,6 +52,7 @@ const filesInterface: FilesInterface = { refreshFilesystem: () => Promise.resolve(), openFileInEditor: () => { throw new Error('Unable to open file, editor not initialised.'); }, openDataInEditor: () => { throw new Error('Unable to view data, editor not initialised.'); }, + openHtmlInEditor: () => { throw new Error('Unable to view HTML, editor not initialised.'); }, }; const plotInterface: PlotInterface = { @@ -76,6 +79,64 @@ async function handlePagerMessage(msg: PagerMessage) { } } +async function handleBrowseMessage(msg: BrowseMessage) { + const { url } = msg.data; + const root = url.split('/').slice(0, -1).join('/'); + const decoder = new TextDecoder('utf8'); + let content = decoder.decode(await webR.FS.readFile(url)); + + // Replace relative URLs in HTML output with the contents of the VFS. + /* TODO: This should really be handled by a custom print method sending the + * entire R object reference to the main thread, rather than performing + * regex on HTML -- famously a bad idea because HTML is context-free. + * Saying that, this does seem to work reasonably well for now. + * + * Since we don't load the `webr` support package by default, the + * alternative looks to be using hacks to register a bunch of custom S3 + * generics like `print.htmlwidget` in the "webr_shim" namespace, and + * then maintain the `search()` order as other packages are loaded so + * that our namespace is always at the front, messy. + */ + const jsRegex = /.*<\/script>/g; + const jsMatches = Array.from(content.matchAll(jsRegex) || []); + const jsContent: {[idx: number]: string} = {}; + await Promise.all(jsMatches.map((match, idx) => { + return webR.FS.readFile(`${root}/${match[1]}`) + .then((file) => bufferToBase64(file)) + .then((enc) => { + jsContent[idx] = "data:text/javascript;base64," + enc; + }); + })); + jsMatches.forEach((match, idx) => { + content = content.replace(match[0], ` + + `); + }); + + let injectedBaseStyle = false; + const cssBaseStyle = ``; + const cssRegex = //g; + const cssMatches = Array.from(content.matchAll(cssRegex) || []); + const cssContent: {[idx: number]: string} = {}; + await Promise.all(cssMatches.map((match, idx) => { + return webR.FS.readFile(`${root}/${match[1]}`) + .then((file) => bufferToBase64(file)) + .then((enc) => { + cssContent[idx] = "data:text/css;base64," + enc; + }); + })); + cssMatches.forEach((match, idx) => { + let cssHtml = ``; + if (!injectedBaseStyle){ + cssHtml = cssBaseStyle + cssHtml; + injectedBaseStyle = true; + } + content = content.replace(match[0], cssHtml); + }); + + filesInterface.openHtmlInEditor(content, url); +} + function handleViewMessage(msg: ViewMessage) { const { title, data } = msg.data; filesInterface.openDataInEditor(title, data); @@ -119,7 +180,8 @@ root.render(); void (async () => { await webR.init(); - // Set the default graphics device and pager + // Set the default graphics device, browser, and pager + await webR.evalRVoid('webr::viewer_install()'); await webR.evalRVoid('webr::pager_install()'); await webR.evalRVoid(` webr::canvas_install( @@ -137,6 +199,9 @@ void (async () => { await webR.evalRVoid('options(webr.show_menu = show_menu)', { env: { show_menu: !!showMenu } }); await webR.evalRVoid('webr::global_prompt_install()', { withHandlers: false }); + // Additional options for running packages under wasm + await webR.evalRVoid('options(rgl.printRglwidget = TRUE)'); + // Clear the loading message terminalInterface.write('\x1b[2K\r'); @@ -167,6 +232,9 @@ void (async () => { case 'view': handleViewMessage(output as ViewMessage); break; + case 'browse': + void handleBrowseMessage(output as BrowseMessage); + break; case 'closed': throw new Error('The webR communication channel has been closed'); default: diff --git a/src/repl/components/Editor.css b/src/repl/components/Editor.css index 871cc874..68266d43 100644 --- a/src/repl/components/Editor.css +++ b/src/repl/components/Editor.css @@ -112,3 +112,14 @@ .d-none { display: none !important; } + +.html-viewer-container { + width: 100%; + height: 100%; +} + +iframe.html-viewer { + width: 100%; + height: 100%; + border: none; +} diff --git a/src/repl/components/Editor.tsx b/src/repl/components/Editor.tsx index 4a714c26..b698e430 100644 --- a/src/repl/components/Editor.tsx +++ b/src/repl/components/Editor.tsx @@ -18,28 +18,32 @@ import './Editor.css'; const language = new Compartment(); const tabSize = new Compartment(); -export type EditorFile = { - name: string; +type EditorBase = { name: string, readOnly: boolean }; +type EditorData = EditorBase & { + type: "data", + data: { + columns: { key: string, name: string }[]; + rows: { [key: string]: string }[]; + } +}; + +type EditorHtml = EditorBase & { path: string; - type: "script" | "text" | "data", + type: "html", readOnly: boolean, - ref: { - editorState: EditorState; - scrollTop?: number; - scrollLeft?: number; - data?: { - columns: { - key: string; - name: string; - }[]; - rows: { - [key: string]: string; - }[]; - } - } + frame: HTMLIFrameElement, }; -const emptyState = EditorState.create(); +type EditorFile = EditorBase & { + path: string; + type: "text", + readOnly: boolean, + editorState: EditorState; + scrollTop?: number; + scrollLeft?: number; +}; + +export type EditorItem = EditorData | EditorHtml | EditorFile; export function FileTabs({ files, @@ -47,7 +51,7 @@ export function FileTabs({ setActiveFileIdx, closeFile }: { - files: EditorFile[]; + files: EditorItem[]; activeFileIdx: number; setActiveFileIdx: React.Dispatch>; closeFile: (e: React.SyntheticEvent, index: number) => void; @@ -107,16 +111,18 @@ export function Editor({ filesInterface: FilesInterface; }) { const editorRef = React.useRef(null); + const htmlRef = React.useRef(null); const [editorView, setEditorView] = React.useState(); - const [files, setFiles] = React.useState([]); + const [files, setFiles] = React.useState([]); const [activeFileIdx, setActiveFileIdx] = React.useState(0); const runSelectedCode = React.useRef((): void => { throw new Error('Unable to run code, webR not initialised.'); }); const activeFile = files[activeFileIdx]; - const isScript = activeFile && activeFile.type === "script"; + const isScript = activeFile && activeFile.type === "text" && activeFile.path.endsWith('.R'); const isData = activeFile && activeFile.type === "data"; + const isHtml = activeFile && activeFile.type === "html"; const isReadOnly = activeFile && activeFile.readOnly; const completionMethods = React.useRef { e.stopPropagation(); + const item = files[index]; + if (item.type === "html") { + item.frame.remove(); + } + const updatedFiles = [...files]; updatedFiles.splice(index, 1); setFiles(updatedFiles); - const prevFile = activeFileIdx - 1; - setActiveFileIdx(prevFile < 0 ? 0 : prevFile); + if (index <= activeFileIdx) { + const prevFile = activeFileIdx - 1; + setActiveFileIdx(prevFile < 0 ? 0 : prevFile); + } }; React.useEffect(() => { @@ -228,9 +241,11 @@ export function Editor({ if (!editorView || !activeFile) { return; } - activeFile.ref.editorState = editorView.state; - activeFile.ref.scrollTop = editorView.scrollDOM.scrollTop; - activeFile.ref.scrollLeft = editorView.scrollDOM.scrollLeft; + if (activeFile.type === "text") { + activeFile.editorState = editorView.state; + activeFile.scrollTop = editorView.scrollDOM.scrollTop; + activeFile.scrollLeft = editorView.scrollDOM.scrollLeft; + } }, [activeFile, editorView]); const runFile = React.useCallback(() => { @@ -251,7 +266,7 @@ export function Editor({ }, [syncActiveFileState, editorView]); const saveFile: React.MouseEventHandler = React.useCallback(() => { - if (!editorView) { + if (!editorView || activeFile.type !== "text") { return; } @@ -281,11 +296,9 @@ export function Editor({ setFiles([{ name: 'Untitled1.R', path: '/home/web_user/Untitled1.R', - type: 'script', + type: 'text', readOnly: false, - ref: { - editorState: state, - } + editorState: state, }]); return function cleanup() { @@ -299,6 +312,13 @@ export function Editor({ */ React.useEffect(() => { filesInterface.openDataInEditor = (title: string, data: NamedObject>) => { + // If data is there switch to that tab instead + const existsIndex = files.findIndex((f) => f.name === title); + if (existsIndex >= 0) { + setActiveFileIdx(existsIndex); + return; + } + syncActiveFileState(); const columns = Object.keys(data).map((key) => { @@ -313,13 +333,29 @@ export function Editor({ const updatedFiles = [...files]; const index = updatedFiles.push({ name: title, - path: `/tmp/${title}.tmp`, type: "data", readOnly: true, - ref: { - editorState: emptyState, - data: { columns, rows } - }, + data: { columns, rows } + }); + setFiles(updatedFiles); + setActiveFileIdx(index - 1); + }; + + filesInterface.openHtmlInEditor = (src: string, path: string) => { + syncActiveFileState(); + + const frame = document.createElement('iframe'); + frame.srcdoc = src; + frame.className = "html-viewer"; + htmlRef.current!.appendChild(frame); + + const updatedFiles = [...files]; + const index = updatedFiles.push({ + name: 'Viewer', + path, + type: "html", + readOnly: true, + frame, }); setFiles(updatedFiles); setActiveFileIdx(index - 1); @@ -327,7 +363,7 @@ export function Editor({ filesInterface.openFileInEditor = (name: string, path: string, readOnly: boolean) => { // Don't reopen the file if it's already open, switch to that tab instead - const existsIndex = files.findIndex((f) => f.path === path); + const existsIndex = files.findIndex((f) => "path" in f && f.path === path); if (existsIndex >= 0) { setActiveFileIdx(existsIndex); return Promise.resolve(); @@ -349,14 +385,12 @@ export function Editor({ const index = updatedFiles.push({ name, path, - type: name.endsWith('.R') ? "script" : "text", + type: "text", readOnly, - ref: { - editorState: EditorState.create({ - doc: content, - extensions, - }), - } + editorState: EditorState.create({ + doc: content, + extensions, + }), }); setFiles(updatedFiles); setActiveFileIdx(index - 1); @@ -364,19 +398,33 @@ export function Editor({ }; }, [files, filesInterface]); + React.useEffect(() => { + if (activeFile && activeFile.type === "html") { + activeFile.frame.classList.remove("d-none"); + } + // Before switching activeFile, hide this HTML + return function cleanup() { + if (activeFile && activeFile.type === "html") { + activeFile.frame.classList.add("d-none"); + } + }; + }, [activeFile]); + React.useEffect(() => { if (!editorView || files.length === 0) { return; } // Update the editor's state and scroll position for currently active file - editorView.setState(activeFile.ref.editorState); - editorView.requestMeasure({ - read: () => { - editorView.scrollDOM.scrollTop = activeFile.ref.scrollTop ?? 0; - editorView.scrollDOM.scrollLeft = activeFile.ref.scrollLeft ?? 0; - return editorView.domAtPos(0).node; - } - }); + if (activeFile.type === "text") { + editorView.setState(activeFile.editorState); + editorView.requestMeasure({ + read: () => { + editorView.scrollDOM.scrollTop = activeFile.scrollTop ?? 0; + editorView.scrollDOM.scrollLeft = activeFile.scrollLeft ?? 0; + return editorView.domAtPos(0).node; + } + }); + } // Update accessibility labelling const container = editorView.contentDOM.parentElement; @@ -409,7 +457,7 @@ export function Editor({
@@ -419,11 +467,11 @@ export function Editor({ To move focus away from the editor, press the Escape key, and then press the Tab key directly after it. Escape and then Shift-Tab can also be used to move focus backwards.

- {(isData && activeFile.ref.data) && + {(isData && activeFile.data) && } +
Date: Mon, 24 Jun 2024 09:20:04 +0100 Subject: [PATCH 5/5] Update NEWS.md --- NEWS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NEWS.md b/NEWS.md index a014d34d..7fab23bc 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,10 @@ * The R `View()` command now invokes a simple data grid viewer in the webR application. +* A function `viewer_install()` is added to the webR support package. The function sets up R so as to generate an output message over the webR communication channel when a URL viewer is invoked (#295). + +* Printing a HTML element or HTML widget in the webR application app now shows the HTML content in an embedded viewer `iframe` (#384, #431). With thanks to @timelyportfolio for the basic [implementation method](https://www.jsinr.me/2024/01/10/selfcontained-htmlwidgets/). + ## Breaking changes * The `ServiceWorker` communication channel has been deprecated. Users should use the `SharedArrayBuffer` channel where cross-origin isolation is possible, or otherwise use the `PostMessage` channel. For the moment the `ServiceWorker` channel can still be used, but emits a warning at start up. The channel will be removed entirely in a future version of webR.