feat: add shareable viewonly links

This commit is contained in:
Aakansha Doshi 2021-02-20 01:47:03 +05:30
parent 74e82d0d7c
commit dcedba88a2
8 changed files with 67 additions and 22 deletions

View File

@ -66,4 +66,10 @@
overflow-y: auto; overflow-y: auto;
} }
} }
.shareable-link--viewonly {
svg {
width: 1.5em;
}
}
} }

View File

@ -12,7 +12,7 @@ import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types"; import { AppState } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import { clipboard, exportFile, link } from "./icons"; import { clipboard, exportFile, eyeIcon, link } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -66,7 +66,10 @@ const ExportModal = ({
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB; onExportToBackend?: (
elements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
) => void;
onCloseRequest: () => void; onCloseRequest: () => void;
}) => { }) => {
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
@ -155,13 +158,23 @@ const ExportModal = ({
/> />
)} )}
{onExportToBackend && ( {onExportToBackend && (
<>
<ToolButton <ToolButton
type="button" type="button"
icon={link} icon={link}
title={t("buttons.getShareableLink")} title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")} aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)} onClick={() => onExportToBackend(exportedElements, false)}
/> />
<ToolButton
type="button"
icon={eyeIcon}
className="shareable-link--viewonly"
title={t("buttons.getViewonlyShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements, true)}
/>
</>
)} )}
</Stack.Row> </Stack.Row>
<div className="ExportDialog__name"> <div className="ExportDialog__name">
@ -236,7 +249,10 @@ export const ExportDialog = ({
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB; onExportToBackend?: (
elements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
) => void;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false); const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null); const triggerButton = useRef<HTMLButtonElement>(null);

View File

@ -58,6 +58,7 @@ interface LayerUIProps {
isCollaborating: boolean; isCollaborating: boolean;
onExportToBackend?: ( onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
appState: AppState, appState: AppState,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement | null,
) => void; ) => void;
@ -368,9 +369,9 @@ const LayerUI = ({
onExportToClipboard={createExporter("clipboard")} onExportToClipboard={createExporter("clipboard")}
onExportToBackend={ onExportToBackend={
onExportToBackend onExportToBackend
? (elements) => { ? (elements, viewonly) => {
onExportToBackend && onExportToBackend &&
onExportToBackend(elements, appState, canvas); onExportToBackend(elements, viewonly, appState, canvas);
} }
: undefined : undefined
} }

View File

@ -113,6 +113,10 @@ export const questionCircle = createIcon(
{ mirror: true }, { 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 // Icon imported form Storybook
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE // Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
export const resetZoom = createIcon( export const resetZoom = createIcon(

View File

@ -251,6 +251,7 @@ export const loadScene = async (
export const exportToBackend = async ( export const exportToBackend = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
viewonly: boolean,
) => { ) => {
const json = serializeAsJSON(elements, appState); const json = serializeAsJSON(elements, appState);
const encoded = new TextEncoder().encode(json); const encoded = new TextEncoder().encode(json);
@ -288,9 +289,13 @@ export const exportToBackend = async (
const json = await response.json(); const json = await response.json();
if (json.id) { if (json.id) {
const url = new URL(window.location.href); 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 // 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 // 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(); const urlString = url.toString();
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString); window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
} else if (json.error_class === "RequestTooLargeError") { } else if (json.error_class === "RequestTooLargeError") {

View File

@ -70,9 +70,8 @@ const initializeScene = async (opts: {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id"); const id = searchParams.get("id");
const jsonMatch = window.location.hash.match( const jsonMatch = window.location.hash.match(
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, /json=([0-9]+),([a-zA-Z0-9_-]+)/,
); );
const initialData = importFromLocalStorage(); const initialData = importFromLocalStorage();
let scene = await loadScene(null, null, initialData); let scene = await loadScene(null, null, initialData);
@ -94,6 +93,10 @@ const initializeScene = async (opts: {
} else if (jsonMatch) { } else if (jsonMatch) {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData); scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
} }
const isViewModeEnabled = !!window.location.hash.match(/#viewonly=true/);
if (isViewModeEnabled) {
scene.appState.viewModeEnabled = true;
}
if (!roomLinkData) { if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin); window.history.replaceState({}, APP_NAME, window.location.origin);
} }
@ -134,6 +137,7 @@ function ExcalidrawWrapper() {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const currentLangCode = languageDetector.detect() || defaultLang.code; const currentLangCode = languageDetector.detect() || defaultLang.code;
const [langCode, setLangCode] = useState(currentLangCode); const [langCode, setLangCode] = useState(currentLangCode);
const [viewModeEnabled, setViewModeEnabled] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
const onResize = () => { const onResize = () => {
@ -157,7 +161,6 @@ function ExcalidrawWrapper() {
if (!initialStatePromiseRef.current.promise) { if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>(); initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
} }
useEffect(() => { useEffect(() => {
// Delayed so that the app has a time to load the latest SW // Delayed so that the app has a time to load the latest SW
setTimeout(() => { setTimeout(() => {
@ -178,12 +181,14 @@ function ExcalidrawWrapper() {
} }
initializeScene({ collabAPI }).then((scene) => { initializeScene({ collabAPI }).then((scene) => {
setViewModeEnabled(!!scene?.appState?.viewModeEnabled);
initialStatePromiseRef.current.promise.resolve(scene); initialStatePromiseRef.current.promise.resolve(scene);
}); });
const onHashChange = (_: HashChangeEvent) => { const onHashChange = (_: HashChangeEvent) => {
initializeScene({ collabAPI }).then((scene) => { initializeScene({ collabAPI }).then((scene) => {
if (scene) { if (scene) {
setViewModeEnabled(!!scene.appState?.viewModeEnabled);
excalidrawAPI.updateScene(scene); excalidrawAPI.updateScene(scene);
} }
}); });
@ -224,6 +229,7 @@ function ExcalidrawWrapper() {
const onExportToBackend = async ( const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
appState: AppState, appState: AppState,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement | null,
) => { ) => {
@ -232,12 +238,16 @@ function ExcalidrawWrapper() {
} }
if (canvas) { if (canvas) {
try { try {
await exportToBackend(exportedElements, { await exportToBackend(
exportedElements,
{
...appState, ...appState,
viewBackgroundColor: appState.exportBackground viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor ? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor, : getDefaultAppState().viewBackgroundColor,
}); },
viewonly,
);
} catch (error) { } catch (error) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
const { width, height } = canvas; const { width, height } = canvas;
@ -287,6 +297,7 @@ function ExcalidrawWrapper() {
onExportToBackend={onExportToBackend} onExportToBackend={onExportToBackend}
renderFooter={renderFooter} renderFooter={renderFooter}
langCode={langCode} langCode={langCode}
viewModeEnabled={viewModeEnabled}
/> />
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && ( {errorMessage && (

View File

@ -106,6 +106,7 @@
"saveAs": "Save as", "saveAs": "Save as",
"load": "Load", "load": "Load",
"getShareableLink": "Get shareable link", "getShareableLink": "Get shareable link",
"getViewonlyShareableLink": "Get View only shareable link",
"close": "Close", "close": "Close",
"selectLanguage": "Select language", "selectLanguage": "Select language",
"scrollBackToContent": "Scroll back to content", "scrollBackToContent": "Scroll back to content",

View File

@ -180,6 +180,7 @@ export interface ExcalidrawProps {
}) => void; }) => void;
onExportToBackend?: ( onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
appState: AppState, appState: AppState,
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement | null,
) => void; ) => void;