diff --git a/web/package.json b/web/package.json index 715db9aab..4fe993f9a 100644 --- a/web/package.json +++ b/web/package.json @@ -156,6 +156,7 @@ "localforage": "1.10.0", "lodash-es": "4.17.21", "lucide-react": "0.462.0", + "lz-string": "^1.5.0", "quickjs-emscripten": "0.24.0", "quickjs-emscripten-sync": "1.5.2", "rc-slider": "9.7.5", diff --git a/web/src/beta/features/PluginPlayground/Plugins/index.tsx b/web/src/beta/features/PluginPlayground/Plugins/index.tsx index 5f47ce0e0..49b8a2ab9 100644 --- a/web/src/beta/features/PluginPlayground/Plugins/index.tsx +++ b/web/src/beta/features/PluginPlayground/Plugins/index.tsx @@ -8,6 +8,7 @@ import usePlugins from "./usePlugins"; type UsePluginsReturn = Pick< ReturnType, + | "encodeAndSharePlugin" | "presetPlugins" | "selectPlugin" | "selectedPlugin" @@ -17,11 +18,13 @@ type UsePluginsReturn = Pick< | "updateFileTitle" | "deleteFile" | "handleFileUpload" + | "sharedPlugins" >; type Props = UsePluginsReturn; const Plugins: FC = ({ + encodeAndSharePlugin, presetPlugins, selectedPlugin, selectPlugin, @@ -30,10 +33,15 @@ const Plugins: FC = ({ addFile, updateFileTitle, deleteFile, - handleFileUpload + handleFileUpload, + sharedPlugins }) => { const [isAddingNewFile, setIsAddingNewFile] = useState(false); + const handleShareIconClicked = (pluginId: string): void => { + encodeAndSharePlugin(pluginId); + }; + return ( @@ -46,10 +54,39 @@ const Plugins: FC = ({ highlighted={selectedPlugin.id === plugin.id} onClick={() => selectPlugin(plugin.id)} title={plugin.title} + optionsMenu={[ + { + id: "0", + title: "share", + icon: "paperPlaneTilt", + onClick: () => handleShareIconClicked(plugin.id) + } + ]} + optionsMenuWidth={100} /> ))} ))} +
+ Shared + {sharedPlugins.map((plugin) => ( + selectPlugin(plugin.id)} + title={plugin.title} + optionsMenu={[ + { + id: "0", + title: "share", + icon: "paperPlaneTilt", + onClick: () => handleShareIconClicked(plugin.id) + } + ]} + optionsMenuWidth={100} + /> + ))} +
diff --git a/web/src/beta/features/PluginPlayground/Plugins/usePlugins.ts b/web/src/beta/features/PluginPlayground/Plugins/usePlugins.ts index ab4f84950..624e28f3e 100644 --- a/web/src/beta/features/PluginPlayground/Plugins/usePlugins.ts +++ b/web/src/beta/features/PluginPlayground/Plugins/usePlugins.ts @@ -1,6 +1,8 @@ import { useNotification } from "@reearth/services/state"; import JSZip from "jszip"; -import { useCallback, useMemo, useState } from "react"; +import LZString from "lz-string"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import useFileInput from "use-file-input"; import { v4 as uuidv4 } from "uuid"; @@ -9,8 +11,53 @@ import { presetPlugins } from "./presets"; import { validateFileTitle } from "./utils"; export default () => { + const [searchParams] = useSearchParams(); + const sharedPluginUrl = searchParams.get("plugin"); + + const decodePluginURL = useCallback((encoded: string) => { + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); + // Decompress and parse + const decompressed = LZString.decompressFromBase64(base64); + + return JSON.parse(decompressed); + }, []); + + const sharedPlugin = sharedPluginUrl + ? decodePluginURL(sharedPluginUrl) + : null; + + const locallyStoredSharedPlugins = localStorage.getItem("SHARED_PLUGINS"); + const [sharedPlugins, setSharedPlugins] = useState( + JSON.parse(locallyStoredSharedPlugins ?? "[]") + ); + + useEffect(() => { + if (sharedPlugin) { + setSharedPlugins((prevSharedPlugins) => { + const doesSharedPluginExist = prevSharedPlugins.some( + (element) => element.id === sharedPlugin.id + ); + const tempSharedPlugins = doesSharedPluginExist + ? prevSharedPlugins + : [...prevSharedPlugins, sharedPlugin]; + localStorage.setItem( + "SHARED_PLUGINS", + JSON.stringify(tempSharedPlugins) + ); + return tempSharedPlugins; + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const presetPluginsArray = presetPlugins + .map((category) => category.plugins) + .flat(); + const [plugins, setPlugins] = useState( - presetPlugins.map((category) => category.plugins).flat() + locallyStoredSharedPlugins + ? [...JSON.parse(locallyStoredSharedPlugins), ...presetPluginsArray] + : presetPluginsArray ); const [selectedPluginId, setSelectedPluginId] = useState(plugins[0].id); @@ -199,7 +246,44 @@ export default () => { } }, [selectedPlugin, setNotification]); + const encodeAndSharePlugin = useCallback( + (pluginId: string): string | undefined => { + selectPlugin(pluginId); + // Need to do a find here as the selectedPlugin does not get updated immediately + const sharedPlugin = plugins.find((plugin) => plugin.id === pluginId); + + // Note: We can't use the same id for a shared plugin + const selectedPluginCopy = { ...sharedPlugin, id: uuidv4() }; + + // First compress the code + try { + const compressed = LZString.compressToBase64( + JSON.stringify(selectedPluginCopy) + ) + .replace(/\+/g, "-") // Convert + to - + .replace(/\//g, "_") // Convert / to _ + .replace(/=/g, ""); // Remove padding = + + const shareUrl = `${window.location.origin}${window.location.pathname}?plugin=${compressed}`; + navigator.clipboard.writeText(shareUrl); + + setNotification({ + type: "success", + text: "Plugin link copied to clipboard" + }); + return compressed; + } catch (error) { + if (error instanceof Error) { + setNotification({ type: "error", text: error.message }); + } + return; + } + }, + [plugins, selectPlugin, setNotification] + ); + return { + encodeAndSharePlugin, presetPlugins, selectPlugin, selectedPlugin, @@ -210,6 +294,7 @@ export default () => { updateFileSourceCode, deleteFile, handleFileUpload, - handlePluginDownload + handlePluginDownload, + sharedPlugins }; }; diff --git a/web/src/beta/features/PluginPlayground/hooks.tsx b/web/src/beta/features/PluginPlayground/hooks.tsx index ed41ed473..b240ab758 100644 --- a/web/src/beta/features/PluginPlayground/hooks.tsx +++ b/web/src/beta/features/PluginPlayground/hooks.tsx @@ -21,7 +21,9 @@ export default () => { updateFileSourceCode, deleteFile, handleFileUpload, - handlePluginDownload + handlePluginDownload, + encodeAndSharePlugin, + sharedPlugins } = usePlugins(); const { widgets, executeCode, fileOutputs } = useCode({ @@ -59,6 +61,7 @@ export default () => { name: "Plugins", children: ( { updateFileTitle={updateFileTitle} deleteFile={deleteFile} handleFileUpload={handleFileUpload} + sharedPlugins={sharedPlugins} /> ) } ], [ + encodeAndSharePlugin, presetPlugins, selectedPlugin, selectPlugin, @@ -81,7 +86,8 @@ export default () => { addFile, updateFileTitle, deleteFile, - handleFileUpload + handleFileUpload, + sharedPlugins ] ); diff --git a/web/yarn.lock b/web/yarn.lock index 84ed82ecd..b58045686 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -8321,6 +8321,7 @@ __metadata: localforage: "npm:1.10.0" lodash-es: "npm:4.17.21" lucide-react: "npm:0.462.0" + lz-string: "npm:^1.5.0" npm-run-all: "npm:4.1.5" postcss: "npm:8.4.49" prettier: "npm:3.3.3"