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({ } - {!isReadOnly && } - - +
+ +
+
+
+

+ 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 && } + {!isReadOnly && } + +
+ + 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)
+
+ + +
+
+
+ ); +} + +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; +}