Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A HTML widget viewer for the webR application, similar to browseURL() #449

Merged
merged 5 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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