diff --git a/NEWS.md b/NEWS.md
index f1c66a2f..07b422c0 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -4,6 +4,10 @@
* (Regression, again) Fix linking to the FreeType library when building webR (See #504 for details).
+## New features
+
+* 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)
+
# webR 0.5.3
## Bug Fixes
diff --git a/src/examples/embed/embed.js b/src/examples/embed/embed.js
new file mode 100644
index 00000000..25998927
--- /dev/null
+++ b/src/examples/embed/embed.js
@@ -0,0 +1,31 @@
+const encoder = new TextEncoder();
+const dataFile = encoder.encode(`x, y
+-3, 9
+-2, 4
+-1, 1
+ 0, 0
+ 1, 1
+ 2, 4
+ 3, 9
+`);
+const scriptFile = encoder.encode(`data <- read.csv("data.csv")
+plot(data, type = 'l')
+`);
+
+const iframe = document.getElementById('ex2');
+iframe.addEventListener("load", function () {
+ iframe.contentWindow.postMessage({
+ items: [
+ {
+ name: 'example.R',
+ path: '/home/web_user/example.R',
+ data: scriptFile,
+ },
+ {
+ name: 'data.csv',
+ path: '/home/web_user/data.csv',
+ data: dataFile,
+ }
+ ]
+ }, '*');
+});
diff --git a/src/examples/embed/index.html b/src/examples/embed/index.html
new file mode 100644
index 00000000..ff486188
--- /dev/null
+++ b/src/examples/embed/index.html
@@ -0,0 +1,20 @@
+
+
+
+ WebR App Embedding Example
+
+
+
+ The webR application will be embedded below.
+
+ In the first example initial file data is populated via the URL.
+ In the second example initial file data is populated using a JavaScript postMessage()
command.
+
+
+
+
+
+
+
+
+
diff --git a/src/repl/App.tsx b/src/repl/App.tsx
index 0ddb9363..8bff86ec 100644
--- a/src/repl/App.tsx
+++ b/src/repl/App.tsx
@@ -1,7 +1,7 @@
import React, { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import Terminal from './components/Terminal';
-import Editor from './components/Editor';
+import Editor, { EditorItem } from './components/Editor';
import Plot from './components/Plot';
import Files from './components/Files';
import { Readline } from 'xterm-readline';
@@ -11,6 +11,7 @@ import { CanvasMessage, PagerMessage, ViewMessage, BrowseMessage } from '../webR
import { Panel, PanelGroup, PanelResizeHandle, ImperativePanelHandle } from 'react-resizable-panels';
import './App.css';
import { NamedObject, WebRDataJsAtomic } from '../webR/robj';
+import { decodeShareData, isShareItems, ShareItem } from './components/Share';
const webR = new WebR({
RArgs: [],
@@ -22,6 +23,7 @@ const webR = new WebR({
},
});
(globalThis as any).webR = webR;
+const encoder = new TextEncoder();
export interface TerminalInterface {
println: Readline['println'];
@@ -31,8 +33,9 @@ export interface TerminalInterface {
export interface FilesInterface {
refreshFilesystem: () => Promise;
- openFileInEditor: (name: string, path: string, readOnly: boolean) => Promise;
- openDataInEditor: (title: string, data: NamedObject> ) => void;
+ openFilesInEditor: (openFiles: { name: string, path: string, readOnly?: boolean, forceRead?: boolean }[], replace?: boolean) => Promise;
+ openContentInEditor: (openFiles: { name: string, content: Uint8Array }[], replace?: boolean) => void;
+ openDataInEditor: (title: string, data: NamedObject>) => void;
openHtmlInEditor: (src: string, path: string) => void;
}
@@ -50,7 +53,8 @@ const terminalInterface: TerminalInterface = {
const filesInterface: FilesInterface = {
refreshFilesystem: () => Promise.resolve(),
- openFileInEditor: () => { throw new Error('Unable to open file, editor not initialised.'); },
+ openFilesInEditor: () => { throw new Error('Unable to open file(s), editor not initialised.'); },
+ openContentInEditor: () => { throw new Error('Unable to show content, 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.'); },
};
@@ -73,7 +77,7 @@ function handleCanvasMessage(msg: CanvasMessage) {
async function handlePagerMessage(msg: PagerMessage) {
const { path, title, deleteFile } = msg.data;
- await filesInterface.openFileInEditor(title, path, true);
+ await filesInterface.openFilesInEditor([{ name: title, path, readOnly: true }]);
if (deleteFile) {
await webR.FS.unlink(path);
}
@@ -99,7 +103,7 @@ async function handleBrowseMessage(msg: BrowseMessage) {
*/
const jsRegex = /.*<\/script>/g;
const jsMatches = Array.from(content.matchAll(jsRegex) || []);
- const jsContent: {[idx: number]: string} = {};
+ const jsContent: { [idx: number]: string } = {};
await Promise.all(jsMatches.map((match, idx) => {
return webR.FS.readFile(`${root}/${match[1]}`)
.then((file) => bufferToBase64(file))
@@ -117,7 +121,7 @@ async function handleBrowseMessage(msg: BrowseMessage) {
const cssBaseStyle = ``;
const cssRegex = //g;
const cssMatches = Array.from(content.matchAll(cssRegex) || []);
- const cssContent: {[idx: number]: string} = {};
+ const cssContent: { [idx: number]: string } = {};
await Promise.all(cssMatches.map((match, idx) => {
return webR.FS.readFile(`${root}/${match[1]}`)
.then((file) => bufferToBase64(file))
@@ -127,7 +131,7 @@ async function handleBrowseMessage(msg: BrowseMessage) {
}));
cssMatches.forEach((match, idx) => {
let cssHtml = ` `;
- if (!injectedBaseStyle){
+ if (!injectedBaseStyle) {
cssHtml = cssBaseStyle + cssHtml;
injectedBaseStyle = true;
}
@@ -148,36 +152,89 @@ const onPanelResize = (size: number) => {
function App() {
const rightPanelRef = React.useRef(null);
+
+ async function applyShareData(items: ShareItem[]): Promise {
+ // Write files to VFS
+ await webR.init();
+ await Promise.all(items.map(async (item) => {
+ return webR.FS.writeFile(item.path, item.data ? item.data : encoder.encode(item.text));
+ }));
+
+ // Load saved files into editor
+ void filesInterface.refreshFilesystem();
+ void filesInterface.openFilesInEditor(items.map((item) => ({
+ name: item.name,
+ path: item.path,
+ forceRead: true
+ })), true);
+ }
+
+ function applyShareHash(hash: string): void {
+ const shareHash = hash.match(/(code)=([^&]+)(?:&(\w+))?/);
+ if (shareHash && shareHash[1] === 'code') {
+ const items = decodeShareData(shareHash[2], shareHash[3]);
+
+ // Load initial content into editor
+ void filesInterface.openContentInEditor(items.map((item) => ({
+ name: item.name,
+ content: item.data ? item.data : encoder.encode(item.text)
+ })), true);
+
+ void applyShareData(items);
+ }
+ }
+
React.useEffect(() => {
window.addEventListener("resize", () => {
if (!rightPanelRef.current) return;
onPanelResize(rightPanelRef.current.getSize());
});
+
+ // Show share content whenever URL hash code changes
+ window.addEventListener("hashchange", (event: HashChangeEvent) => {
+ const url = new URL(event.newURL);
+ applyShareHash(url.hash);
+ });
+
+ // Listen for messages containing shared files data. See `encodeShareData()` for details.
+ window.addEventListener("message", (event: MessageEvent<{ items: EditorItem[] }>) => {
+ const items = event.data.items;
+ if (!isShareItems(items)) {
+ throw new Error("Provided postMessage data does not contain a valid set of share files.");
+ }
+ void applyShareData(items);
+ });
+ }, []);
+
+ // Show share content on initial load
+ React.useEffect(() => {
+ const url = new URL(window.location.href);
+ applyShareHash(url.hash);
}, []);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/repl/components/Editor.tsx b/src/repl/components/Editor.tsx
index b698e430..6d283cac 100644
--- a/src/repl/components/Editor.tsx
+++ b/src/repl/components/Editor.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { WebR, RFunction, Shelter } from '../../webR/webr-main';
-import { FaPlay, FaRegSave } from 'react-icons/fa';
+import { FaPlay, FaRegSave, FaShare } from 'react-icons/fa';
import { basicSetup, EditorView } from 'codemirror';
import { EditorState, Compartment, Prec } from '@codemirror/state';
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
@@ -11,6 +11,7 @@ import { FilesInterface, TerminalInterface } from '../App';
import { r } from 'codemirror-lang-r';
import { NamedObject, WebRDataJsAtomic } from '../../webR/robj';
import DataGrid from 'react-data-grid';
+import { encodeShareData, ShareModal } from './Share';
import * as utils from './utils';
import 'react-data-grid/lib/styles.css';
import './Editor.css';
@@ -19,7 +20,7 @@ const language = new Compartment();
const tabSize = new Compartment();
type EditorBase = { name: string, readOnly: boolean };
-type EditorData = EditorBase & {
+export type EditorData = EditorBase & {
type: "data",
data: {
columns: { key: string, name: string }[];
@@ -27,17 +28,18 @@ type EditorData = EditorBase & {
}
};
-type EditorHtml = EditorBase & {
+export type EditorHtml = EditorBase & {
path: string;
type: "html",
readOnly: boolean,
frame: HTMLIFrameElement,
};
-type EditorFile = EditorBase & {
+export type EditorFile = EditorBase & {
path: string;
type: "text",
readOnly: boolean,
+ dirty: boolean;
editorState: EditorState;
scrollTop?: number;
scrollLeft?: number;
@@ -49,11 +51,13 @@ export function FileTabs({
files,
activeFileIdx,
setActiveFileIdx,
- closeFile
+ focusEditor,
+ closeFile,
}: {
files: EditorItem[];
activeFileIdx: number;
setActiveFileIdx: React.Dispatch>;
+ focusEditor: () => void;
closeFile: (e: React.SyntheticEvent, index: number) => void;
}) {
return (
@@ -73,14 +77,17 @@ export function FileTabs({
setActiveFileIdx(index)}
+ onClick={() => {
+ setActiveFileIdx(index);
+ setTimeout(focusEditor, 0);
+ }}
>
- {f.name}
+ {f.name}{f.type === "text" && !f.readOnly && f.dirty ? '*' : null}
();
const [files, setFiles] = React.useState([]);
const [activeFileIdx, setActiveFileIdx] = React.useState(0);
+ const [shareModalOpen, setShareModalOpen] = React.useState(false);
+ const [shareUrl, setShareUrl] = React.useState(window.location.href);
+
const runSelectedCode = React.useRef((): void => {
throw new Error('Unable to run code, webR not initialised.');
});
+ const saveCurrentFile = React.useRef((): void => {
+ throw new Error('Unable to save file, editor not properly initialized.');
+ });
+ const setCurrentFileDirty = React.useRef<(_: boolean) => void>((): void => {
+ throw new Error('Unable to update file, editor not properly initialized.');
+ });
const activeFile = files[activeFileIdx];
const isScript = activeFile && activeFile.type === "text" && activeFile.path.endsWith('.R');
@@ -182,21 +198,40 @@ export function Editor({
const editorExtensions = [
basicSetup,
- language.of(r()),
tabSize.of(EditorState.tabSize.of(2)),
Prec.high(
keymap.of([
indentWithTab,
+ {
+ key: 'Mod-s',
+ run: () => {
+ saveCurrentFile.current();
+ return true;
+ },
+ },
+ ])
+ ),
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ setCurrentFileDirty.current(true);
+ }
+ }),
+ ];
+
+ const scriptExtensions = [
+ editorExtensions,
+ language.of(r()),
+ Prec.high(
+ keymap.of([
{
key: 'Mod-Enter',
run: () => {
- if (!runSelectedCode.current) return false;
runSelectedCode.current();
return true;
},
},
- ]
- )),
+ ])
+ ),
autocompletion({ override: [completion] })
];
@@ -248,6 +283,31 @@ export function Editor({
}
}, [activeFile, editorView]);
+ const setFileDirty = React.useCallback((dirty: boolean) => {
+ setFiles(prevFiles =>
+ prevFiles.map((file, index) => {
+ if (editorView && index === activeFileIdx && file.type === "text") {
+ file.scrollTop = editorView.scrollDOM.scrollTop;
+ file.scrollLeft = editorView.scrollDOM.scrollLeft;
+ return {
+ ...file,
+ editorState: editorView.state,
+ scrollTop: editorView.scrollDOM.scrollTop,
+ scrollLeft: editorView.scrollDOM.scrollLeft,
+ dirty
+ };
+ }
+ return file;
+ })
+ );
+ }, [activeFileIdx, editorView]);
+
+ React.useEffect(() => {
+ setCurrentFileDirty.current = (dirty: boolean): void => {
+ setFileDirty(dirty);
+ };
+ }, [setFileDirty, editorView, activeFile]);
+
const runFile = React.useCallback(() => {
if (!editorView) {
return;
@@ -265,28 +325,49 @@ export function Editor({
});
}, [syncActiveFileState, editorView]);
- const saveFile: React.MouseEventHandler = React.useCallback(() => {
- if (!editorView || activeFile.type !== "text") {
+ const saveFile = React.useCallback(() => {
+ if (!editorView || activeFile.type !== "text" || activeFile.readOnly) {
return;
}
syncActiveFileState();
- const code = editorView.state.doc.toString();
- const data = new TextEncoder().encode(code);
+ const content = editorView.state.doc.toString();
+ const data = new TextEncoder().encode(content);
webR.FS.writeFile(activeFile.path, data).then(() => {
void filesInterface.refreshFilesystem();
+ setFileDirty(false);
}, (reason) => {
+ setFileDirty(true);
console.error(reason);
throw new Error(`Can't save editor contents. See the JavaScript console for details.`);
});
}, [syncActiveFileState, editorView]);
+ React.useEffect(() => {
+ saveCurrentFile.current = (): void => {
+ void saveFile();
+ };
+ }, [saveFile, editorView, activeFile]);
+
+ const share = React.useCallback(() => {
+ if (files.length === 0) return;
+ saveFile();
+ setShareUrl(window.location.href.toString());
+ setShareModalOpen(true);
+ }, [files, webR]);
+
+ const focusEditor = React.useCallback(() => {
+ if (editorView) {
+ editorView.focus();
+ }
+ }, [editorView]);
+
React.useEffect(() => {
if (!editorRef.current) {
return;
}
- const state = EditorState.create({ extensions: editorExtensions });
+ const state = EditorState.create({ extensions: scriptExtensions });
const view = new EditorView({
state,
parent: editorRef.current,
@@ -298,6 +379,7 @@ export function Editor({
path: '/home/web_user/Untitled1.R',
type: 'text',
readOnly: false,
+ dirty: true,
editorState: state,
}]);
@@ -306,6 +388,31 @@ export function Editor({
};
}, []);
+
+ /*
+ * Update the share URL as active files are saved
+ */
+ React.useEffect(() => {
+ const shouldUpdate = files.filter((file): file is EditorFile => file.type === 'text').every((file) => !file.dirty);
+ if (files.length > 0 && shouldUpdate) {
+ Promise.all(
+ files.filter((file): file is EditorFile => file.type === "text" && !file.readOnly)
+ .map(async (file) => ({
+ name: file.name,
+ path: file.path,
+ data: await webR.FS.readFile(file.path)
+ }))
+ ).then(shareItems => {
+ const shareData = encodeShareData(shareItems);
+ const url = new URL(window.location.href);
+ url.hash = `code=${shareData}`;
+ window.history.pushState({}, '', url.toString());
+ }, (reason: Error) => {
+ throw new Error(`Can't update share URL: ${reason.message}`);
+ });
+ }
+ }, [files]);
+
/*
* Register this component with the files interface so that when it comes to
* opening files they are displayed in this codemirror instance.
@@ -322,7 +429,7 @@ export function Editor({
syncActiveFileState();
const columns = Object.keys(data).map((key) => {
- return {key, name: key === "row.names" ? "" : key};
+ return { key, name: key === "row.names" ? "" : key };
});
const rows = Object.entries(data).reduce((a, entry) => {
@@ -361,40 +468,89 @@ export function Editor({
setActiveFileIdx(index - 1);
};
- 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) => "path" in f && f.path === path);
- if (existsIndex >= 0) {
- setActiveFileIdx(existsIndex);
- return Promise.resolve();
+ filesInterface.openContentInEditor = (openFiles: {
+ name: string,
+ content: Uint8Array,
+ }[], replace = false) => {
+ if (openFiles.length === 0) return;
+
+ const updatedFiles: EditorItem[] = replace ? [] : [...files];
+ openFiles.forEach((file) => {
+ updatedFiles.push({
+ name: file.name,
+ path: `/tmp/.webR${file.name}`,
+ type: "text",
+ readOnly: false,
+ dirty: true,
+ editorState: EditorState.create({
+ doc: utils.decodeTextOrBinaryContent(file.content),
+ extensions: file.name.toLowerCase().endsWith('.r') ? scriptExtensions : editorExtensions
+ }),
+ });
}
+ );
+ setFiles(updatedFiles);
+ setActiveFileIdx(0);
+ };
- return webR.FS.readFile(path).then((data) => {
- syncActiveFileState();
- const updatedFiles = [...files];
- const extensions = name.toLowerCase().endsWith('.r') ? editorExtensions : [];
- if (readOnly) extensions.push(EditorState.readOnly.of(true));
-
- // Get file content, dealing with backspace characters until none remain
- let content = new TextDecoder().decode(data);
- while (content.match(/.[\b]/)) {
- content = content.replace(/.[\b]/g, '');
+ filesInterface.openFilesInEditor = async (openFiles: {
+ name: string,
+ path: string,
+ readOnly?: boolean;
+ forceRead?: boolean
+ }[], replace = false) => {
+ // Dismiss sharing modal
+ setShareModalOpen(false);
+
+ const updatedFiles: EditorItem[] = replace ? [] : [...files];
+ let index = null;
+
+ for (const file of openFiles) {
+ const _options = {
+ readOnly: false,
+ forceRead: false,
+ ...file,
+ };
+
+ // If file is already open, switch to that tab
+ const existsIndex = updatedFiles.findIndex((f) => "path" in f && f.path === file.path);
+ if (existsIndex >= 0 && !_options.forceRead) {
+ index ??= existsIndex;
+ continue;
}
- // Add this new file content to the list of open files
- const index = updatedFiles.push({
- name,
- path,
- type: "text",
- readOnly,
- editorState: EditorState.create({
- doc: content,
- extensions,
- }),
+ // Otherwise, read the file contents from the VFS
+ await webR.FS.readFile(file.path).then((data) => {
+ syncActiveFileState();
+ let extensions = file.name.toLowerCase().endsWith('.r') ? scriptExtensions : editorExtensions;
+ if (_options.readOnly) extensions = [EditorState.readOnly.of(true)];
+
+ const newFile: EditorItem = {
+ name: file.name,
+ path: file.path,
+ type: "text",
+ readOnly: _options.readOnly,
+ dirty: false,
+ editorState: EditorState.create({
+ doc: utils.decodeTextOrBinaryContent(data),
+ extensions,
+ }),
+ };
+
+ if (existsIndex >= 0) {
+ // Switch to and update existing tab content
+ updatedFiles[existsIndex] = newFile;
+ index ??= existsIndex;
+ } else {
+ // Add this new file content to the list of open files
+ const newIndex = updatedFiles.push(newFile);
+ index ??= newIndex - 1;
+ }
});
- setFiles(updatedFiles);
- setActiveFileIdx(index - 1);
- });
+ }
+
+ setFiles(updatedFiles);
+ setActiveFileIdx(index ?? 0);
};
}, [files, filesInterface]);
@@ -438,64 +594,75 @@ export function Editor({
}, [files, syncActiveFileState, activeFile, editorView]);
return (
-
-
-
-
-
-
-
- This component is an instance of the CodeMirror interactive text editor.
- The editor has been configured so that the Tab key controls the indentation of code.
- 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.data) &&
-
- }
-
-
+
- {isScript &&
- Run
- }
- {!isReadOnly &&
- Save
- }
-
-
+
+
+
+
+
+
+ This component is an instance of the CodeMirror interactive text editor.
+ The editor has been configured so that the Tab key controls the indentation of code.
+ 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.data) &&
+
+ }
+
+
+ {isScript &&
+ Run
+ }
+ {!isReadOnly &&
+ Save
+ }
+
+ Share
+
+
+
+ setShareModalOpen(false)}
+ shareUrl={shareUrl}
+ />
+ >
);
}
diff --git a/src/repl/components/Files.tsx b/src/repl/components/Files.tsx
index df924ec2..c232461e 100644
--- a/src/repl/components/Files.tsx
+++ b/src/repl/components/Files.tsx
@@ -154,7 +154,7 @@ export function Files({
void (async () => {
const zip = new JSZip();
await zipFromFSNode(zip, selectedNode);
- const data = await zip.generateAsync({type : "uint8array"});
+ const data = await zip.generateAsync({ type: "uint8array" });
doDownload(`${selectedNode.name}.zip`, data);
})();
}
@@ -165,7 +165,7 @@ export function Files({
return;
}
const path = getNodePath(selectedNode);
- await filesInterface.openFileInEditor(selectedNode.name, path, false);
+ await filesInterface.openFilesInEditor([{ name: selectedNode.name, path, readOnly: false }]);
};
const onNewDirectory = async () => {
diff --git a/src/repl/components/Share.css b/src/repl/components/Share.css
new file mode 100644
index 00000000..6f23d01d
--- /dev/null
+++ b/src/repl/components/Share.css
@@ -0,0 +1,61 @@
+.share-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.share-modal {
+ background-color: white;
+ border-radius: 4px;
+ padding: 20px;
+ width: 500px;
+ max-width: 90%;
+ position: relative;
+}
+
+.share-modal-heading {
+ margin-bottom: 15px;
+}
+
+.share-modal-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.share-modal-content {
+ display: flex;
+}
+
+.share-modal-content input {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-family: monospace;
+ margin-right: 8px;
+}
+
+.copy-button {
+ padding: 8px 12px;
+ background-color: #0366d6;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: bold;
+}
+
+.copy-button:hover {
+ background-color: #0255bd;
+}
diff --git a/src/repl/components/Share.tsx b/src/repl/components/Share.tsx
new file mode 100644
index 00000000..1c486d56
--- /dev/null
+++ b/src/repl/components/Share.tsx
@@ -0,0 +1,169 @@
+
+import React, { useState, useEffect } from 'react';
+import { FaTimes } from 'react-icons/fa';
+import { inflate, deflate } from "pako";
+import { decode, encode } from '@msgpack/msgpack';
+import { base64ToBuffer, bufferToBase64 } from '../../webR/utils';
+import './Share.css';
+
+export type ShareItem = {
+ name: string;
+ path: string;
+ data?: Uint8Array;
+ text?: string;
+};
+
+export enum ShareDataFlags {
+ UNCOMPRESSED = 'u',
+ ZLIB = 'z',
+ MSGPACK = 'm',
+ JSON = 'j',
+}
+
+export function isShareItems(files: any): files is ShareItem[] {
+ return Array.isArray(files) && files.every((file) =>
+ 'name' in file && typeof file.name === 'string' &&
+ 'path' in file && typeof file.path === 'string' &&
+ (
+ ('text' in file && typeof file.text === "string") ||
+ ('data' in file && file.data instanceof Uint8Array)
+ )
+ );
+}
+
+/**
+ * Encode files for sharing.
+ *
+ * Encode shared files for use as the hash string in a sharing URL.
+ * This function outputs strings with msgpack format and zlib compression.
+ *
+ * Shared item typing
+ * ------------------
+ * A shared item is an object with the following format,
+ *
+ * { name: string; path: string; text?: string; data?: Uint8Array }
+ *
+ * where `name` is a display name (usually the filename), `path` is the path
+ * where the file will be written to the Emscripten VFS, and either a `text`
+ * string or the file's binary `data` is present, defining the content for the
+ * shared file.
+ *
+ * Sharing via Data URI
+ * --------------------
+ * An array of shared items should be encoded either in msgpack or JSON format,
+ * and then optionally compressed using the zlib deflate algorithm.
+ *
+ * The resulting binary data should be base64 encoded, with special characters
+ * encoded for use as a URL hash.
+ *
+ * The hash may optionally end in `&[...]`, where [...] may be one or more of
+ * the following flags:
+ * - 'u': uncompressed
+ * - 'z': zlib compressed
+ * - 'm': msgpack format
+ * - 'j': json format
+ * The default flags string is `&mz`.
+ *
+ * Sharing via `postMessage()`
+ * ---------------------------
+ * The webR app listens for messages with `data` containing an array of shared
+ * items: { items: ShareItem[] }.
+ *
+ * When such a message has been received, the shared file content is applied
+ * to the current editor.
+ * @param {ShareItem[]} items An array of shared file content.
+ * @returns {string} Encoded URI string.
+ */
+export function encodeShareData(items: ShareItem[]): string {
+ const encoded = encode(items); // msgpack format
+ const compressed = deflate(encoded); // zlib deflate compressed
+ const base64 = bufferToBase64(compressed); // base64 encoded
+ const uri = encodeURIComponent(base64); // Encode special characters for URI
+ return uri;
+}
+
+/**
+ * Decode shared files data.
+ *
+ * Decodes the hash string provided in a sharing URL. Data may be JSON or
+ * msgpack encoded, with optional compression. See `encodeShareData()` for
+ * futher details.
+ * @param {string} data Encoded URI string.
+ * @param {string} [flags] Decoding flags. Defaults to `mz`, meaning msgpack
+ * format and zlib compressed.
+ * @returns {ShareItem[]} An array of shared file content.
+ */
+export function decodeShareData(data: string, flags = 'mz'): ShareItem[] {
+ const base64 = decodeURIComponent(data); // Decode URI encoded characters
+ const buffer = base64ToBuffer(base64); // Decode base64
+
+ const encoded = flags.includes(ShareDataFlags.UNCOMPRESSED)
+ ? buffer // No compression
+ : inflate(buffer); // zlib deflate compressed
+
+ const items = flags.includes(ShareDataFlags.JSON)
+ ? JSON.parse(new TextDecoder().decode(encoded)) as unknown // JSON format
+ : decode(encoded); // msgpack format
+
+ if (!isShareItems(items)) {
+ throw new Error("Provided URL data is not a valid set of share files.");
+ }
+ return items;
+}
+
+interface ShareModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ shareUrl: string;
+}
+
+export function ShareModal({ isOpen, onClose, shareUrl }: ShareModalProps) {
+ const [copied, setCopied] = useState(false);
+ const urlBytes = new TextEncoder().encode(shareUrl).length;
+
+ useEffect(() => {
+ if (copied) {
+ const timer = setTimeout(() => setCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [copied]);
+
+ const handleCopyClick = () => {
+ navigator.clipboard.writeText(shareUrl).then(() => {
+ setCopied(true);
+ }).catch(err => {
+ console.error('Failed to copy: ', err);
+ });
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
Share URL ({urlBytes} bytes)
+
+
+
+ {copied ? "Copied!" : "Copy URL"}
+
+
+
+
+ );
+}
+
+export default ShareModal;
diff --git a/src/repl/components/utils.ts b/src/repl/components/utils.ts
index 66642045..db7702c0 100644
--- a/src/repl/components/utils.ts
+++ b/src/repl/components/utils.ts
@@ -54,3 +54,18 @@ export function moveCursorToNextLine(cmView: EditorView): void {
const nextLineOffset = positionToOffset(cmState.doc, pos);
cmView.dispatch({ selection: { anchor: nextLineOffset } });
}
+
+export function decodeTextOrBinaryContent(data: Uint8Array): string {
+ try {
+ // Get file content, dealing with backspace characters until none remain
+ let text = new TextDecoder("utf-8", { fatal: true }).decode(data);
+ while (text.match(/.[\b]/)) {
+ text = text.replace(/.[\b]/g, '');
+ }
+ return text;
+ } catch (err) {
+ // Deal with binary data
+ if (!(err instanceof TypeError)) throw err;
+ return `<< ${data.byteLength} bytes of binary data >>`;
+ }
+}
diff --git a/src/webR/utils.ts b/src/webR/utils.ts
index 36a45fc0..502be25e 100644
--- a/src/webR/utils.ts
+++ b/src/webR/utils.ts
@@ -159,3 +159,13 @@ export function bufferToBase64(buffer: ArrayBuffer) {
}
return window.btoa(binary);
}
+
+// From https://stackoverflow.com/a/21797381
+export function base64ToBuffer(base64: string) {
+ const binaryString = window.atob(base64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes.buffer;
+}