diff --git a/package-lock.json b/package-lock.json index 793b9677..7e2f5a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shinylive", - "version": "0.2.8", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shinylive", - "version": "0.2.8", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@codemirror/autocomplete": "^6.4.2", @@ -61,6 +61,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "tsx": "^4.7.0", "typescript": "^5.3.3", "vscode-languageserver-protocol": "^3.17.5", @@ -4982,6 +4983,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "dev": true, + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -8439,6 +8449,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dev": true, + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-icons": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", diff --git a/package.json b/package.json index aab249ba..5935706f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "tsx": "^4.7.0", "typescript": "^5.3.3", "vscode-languageserver-protocol": "^3.17.5", @@ -142,6 +143,5 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } - }, - "packageManager": "yarn@3.2.3" + } } diff --git a/scripts/build.ts b/scripts/build.ts index 66ecff4c..feac8e56 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -176,6 +176,7 @@ const buildmap = { "src/pyodide-worker.ts", "src/load-shinylive-sw.ts", "src/run-python-blocks.ts", + "src/lzstring-worker.ts", ], outdir: `${BUILD_DIR}/shinylive`, // See note in esbuild.build() call above about why these are external. diff --git a/site_template/editor/index.html b/site_template/editor/index.html index 5116efdb..c5a7aad0 100644 --- a/site_template/editor/index.html +++ b/site_template/editor/index.html @@ -1,4 +1,4 @@ - + @@ -18,6 +18,13 @@ allowCodeUrl: true, allowGistUrl: true, allowExampleUrl: true, + // This option causes shinylive to update the URL hash when the user + // clicks on the re-run button in the editor. It is false by default. + // It should be set to true only when the editor and viewer are used + // in a full-window configuration. If you are using the editor and + // viewer embedded in a larger page, it does not make sense to set + // this to true. + updateUrlHashOnRerun: true, }; const appRoot = document.getElementById("root"); diff --git a/site_template/examples/index.html b/site_template/examples/index.html index 62cceba1..3f85ce36 100644 --- a/site_template/examples/index.html +++ b/site_template/examples/index.html @@ -1,4 +1,4 @@ - + @@ -15,11 +15,23 @@ // reasons, if you enable any of these, then this site should be hosted on // a separate domain or subdomain from other content. Otherwise the // running of arbitrary code could be used, for example, to steal cookies. - runApp(appRoot, "examples-editor-terminal-viewer", { - allowCodeUrl: true, - allowGistUrl: true, - allowExampleUrl: true, - }, "{{APP_ENGINE}}"); + runApp( + appRoot, + "examples-editor-terminal-viewer", + { + allowCodeUrl: true, + allowGistUrl: true, + allowExampleUrl: true, + // This option causes shinylive to update the URL hash when the user + // clicks on the re-run button in the editor. It is false by default. + // It should be set to true only when the editor and viewer are used + // in a full-window configuration. If you are using the editor and + // viewer embedded in a larger page, it does not make sense to set + // this to true. + updateUrlHashOnRerun: true, + }, + "{{APP_ENGINE}}", + ); diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 1609e4ef..80570db0 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -91,6 +91,10 @@ type AppOptions = { // In Viewer-only mode, should the header bar be shown? showHeaderBar?: boolean; + + // When the app is re-run from the Editor, should the URL hash be updated with + // the encoded version of the app? + updateUrlHashOnRerun?: boolean; }; export type ProxyHandle = PyodideProxyHandle | WebRProxyHandle; @@ -353,6 +357,7 @@ export function App({ file.name === "app.R" || file.name === "server.R", )} + updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun} appEngine={appEngine} /> @@ -400,6 +405,7 @@ export function App({ file.name === "app.R" || file.name === "server.R", )} + updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun} appEngine={appEngine} /> @@ -433,6 +439,7 @@ export function App({ terminalMethods={terminalMethods} utilityMethods={utilityMethods} runOnLoad={false} + updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun} appEngine={appEngine} /> @@ -458,6 +465,7 @@ export function App({ lineNumbers={false} showHeaderBar={false} floatingButtons={true} + updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun} appEngine={appEngine} /> @@ -495,6 +503,7 @@ export function App({ terminalMethods={terminalMethods} utilityMethods={utilityMethods} viewerMethods={viewerMethods} + updateUrlHashOnRerun={appOptions.updateUrlHashOnRerun} appEngine={appEngine} /> diff --git a/src/Components/Editor.tsx b/src/Components/Editor.tsx index d292b91f..988226f6 100644 --- a/src/Components/Editor.tsx +++ b/src/Components/Editor.tsx @@ -11,6 +11,7 @@ import "balloon-css"; import type { Zippable } from "fflate"; import { zipSync } from "fflate"; import * as React from "react"; +import toast, { Toaster } from "react-hot-toast"; import type * as LSP from "vscode-languageserver-protocol"; import * as fileio from "../fileio"; import { createUri } from "../language-server/client"; @@ -36,7 +37,15 @@ import { languageServerExtensions } from "./codeMirror/language-server/lsp-exten import { useTabbedCodeMirror } from "./codeMirror/useTabbedCodeMirror"; import * as cmUtils from "./codeMirror/utils"; import type { FileContent } from "./filecontent"; -import { editorUrlPrefix, fileContentsToUrlString } from "./share"; +import { + editorUrlPrefix, + fileContentsToUrlString, + fileContentsToUrlStringInWebWorker, +} from "./share"; + +// If the file contents are larger than this value, then don't automatically +// update the URL hash when re-running the app. +const UPDATE_URL_SIZE_THRESHOLD = 250000; export type EditorFile = | { @@ -77,6 +86,7 @@ export default function Editor({ lineNumbers = true, showHeaderBar = true, floatingButtons = false, + updateUrlHashOnRerun = false, appEngine, }: { currentFilesFromApp: FileContent[]; @@ -93,6 +103,7 @@ export default function Editor({ lineNumbers?: boolean; showHeaderBar?: boolean; floatingButtons?: boolean; + updateUrlHashOnRerun?: boolean; appEngine: AppEngine; }) { // In the future, instead of directly instantiating the PyrightClient, it @@ -114,6 +125,33 @@ export default function Editor({ // the Viewer component. const lspPathPrefix = `editor${editorInstanceId}/`; + // This tracks whether the files have changed since the the last time the user + // has run the app/code. This is used to determine whether to update the URL. + // It is different from `setFilesHaveChanged` which is passed in, because that + // tracks whether the files have changed since they were passed into the + // Editor component. + // + // If the Editor starts with a file, then you change it and re-run, then both + // the external `filesHaveChanged` and `filesHaveChangedSinceLastRun` will be + // true. But if you re-run it again without making changes, then + // `filesHaveChanged` will still be true, and `filesHaveChangedSinceLastRun` + // will be false. + const [filesHaveChangedSinceLastRun, setFilesHaveChangedSinceLastRun] = + React.useState(false); + + // This is a shortcut to indicate that the files have changed. See the comment + // for `setFilesHaveChangedSinceLastRun` to understand why this is needed. + const setFilesHaveChangedCombined = React.useCallback( + (value: boolean) => { + setFilesHaveChanged(value); + setFilesHaveChangedSinceLastRun(value); + }, + [setFilesHaveChanged, setFilesHaveChangedSinceLastRun], + ); + + const [hasShownUrlTooLargeMessage, setHasShownUrlTooLargeMessage] = + React.useState(false); + // Given a FileContent object, figure out which editor extensions to use. // Use a memoized function to maintain referentially stablity. const inferEditorExtensions = React.useCallback( @@ -130,7 +168,7 @@ export default function Editor({ getLanguageExtension(language), EditorView.updateListener.of((u: ViewUpdate) => { if (u.docChanged) { - setFilesHaveChanged(true); + setFilesHaveChangedCombined(true); } }), languageServerExtensions(lspClient, lspPathPrefix + file.name), @@ -139,7 +177,7 @@ export default function Editor({ ), ]; }, - [lineNumbers, setFilesHaveChanged, lspClient, lspPathPrefix], + [lineNumbers, setFilesHaveChangedCombined, lspClient, lspPathPrefix], ); const [cmView, setCmView] = React.useState(); @@ -148,7 +186,7 @@ export default function Editor({ currentFilesFromApp, cmView, inferEditorExtensions, - setFilesHaveChanged, + setFilesHaveChanged: setFilesHaveChangedCombined, lspClient, lspPathPrefix, }); @@ -182,12 +220,43 @@ export default function Editor({ if (!viewerMethods || !viewerMethods.ready) return; syncActiveFileState(); + const fileContents = editorFilesToFileContents(files); + + if (updateUrlHashOnRerun && filesHaveChangedSinceLastRun) { + const filesSize = fileContentsSize(fileContents); + + if ( + !hasShownUrlTooLargeMessage && + filesSize > UPDATE_URL_SIZE_THRESHOLD + ) { + toast( + "Auto-updating the app link is disabled because the app is very large. " + + "If you want the sharing URL, click the Share button.", + ); + setHasShownUrlTooLargeMessage(true); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + updateBrowserUrlHash(fileContents); + } + } + + setFilesHaveChangedCombined(false); + // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { await viewerMethods.stopApp(); - await viewerMethods.runApp(editorFilesToFileContents(files)); + await viewerMethods.runApp(fileContents); })(); - }, [viewerMethods, syncActiveFileState, files]); + }, [ + viewerMethods, + syncActiveFileState, + updateUrlHashOnRerun, + filesHaveChangedSinceLastRun, + setFilesHaveChangedCombined, + hasShownUrlTooLargeMessage, + setHasShownUrlTooLargeMessage, + files, + ]); // Run the entire current file in the terminal. const runAllCode = React.useCallback(() => { @@ -558,6 +627,13 @@ export default function Editor({ ) : null}
+ {floatingButtons ? (
{runButton}
) : null} @@ -640,6 +716,22 @@ function editorFilesToFflateZippable(files: EditorFile[]): Zippable { return res; } +// Get the size in bytes of the contents of a FileContent array. Note that this +// isn't exactly the size in bytes -- for text files, it counts the number of +// characters, but some could be multi-byte (and the size could vary depending +// on the encoding). But it's close enough for our purposes. +function fileContentsSize(files: FileContent[]): number { + let size = 0; + for (const file of files) { + if (file.type === "binary") { + size += file.content.length; + } else { + size += file.content.length; + } + } + return size; +} + // ============================================================================= // Misc utility functions // ============================================================================= @@ -714,3 +806,14 @@ function keyBindings({ }, ]; } +/** + * Update the browser URL hash with the current contents of the Editor. + */ +async function updateBrowserUrlHash( + fileContents: FileContent[], +): Promise { + const encodedFileContents = + await fileContentsToUrlStringInWebWorker(fileContents); + const hash = "#code=" + encodedFileContents; + history.replaceState(null, "", hash); +} diff --git a/src/Components/share.ts b/src/Components/share.ts index 40a2601c..b72c64d4 100644 --- a/src/Components/share.ts +++ b/src/Components/share.ts @@ -1,4 +1,6 @@ import LZString from "lz-string"; +import type * as LZStringWorker from "../lzstring-worker"; +import * as utils from "../utils"; import type { AppEngine } from "./App"; import type { FileContent } from "./filecontent"; import { FCtoFCJSON } from "./filecontent"; @@ -30,3 +32,80 @@ export function fileContentsToUrlString( JSON.stringify(fileContents.map(FCtoFCJSON)), ); } + +/** + * Given a FileContent[] object, return a string that is a LZ-compressed JSON + * representation of it. This version uses a web worker to do the compression. + */ +export async function fileContentsToUrlStringInWebWorker( + fileContents: FileContent[], + sort: boolean = true, +): Promise { + if (sort) { + fileContents.sort((a, b) => a.name.localeCompare(b.name)); + } + const fileContentJsonString = JSON.stringify(fileContents.map(FCtoFCJSON)); + return await encodeLzstringWebWorker(fileContentJsonString); +} + +// ============================================================================= +// Code for calling lzstring with a web worker +// ============================================================================= + +// Narrow the types for postMessage to just the type we'll actually send. +interface LZStringWebWorker extends Omit { + postMessage( + msg: LZStringWorker.RequestMessage, + transfer: Transferable[], + ): void; +} + +let _lzstringWebWorker: LZStringWebWorker | null = null; + +/** + * Ensure that the lzstring web worker exists. + * + * @returns The lzstring web worker. If it doesn't exist, it will be created. + */ +function ensureLzstringWebWorker(): LZStringWebWorker { + if (_lzstringWebWorker === null) { + _lzstringWebWorker = new Worker( + utils.currentScriptDir() + "/lzstring-worker.js", + { type: "module" }, + ); + } + return _lzstringWebWorker; +} + +/** + * Compress a string using lzstring in a web worker. + */ +async function encodeLzstringWebWorker(value: string): Promise { + const response = await postMessageLzstringWebWorker({ + type: "encode", + value, + }); + return response.value; +} + +/** + * Send a message to the lzstring web worker and return a promise that resolves + * when the worker responds. + */ +async function postMessageLzstringWebWorker( + msg: LZStringWorker.RequestMessage, +): Promise { + const worker = ensureLzstringWebWorker(); + + return new Promise((onSuccess) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (e) => { + channel.port1.close(); + const msg = e.data as LZStringWorker.ResponseMessage; + onSuccess(msg); + }; + + worker.postMessage(msg, [channel.port2]); + }); +} diff --git a/src/lzstring-worker.ts b/src/lzstring-worker.ts new file mode 100644 index 00000000..b8d102f5 --- /dev/null +++ b/src/lzstring-worker.ts @@ -0,0 +1,40 @@ +// LZString in a Web Worker + +import LZString from "lz-string"; + +self.onmessage = function (e: MessageEvent): void { + const msg = e.data as RequestMessage; + const messagePort: ResponseMesssagePort = e.ports[0]; + + let result: string; + + if (msg.type === "encode") { + result = LZString.compressToEncodedURIComponent(msg.value); + } else if (msg.type === "decode") { + result = LZString.decompressFromEncodedURIComponent(msg.value); + } else { + throw new Error(`Unknown request message type: ${(msg as any).type}`); + } + + messagePort.postMessage({ value: result }); +}; + +interface ResponseMesssagePort extends Omit { + postMessage(msg: ResponseMessage): void; +} + +export interface RequestMessageEncode { + type: "encode"; + value: string; +} + +export interface RequestMessageDecode { + type: "decode"; + value: string; +} + +export type RequestMessage = RequestMessageEncode | RequestMessageDecode; + +export interface ResponseMessage { + value: string; +}