Skip to content

Commit 88c3fb8

Browse files
authored
webR App: Load and update encoded share URLs (#554)
* REPL: Show unsaved file indicator * REPL: Focus on editor when changing tabs * REPL: Update share code in URL as files are modified * Update files when hash changes * Refactor sharing functionality * Keep linter happy * Add sharing modal * Fix openFileInEditor options in Files.tsx * Fix open multiple files in editor simultaneously * Minor tweak for stability * Add postMessage functionality * Update NEWS.md * Show share data during initial load * Update embed example * Support JSON and uncompressed share strings
1 parent 2297c39 commit 88c3fb8

File tree

10 files changed

+668
-134
lines changed

10 files changed

+668
-134
lines changed

NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
* (Regression, again) Fix linking to the FreeType library when building webR (See #504 for details).
66

7+
## New features
8+
9+
* Support sharing URLs and initial editor file population in the webR application. See `src/examples/embed/` for an example of iframe embedding with `postMessage()`. (#554)
10+
711
# webR 0.5.3
812

913
## Bug Fixes

src/examples/embed/embed.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const encoder = new TextEncoder();
2+
const dataFile = encoder.encode(`x, y
3+
-3, 9
4+
-2, 4
5+
-1, 1
6+
0, 0
7+
1, 1
8+
2, 4
9+
3, 9
10+
`);
11+
const scriptFile = encoder.encode(`data <- read.csv("data.csv")
12+
plot(data, type = 'l')
13+
`);
14+
15+
const iframe = document.getElementById('ex2');
16+
iframe.addEventListener("load", function () {
17+
iframe.contentWindow.postMessage({
18+
items: [
19+
{
20+
name: 'example.R',
21+
path: '/home/web_user/example.R',
22+
data: scriptFile,
23+
},
24+
{
25+
name: 'data.csv',
26+
path: '/home/web_user/data.csv',
27+
data: dataFile,
28+
}
29+
]
30+
}, '*');
31+
});

src/examples/embed/index.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<html>
2+
3+
<head>
4+
<title>WebR App Embedding Example</title>
5+
</head>
6+
7+
<body>
8+
<p>The webR application will be embedded below.</p>
9+
<p>
10+
In the first example initial file data is populated via the URL.
11+
In the second example initial file data is populated using a JavaScript <code>postMessage()</code> command.
12+
</p>
13+
<div style="height: 90%; display: flex;">
14+
<iframe id="ex1" width="50%" src="https://webr.r-wasm.org/latest/#code=eJyb2LwkLzE3dWVqRWJuQU6qXtCSgsSSjB36Gfm5qfrlqUnxpcWpRfoI2ZTEksQj4gVFmXklGkrFGYlFqSkKZZmJCqFBPkqaAOxxHjs%3D"></iframe>
15+
<iframe id="ex2" width="50%" src="https://webr.r-wasm.org/latest/"></iframe>
16+
</div>
17+
<script src="./embed.js"></script>
18+
</body>
19+
20+
</html>

src/repl/App.tsx

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { StrictMode } from 'react';
22
import ReactDOM from 'react-dom/client';
33
import Terminal from './components/Terminal';
4-
import Editor from './components/Editor';
4+
import Editor, { EditorItem } from './components/Editor';
55
import Plot from './components/Plot';
66
import Files from './components/Files';
77
import { Readline } from 'xterm-readline';
@@ -11,6 +11,7 @@ import { CanvasMessage, PagerMessage, ViewMessage, BrowseMessage } from '../webR
1111
import { Panel, PanelGroup, PanelResizeHandle, ImperativePanelHandle } from 'react-resizable-panels';
1212
import './App.css';
1313
import { NamedObject, WebRDataJsAtomic } from '../webR/robj';
14+
import { decodeShareData, isShareItems, ShareItem } from './components/Share';
1415

1516
const webR = new WebR({
1617
RArgs: [],
@@ -22,6 +23,7 @@ const webR = new WebR({
2223
},
2324
});
2425
(globalThis as any).webR = webR;
26+
const encoder = new TextEncoder();
2527

2628
export interface TerminalInterface {
2729
println: Readline['println'];
@@ -31,8 +33,9 @@ export interface TerminalInterface {
3133

3234
export interface FilesInterface {
3335
refreshFilesystem: () => Promise<void>;
34-
openFileInEditor: (name: string, path: string, readOnly: boolean) => Promise<void>;
35-
openDataInEditor: (title: string, data: NamedObject<WebRDataJsAtomic<string>> ) => void;
36+
openFilesInEditor: (openFiles: { name: string, path: string, readOnly?: boolean, forceRead?: boolean }[], replace?: boolean) => Promise<void>;
37+
openContentInEditor: (openFiles: { name: string, content: Uint8Array }[], replace?: boolean) => void;
38+
openDataInEditor: (title: string, data: NamedObject<WebRDataJsAtomic<string>>) => void;
3639
openHtmlInEditor: (src: string, path: string) => void;
3740
}
3841

@@ -50,7 +53,8 @@ const terminalInterface: TerminalInterface = {
5053

5154
const filesInterface: FilesInterface = {
5255
refreshFilesystem: () => Promise.resolve(),
53-
openFileInEditor: () => { throw new Error('Unable to open file, editor not initialised.'); },
56+
openFilesInEditor: () => { throw new Error('Unable to open file(s), editor not initialised.'); },
57+
openContentInEditor: () => { throw new Error('Unable to show content, editor not initialised.'); },
5458
openDataInEditor: () => { throw new Error('Unable to view data, editor not initialised.'); },
5559
openHtmlInEditor: () => { throw new Error('Unable to view HTML, editor not initialised.'); },
5660
};
@@ -73,7 +77,7 @@ function handleCanvasMessage(msg: CanvasMessage) {
7377

7478
async function handlePagerMessage(msg: PagerMessage) {
7579
const { path, title, deleteFile } = msg.data;
76-
await filesInterface.openFileInEditor(title, path, true);
80+
await filesInterface.openFilesInEditor([{ name: title, path, readOnly: true }]);
7781
if (deleteFile) {
7882
await webR.FS.unlink(path);
7983
}
@@ -99,7 +103,7 @@ async function handleBrowseMessage(msg: BrowseMessage) {
99103
*/
100104
const jsRegex = /<script.*src=["'`](.+\.js)["'`].*>.*<\/script>/g;
101105
const jsMatches = Array.from(content.matchAll(jsRegex) || []);
102-
const jsContent: {[idx: number]: string} = {};
106+
const jsContent: { [idx: number]: string } = {};
103107
await Promise.all(jsMatches.map((match, idx) => {
104108
return webR.FS.readFile(`${root}/${match[1]}`)
105109
.then((file) => bufferToBase64(file))
@@ -117,7 +121,7 @@ async function handleBrowseMessage(msg: BrowseMessage) {
117121
const cssBaseStyle = `<style>body{font-family: sans-serif;}</style>`;
118122
const cssRegex = /<link.*href=["'`](.+\.css)["'`].*>/g;
119123
const cssMatches = Array.from(content.matchAll(cssRegex) || []);
120-
const cssContent: {[idx: number]: string} = {};
124+
const cssContent: { [idx: number]: string } = {};
121125
await Promise.all(cssMatches.map((match, idx) => {
122126
return webR.FS.readFile(`${root}/${match[1]}`)
123127
.then((file) => bufferToBase64(file))
@@ -127,7 +131,7 @@ async function handleBrowseMessage(msg: BrowseMessage) {
127131
}));
128132
cssMatches.forEach((match, idx) => {
129133
let cssHtml = `<link rel="stylesheet" href="${cssContent[idx]}"/>`;
130-
if (!injectedBaseStyle){
134+
if (!injectedBaseStyle) {
131135
cssHtml = cssBaseStyle + cssHtml;
132136
injectedBaseStyle = true;
133137
}
@@ -148,36 +152,89 @@ const onPanelResize = (size: number) => {
148152

149153
function App() {
150154
const rightPanelRef = React.useRef<ImperativePanelHandle | null>(null);
155+
156+
async function applyShareData(items: ShareItem[]): Promise<void> {
157+
// Write files to VFS
158+
await webR.init();
159+
await Promise.all(items.map(async (item) => {
160+
return webR.FS.writeFile(item.path, item.data ? item.data : encoder.encode(item.text));
161+
}));
162+
163+
// Load saved files into editor
164+
void filesInterface.refreshFilesystem();
165+
void filesInterface.openFilesInEditor(items.map((item) => ({
166+
name: item.name,
167+
path: item.path,
168+
forceRead: true
169+
})), true);
170+
}
171+
172+
function applyShareHash(hash: string): void {
173+
const shareHash = hash.match(/(code)=([^&]+)(?:&(\w+))?/);
174+
if (shareHash && shareHash[1] === 'code') {
175+
const items = decodeShareData(shareHash[2], shareHash[3]);
176+
177+
// Load initial content into editor
178+
void filesInterface.openContentInEditor(items.map((item) => ({
179+
name: item.name,
180+
content: item.data ? item.data : encoder.encode(item.text)
181+
})), true);
182+
183+
void applyShareData(items);
184+
}
185+
}
186+
151187
React.useEffect(() => {
152188
window.addEventListener("resize", () => {
153189
if (!rightPanelRef.current) return;
154190
onPanelResize(rightPanelRef.current.getSize());
155191
});
192+
193+
// Show share content whenever URL hash code changes
194+
window.addEventListener("hashchange", (event: HashChangeEvent) => {
195+
const url = new URL(event.newURL);
196+
applyShareHash(url.hash);
197+
});
198+
199+
// Listen for messages containing shared files data. See `encodeShareData()` for details.
200+
window.addEventListener("message", (event: MessageEvent<{ items: EditorItem[] }>) => {
201+
const items = event.data.items;
202+
if (!isShareItems(items)) {
203+
throw new Error("Provided postMessage data does not contain a valid set of share files.");
204+
}
205+
void applyShareData(items);
206+
});
207+
}, []);
208+
209+
// Show share content on initial load
210+
React.useEffect(() => {
211+
const url = new URL(window.location.href);
212+
applyShareHash(url.hash);
156213
}, []);
157214

158215
return (
159216
<div className='repl'>
160-
<PanelGroup direction="horizontal">
161-
<Panel defaultSize={50} minSize={10}>
162-
<PanelGroup autoSaveId="conditional" direction="vertical">
163-
<Editor
164-
webR={webR}
165-
terminalInterface={terminalInterface}
166-
filesInterface={filesInterface}
167-
/>
168-
<PanelResizeHandle />
169-
<Terminal webR={webR} terminalInterface={terminalInterface} />
170-
</PanelGroup>
171-
</Panel>
172-
<PanelResizeHandle />
173-
<Panel ref={rightPanelRef} onResize={onPanelResize} minSize={10}>
174-
<PanelGroup direction="vertical">
175-
<Files webR={webR} filesInterface={filesInterface} />
176-
<PanelResizeHandle />
177-
<Plot webR={webR} plotInterface={plotInterface} />
178-
</PanelGroup>
179-
</Panel>
180-
</PanelGroup>
217+
<PanelGroup direction="horizontal">
218+
<Panel defaultSize={50} minSize={10}>
219+
<PanelGroup autoSaveId="conditional" direction="vertical">
220+
<Editor
221+
webR={webR}
222+
terminalInterface={terminalInterface}
223+
filesInterface={filesInterface}
224+
/>
225+
<PanelResizeHandle />
226+
<Terminal webR={webR} terminalInterface={terminalInterface} />
227+
</PanelGroup>
228+
</Panel>
229+
<PanelResizeHandle />
230+
<Panel ref={rightPanelRef} onResize={onPanelResize} minSize={10}>
231+
<PanelGroup direction="vertical">
232+
<Files webR={webR} filesInterface={filesInterface} />
233+
<PanelResizeHandle />
234+
<Plot webR={webR} plotInterface={plotInterface} />
235+
</PanelGroup>
236+
</Panel>
237+
</PanelGroup>
181238
</div>
182239
);
183240
}

0 commit comments

Comments
 (0)