diff --git a/src/components/ExportDialog.scss b/src/components/ExportDialog.scss index c47cdf400..d7e45da01 100644 --- a/src/components/ExportDialog.scss +++ b/src/components/ExportDialog.scss @@ -66,4 +66,10 @@ overflow-y: auto; } } + + .shareable-link--viewonly { + svg { + width: 1.5em; + } + } } diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 2df04aa7d..2456f4920 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -12,7 +12,7 @@ import { exportToCanvas, getExportSize } from "../scene/export"; import { AppState } from "../types"; import { Dialog } from "./Dialog"; import "./ExportDialog.scss"; -import { clipboard, exportFile, link } from "./icons"; +import { clipboard, exportFile, eyeIcon, link } from "./icons"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; @@ -66,7 +66,10 @@ const ExportModal = ({ onExportToPng: ExportCB; onExportToSvg: ExportCB; onExportToClipboard: ExportCB; - onExportToBackend?: ExportCB; + onExportToBackend?: ( + elements: readonly NonDeletedExcalidrawElement[], + viewonly: boolean, + ) => void; onCloseRequest: () => void; }) => { const someElementIsSelected = isSomeElementSelected(elements, appState); @@ -155,13 +158,23 @@ const ExportModal = ({ /> )} {onExportToBackend && ( - onExportToBackend(exportedElements)} - /> + <> + onExportToBackend(exportedElements, false)} + /> + onExportToBackend(exportedElements, true)} + /> + )}
@@ -236,7 +249,10 @@ export const ExportDialog = ({ onExportToPng: ExportCB; onExportToSvg: ExportCB; onExportToClipboard: ExportCB; - onExportToBackend?: ExportCB; + onExportToBackend?: ( + elements: readonly NonDeletedExcalidrawElement[], + viewonly: boolean, + ) => void; }) => { const [modalIsShown, setModalIsShown] = useState(false); const triggerButton = useRef(null); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 103b0ef84..b77ccb65d 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -58,6 +58,7 @@ interface LayerUIProps { isCollaborating: boolean; onExportToBackend?: ( exportedElements: readonly NonDeletedExcalidrawElement[], + viewonly: boolean, appState: AppState, canvas: HTMLCanvasElement | null, ) => void; @@ -368,9 +369,9 @@ const LayerUI = ({ onExportToClipboard={createExporter("clipboard")} onExportToBackend={ onExportToBackend - ? (elements) => { + ? (elements, viewonly) => { onExportToBackend && - onExportToBackend(elements, appState, canvas); + onExportToBackend(elements, viewonly, appState, canvas); } : undefined } diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 9a9d87606..1d07364b5 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -113,6 +113,10 @@ export const questionCircle = createIcon( { mirror: true }, ); +export const eyeIcon = createIcon( + "M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z", +); + // Icon imported form Storybook // Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE export const resetZoom = createIcon( diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 0843b5495..43246be2c 100644 --- a/src/excalidraw-app/data/index.ts +++ b/src/excalidraw-app/data/index.ts @@ -251,6 +251,7 @@ export const loadScene = async ( export const exportToBackend = async ( elements: readonly ExcalidrawElement[], appState: AppState, + viewonly: boolean, ) => { const json = serializeAsJSON(elements, appState); const encoded = new TextEncoder().encode(json); @@ -288,9 +289,13 @@ export const exportToBackend = async ( const json = await response.json(); if (json.id) { const url = new URL(window.location.href); + url.hash = "#"; + if (viewonly) { + url.hash = `${url.hash}viewonly=true&`; + } // We need to store the key (and less importantly the id) as hash instead // of queryParam in order to never send it to the server - url.hash = `json=${json.id},${exportedKey.k!}`; + url.hash = `${url.hash}json=${json.id},${exportedKey.k!}`; const urlString = url.toString(); window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString); } else if (json.error_class === "RequestTooLargeError") { diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 96d8af30f..ba98da49d 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -70,9 +70,8 @@ const initializeScene = async (opts: { const searchParams = new URLSearchParams(window.location.search); const id = searchParams.get("id"); const jsonMatch = window.location.hash.match( - /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, + /json=([0-9]+),([a-zA-Z0-9_-]+)/, ); - const initialData = importFromLocalStorage(); let scene = await loadScene(null, null, initialData); @@ -94,6 +93,10 @@ const initializeScene = async (opts: { } else if (jsonMatch) { scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData); } + const isViewModeEnabled = !!window.location.hash.match(/#viewonly=true/); + if (isViewModeEnabled) { + scene.appState.viewModeEnabled = true; + } if (!roomLinkData) { window.history.replaceState({}, APP_NAME, window.location.origin); } @@ -134,6 +137,7 @@ function ExcalidrawWrapper() { const [errorMessage, setErrorMessage] = useState(""); const currentLangCode = languageDetector.detect() || defaultLang.code; const [langCode, setLangCode] = useState(currentLangCode); + const [viewModeEnabled, setViewModeEnabled] = useState(false); useLayoutEffect(() => { const onResize = () => { @@ -157,7 +161,6 @@ function ExcalidrawWrapper() { if (!initialStatePromiseRef.current.promise) { initialStatePromiseRef.current.promise = resolvablePromise(); } - useEffect(() => { // Delayed so that the app has a time to load the latest SW setTimeout(() => { @@ -178,12 +181,14 @@ function ExcalidrawWrapper() { } initializeScene({ collabAPI }).then((scene) => { + setViewModeEnabled(!!scene?.appState?.viewModeEnabled); initialStatePromiseRef.current.promise.resolve(scene); }); const onHashChange = (_: HashChangeEvent) => { initializeScene({ collabAPI }).then((scene) => { if (scene) { + setViewModeEnabled(!!scene.appState?.viewModeEnabled); excalidrawAPI.updateScene(scene); } }); @@ -224,6 +229,7 @@ function ExcalidrawWrapper() { const onExportToBackend = async ( exportedElements: readonly NonDeletedExcalidrawElement[], + viewonly: boolean, appState: AppState, canvas: HTMLCanvasElement | null, ) => { @@ -232,12 +238,16 @@ function ExcalidrawWrapper() { } if (canvas) { try { - await exportToBackend(exportedElements, { - ...appState, - viewBackgroundColor: appState.exportBackground - ? appState.viewBackgroundColor - : getDefaultAppState().viewBackgroundColor, - }); + await exportToBackend( + exportedElements, + { + ...appState, + viewBackgroundColor: appState.exportBackground + ? appState.viewBackgroundColor + : getDefaultAppState().viewBackgroundColor, + }, + viewonly, + ); } catch (error) { if (error.name !== "AbortError") { const { width, height } = canvas; @@ -287,6 +297,7 @@ function ExcalidrawWrapper() { onExportToBackend={onExportToBackend} renderFooter={renderFooter} langCode={langCode} + viewModeEnabled={viewModeEnabled} /> {excalidrawAPI && } {errorMessage && ( diff --git a/src/locales/en.json b/src/locales/en.json index 937f780a5..cca836544 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -106,6 +106,7 @@ "saveAs": "Save as", "load": "Load", "getShareableLink": "Get shareable link", + "getViewonlyShareableLink": "Get View only shareable link", "close": "Close", "selectLanguage": "Select language", "scrollBackToContent": "Scroll back to content", diff --git a/src/types.ts b/src/types.ts index ba0a3497e..9043c55b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -180,6 +180,7 @@ export interface ExcalidrawProps { }) => void; onExportToBackend?: ( exportedElements: readonly NonDeletedExcalidrawElement[], + viewonly: boolean, appState: AppState, canvas: HTMLCanvasElement | null, ) => void;