diff --git a/package.json b/package.json index 9a3cd5570..8364fe701 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "clsx": "1.1.1", "firebase": "8.3.3", "i18next-browser-languagedetector": "6.1.0", + "idb-keyval": "5.0.6", "lodash.throttle": "4.1.1", "nanoid": "3.1.22", "open-color": "1.8.0", diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 0c46736a1..093763285 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -14,10 +14,11 @@ import { register } from "./register"; import { supported as fsSupported } from "browser-fs-access"; import { CheckboxItem } from "../components/CheckboxItem"; import { getExportSize } from "../scene/export"; -import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES } from "../constants"; +import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, IDB_KEYS } from "../constants"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getNonDeletedElements } from "../element"; import { ActiveFile } from "../components/ActiveFile"; +import * as idb from "idb-keyval"; export const actionChangeProjectName = register({ name: "changeProjectName", @@ -149,7 +150,10 @@ export const actionSaveToActiveFile = register({ if (error?.name !== "AbortError") { console.error(error); } - return { commitToHistory: false }; + return { + commitToHistory: false, + appState: { ...appState, fileHandle: null }, + }; } }, keyTest: (event) => @@ -170,6 +174,13 @@ export const actionSaveFileToDisk = register({ ...appState, fileHandle: null, }); + try { + if (fileHandle) { + await idb.set(IDB_KEYS.fileHandle, fileHandle); + } + } catch (error) { + console.error(error); + } return { commitToHistory: false, appState: { ...appState, fileHandle } }; } catch (error) { if (error?.name !== "AbortError") { diff --git a/src/components/App.tsx b/src/components/App.tsx index f97a0a8c5..62d07e3d4 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -52,6 +52,7 @@ import { ENV, EVENT, GRID_SIZE, + IDB_KEYS, LINE_CONFIRM_THRESHOLD, MIME_TYPES, MQ_MAX_HEIGHT_LANDSCAPE, @@ -194,6 +195,7 @@ import LayerUI from "./LayerUI"; import { Stats } from "./Stats"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; +import * as idb from "idb-keyval"; const IsMobileContext = React.createContext(false); export const useIsMobile = () => useContext(IsMobileContext); @@ -807,6 +809,15 @@ class App extends React.Component { } else { this.updateDOMRect(this.initializeScene); } + + try { + const fileHandle = await idb.get(IDB_KEYS.fileHandle); + if (fileHandle) { + this.setState({ fileHandle }); + } + } catch (error) { + console.error(error); + } } public componentWillUnmount() { diff --git a/src/constants.ts b/src/constants.ts index 89582a550..cb7bd4f43 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -97,6 +97,10 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_LIBRARY: "excalidraw-library", } as const; +export const IDB_KEYS = { + fileHandle: "fileHandle", +} as const; + // time in milliseconds export const TAP_TWICE_TIMEOUT = 300; export const TOUCH_CTX_MENU_TIMEOUT = 500; diff --git a/src/data/json.ts b/src/data/json.ts index 6ca7c75bf..213ae8450 100644 --- a/src/data/json.ts +++ b/src/data/json.ts @@ -1,4 +1,4 @@ -import { fileOpen, fileSave } from "browser-fs-access"; +import { fileOpen, fileSave, FileSystemHandle } from "browser-fs-access"; import { cleanAppStateForExport } from "../appState"; import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; @@ -12,6 +12,7 @@ import { ExportedLibraryData, } from "./types"; import Library from "./library"; +import { AbortError } from "../errors"; export const serializeAsJSON = ( elements: readonly ExcalidrawElement[], @@ -28,6 +29,26 @@ export const serializeAsJSON = ( return JSON.stringify(data, null, 2); }; +// adapted from https://web.dev/file-system-access +const verifyPermission = async (fileHandle: FileSystemHandle) => { + try { + const options = { mode: "readwrite" } as any; + // Check if permission was already granted. If so, return true. + if ((await fileHandle.queryPermission(options)) === "granted") { + return true; + } + // Request permission. If the user grants permission, return true. + if ((await fileHandle.requestPermission(options)) === "granted") { + return true; + } + // The user didn't grant permission, so return false. + return false; + } catch (error) { + console.error(error); + return false; + } +}; + export const saveAsJSON = async ( elements: readonly ExcalidrawElement[], appState: AppState, @@ -37,6 +58,12 @@ export const saveAsJSON = async ( type: MIME_TYPES.excalidraw, }); + if (appState.fileHandle) { + if (!(await verifyPermission(appState.fileHandle))) { + throw new AbortError(); + } + } + const fileHandle = await fileSave( blob, { diff --git a/src/errors.ts b/src/errors.ts index bba8007ff..b576fe469 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,5 @@ type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG"; + export class CanvasError extends Error { constructor( message: string = "Couldn't export canvas.", @@ -9,3 +10,11 @@ export class CanvasError extends Error { this.message = message; } } + +export class AbortError extends Error { + constructor(message: string = "Request aborted") { + super(); + this.name = "AbortError"; + this.message = message; + } +} diff --git a/yarn.lock b/yarn.lock index 898bec6df..d8ca05fb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6536,6 +6536,11 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" +idb-keyval@5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-5.0.6.tgz#62fe4a6703fb5ec86661f41330c94fda65e6d0e6" + integrity sha512-6lJuVbwyo82mKSH6Wq2eHkt9LcbwHAelMIcMe0tP4p20Pod7tTxq9zf0ge2n/YDfMOpDryerfmmYyuQiaFaKOg== + idb@3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz"