Skip to content

Commit

Permalink
A HTML widget viewer for the webR application, similar to `browseURL(…
Browse files Browse the repository at this point in the history
…)` (#449)

* Resize canvas device in empty environment

Avoids leaking `devices` and `idx` into the global environment.

* Switch to COEP: credentialless

* Add viewer output message to webr support package

* Add HTML widget viewer to webR application

* Update NEWS.md
  • Loading branch information
georgestagg authored Jun 24, 2024
1 parent 6e7ac09 commit f2f079a
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 60 deletions.
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/webr/NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export(shim_install)
export(syncfs)
export(test_package)
export(unmount)
export(viewer_install)
useDynLib(webr, .registration = TRUE)
23 changes: 23 additions & 0 deletions packages/webr/R/viewer.R
Original file line number Diff line number Diff line change
@@ -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)
}
)
}
15 changes: 15 additions & 0 deletions packages/webr/man/viewer_install.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
72 changes: 70 additions & 2 deletions src/repl/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +33,7 @@ export interface FilesInterface {
refreshFilesystem: () => Promise<void>;
openFileInEditor: (name: string, path: string, readOnly: boolean) => Promise<void>;
openDataInEditor: (title: string, data: NamedObject<WebRDataJsAtomic<string>> ) => void;
openHtmlInEditor: (src: string, path: string) => void;
}

export interface PlotInterface {
Expand All @@ -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 = {
Expand All @@ -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.*src=["'`](.+\.js)["'`].*>.*<\/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], `
<script type="text/javascript" src="${jsContent[idx]}"></script>
`);
});

let injectedBaseStyle = false;
const cssBaseStyle = `<style>body{font-family: sans-serif;}</style>`;
const cssRegex = /<link.*href=["'`](.+\.css)["'`].*>/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 = `<link rel="stylesheet" href="${cssContent[idx]}"/>`;
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);
Expand Down Expand Up @@ -119,7 +180,8 @@ root.render(<StrictMode><App /></StrictMode>);
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(
Expand All @@ -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');

Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions src/repl/components/Editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit f2f079a

Please sign in to comment.