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.
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()}.
+}
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 });
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.