diff --git a/package.json b/package.json index cd9bc3cc..56bbe007 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ ] }, "dependencies": { + "@dwelle/browser-fs-access": "0.21.1", "@sentry/browser": "6.2.5", "@sentry/integrations": "6.2.5", "@testing-library/jest-dom": "5.11.10", @@ -27,7 +28,6 @@ "@types/react": "17.0.3", "@types/react-dom": "17.0.3", "@types/socket.io-client": "1.4.36", - "browser-fs-access": "0.20.5", "clsx": "1.1.1", "firebase": "8.3.3", "i18next-browser-languagedetector": "6.1.0", @@ -76,7 +76,7 @@ }, "jest": { "transformIgnorePatterns": [ - "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" + "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)" ], "resetMocks": false }, diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index bffa9298..605e0878 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -12,7 +12,6 @@ import { t } from "../i18n"; import { useIsMobile } from "../components/App"; import { KEYS } from "../keys"; 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"; @@ -20,6 +19,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getNonDeletedElements } from "../element"; import { ActiveFile } from "../components/ActiveFile"; import { isImageFileHandle } from "../data/blob"; +import { nativeFileSystemSupported } from "../data/filesystem"; export const actionChangeProjectName = register({ name: "changeProjectName", @@ -193,7 +193,7 @@ export const actionSaveFileToDisk = register({ title={t("buttons.saveAs")} aria-label={t("buttons.saveAs")} showAriaLabel={useIsMobile()} - hidden={!fsSupported} + hidden={!nativeFileSystemSupported} onClick={() => updateData(null)} data-testid="save-as-button" /> diff --git a/src/components/App.tsx b/src/components/App.tsx index 30074e43..436f45ec 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2,7 +2,6 @@ import React, { useContext } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; import rough from "roughjs/bin/rough"; import clsx from "clsx"; -import { supported as fsSupported } from "browser-fs-access"; import { nanoid } from "nanoid"; import { @@ -195,6 +194,7 @@ import LayerUI from "./LayerUI"; import { Stats } from "./Stats"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; +import { nativeFileSystemSupported } from "../data/filesystem"; const IsMobileContext = React.createContext(false); export const useIsMobile = () => useContext(IsMobileContext); @@ -3833,7 +3833,7 @@ class App extends React.Component { try { const file = event.dataTransfer.files[0]; if (file?.type === "image/png" || file?.type === "image/svg+xml") { - if (fsSupported) { + if (nativeFileSystemSupported) { try { // This will only work as of Chrome 86, // but can be safely ignored on older releases. @@ -3893,7 +3893,7 @@ class App extends React.Component { // default: assume an Excalidraw file regardless of extension/MimeType } else { this.setState({ isLoading: true }); - if (fsSupported) { + if (nativeFileSystemSupported) { try { // This will only work as of Chrome 86, // but can be safely ignored on older releases. diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index e02b43d8..36c12fd0 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -15,10 +15,10 @@ import { clipboard, exportImage } from "./icons"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import "./ExportDialog.scss"; -import { supported as fsSupported } from "browser-fs-access"; import OpenColor from "open-color"; import { CheckboxItem } from "./CheckboxItem"; import { DEFAULT_EXPORT_PADDING } from "../constants"; +import { nativeFileSystemSupported } from "../data/filesystem"; const supportsContextFilters = "filter" in document.createElement("canvas").getContext("2d")!; @@ -182,7 +182,8 @@ const ImageExportModal = ({ margin: ".6em 0", }} > - {!fsSupported && actionManager.renderAction("changeProjectName")} + {!nativeFileSystemSupported && + actionManager.renderAction("changeProjectName")} {t("exportDialog.disk_title")}
{t("exportDialog.disk_details")} - {!fsSupported && actionManager.renderAction("changeProjectName")} + {!nativeFileSystemSupported && + actionManager.renderAction("changeProjectName")}
= { + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + json: "application/json", + excalidraw: MIME_TYPES.excalidraw, + excalidrawlib: MIME_TYPES.excalidrawlib, +}; + +const INPUT_CHANGE_INTERVAL_MS = 500; + +export const fileOpen = (opts: { + extensions?: FILE_EXTENSION[]; + description?: string; + multiple?: M; +}): Promise< + M extends false | undefined ? FileWithHandle : FileWithHandle[] +> => { + // an unsafe TS hack, alas not much we can do AFAIK + type RetType = M extends false | undefined + ? FileWithHandle + : FileWithHandle[]; + + const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { + mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]); + + return mimeTypes; + }, [] as string[]); + + const extensions = opts.extensions?.reduce((acc, ext) => { + if (ext === "jpg") { + return acc.concat(".jpg", ".jpeg"); + } + return acc.concat(`.${ext}`); + }, [] as string[]); + + return _fileOpen({ + description: opts.description, + extensions, + mimeTypes, + multiple: opts.multiple ?? false, + legacySetup: (resolve, reject, input) => { + const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS); + const focusHandler = () => { + checkForFile(); + document.addEventListener(EVENT.KEYUP, scheduleRejection); + document.addEventListener(EVENT.POINTER_UP, scheduleRejection); + scheduleRejection(); + }; + const checkForFile = () => { + // this hack might not work when expecting multiple files + if (input.files?.length) { + const ret = opts.multiple ? [...input.files] : input.files[0]; + resolve(ret as RetType); + } + }; + requestAnimationFrame(() => { + window.addEventListener(EVENT.FOCUS, focusHandler); + }); + const interval = window.setInterval(() => { + checkForFile(); + }, INPUT_CHANGE_INTERVAL_MS); + return (rejectPromise) => { + clearInterval(interval); + scheduleRejection.cancel(); + window.removeEventListener(EVENT.FOCUS, focusHandler); + document.removeEventListener(EVENT.KEYUP, scheduleRejection); + document.removeEventListener(EVENT.POINTER_UP, scheduleRejection); + if (rejectPromise) { + // so that something is shown in console if we need to debug this + console.warn("Opening the file was canceled (legacy-fs)."); + rejectPromise(new AbortError()); + } + }; + }, + }) as Promise; +}; + +export const fileSave = ( + blob: Blob, + opts: { + /** supply without the extension */ + name: string; + /** file extension */ + extension: FILE_EXTENSION; + description?: string; + /** existing FileSystemHandle */ + fileHandle?: FileSystemHandle | null; + }, +) => { + return _fileSave( + blob, + { + fileName: `${opts.name}.${opts.extension}`, + description: opts.description, + extensions: [`.${opts.extension}`], + }, + opts.fileHandle, + ); +}; + +export type { FileSystemHandle }; +export { nativeFileSystemSupported }; diff --git a/src/data/index.ts b/src/data/index.ts index fc988986..4a4ef48c 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,4 +1,3 @@ -import { fileSave, FileSystemHandle } from "browser-fs-access"; import { copyBlobToClipboardAsPng, copyTextToSystemClipboard, @@ -10,6 +9,7 @@ import { exportToCanvas, exportToSvg } from "../scene/export"; import { ExportType } from "../scene/types"; import { AppState } from "../types"; import { canvasToBlob } from "./blob"; +import { fileSave, FileSystemHandle } from "./filesystem"; import { serializeAsJSON } from "./json"; export { loadFromBlob } from "./blob"; @@ -49,10 +49,10 @@ export const exportCanvas = async ( return await fileSave( new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), { - fileName: `${name}.svg`, - extensions: [".svg"], + name, + extension: "svg", + fileHandle, }, - fileHandle, ); } else if (type === "clipboard-svg") { await copyTextToSystemClipboard(tempSvg.outerHTML); @@ -71,7 +71,6 @@ export const exportCanvas = async ( tempCanvas.remove(); if (type === "png") { - const fileName = `${name}.png`; if (appState.exportEmbedScene) { blob = await ( await import(/* webpackChunkName: "image" */ "./image") @@ -81,14 +80,11 @@ export const exportCanvas = async ( }); } - return await fileSave( - blob, - { - fileName, - extensions: [".png"], - }, + return await fileSave(blob, { + name, + extension: "png", fileHandle, - ); + }); } else if (type === "clipboard") { try { await copyBlobToClipboardAsPng(blob); diff --git a/src/data/json.ts b/src/data/json.ts index ce130cd6..ea264e60 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 } from "./filesystem"; import { cleanAppStateForExport } from "../appState"; import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; @@ -37,15 +37,14 @@ export const saveAsJSON = async ( type: MIME_TYPES.excalidraw, }); - const fileHandle = await fileSave( - blob, - { - fileName: `${appState.name}.excalidraw`, - description: "Excalidraw file", - extensions: [".excalidraw"], - }, - isImageFileHandle(appState.fileHandle) ? null : appState.fileHandle, - ); + const fileHandle = await fileSave(blob, { + name: appState.name, + extension: "excalidraw", + description: "Excalidraw file", + fileHandle: isImageFileHandle(appState.fileHandle) + ? null + : appState.fileHandle, + }); return { fileHandle }; }; @@ -101,15 +100,16 @@ export const saveLibraryAsJSON = async (library: Library) => { library: libraryItems, }; const serialized = JSON.stringify(data, null, 2); - const fileName = "library.excalidrawlib"; - const blob = new Blob([serialized], { - type: MIME_TYPES.excalidrawlib, - }); - await fileSave(blob, { - fileName, - description: "Excalidraw library file", - extensions: [".excalidrawlib"], - }); + await fileSave( + new Blob([serialized], { + type: MIME_TYPES.excalidrawlib, + }), + { + name: "library", + extension: "excalidrawlib", + description: "Excalidraw library file", + }, + ); }; export const importLibraryFromJSON = async (library: Library) => { diff --git a/src/errors.ts b/src/errors.ts index bba8007f..e0444d10 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,9 @@ export class CanvasError extends Error { this.message = message; } } + +export class AbortError extends DOMException { + constructor(message: string = "Request Aborted") { + super(message, "AbortError"); + } +} diff --git a/src/types.ts b/src/types.ts index fb4c0534..c595ddab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ import { Language } from "./i18n"; import { ClipboardData } from "./clipboard"; import { isOverScrollBars } from "./scene"; import { MaybeTransformHandleType } from "./element/transformHandles"; +import { FileSystemHandle } from "./data/filesystem"; export type Point = Readonly; @@ -112,7 +113,7 @@ export type AppState = { offsetLeft: number; isLibraryOpen: boolean; - fileHandle: import("browser-fs-access").FileSystemHandle | null; + fileHandle: FileSystemHandle | null; collaborators: Map; showStats: boolean; currentChartType: ChartType; diff --git a/yarn.lock b/yarn.lock index e1e41bb3..50dc75ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1062,6 +1062,11 @@ enabled "2.0.x" kuler "^2.0.0" +"@dwelle/browser-fs-access@0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@dwelle/browser-fs-access/-/browser-fs-access-0.21.1.tgz#46a9c1c95a8b8da3887d95136dc8c1f65830cfa7" + integrity sha512-ryAWrTdFgB2IjUooBcKz2bSrVsAUqtjctLK6ByFGbqx7qxk+kqpjA4J54uiMcbvaJ17N/cYeserA6uxBIWIdsg== + "@eslint/eslintrc@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547" @@ -3218,11 +3223,6 @@ brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz" -browser-fs-access@0.20.5: - version "0.20.5" - resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.20.5.tgz#16bea029f6dc14787c8b394360f32f3b8cf06f50" - integrity sha512-ROPZ9ZYC4gptm0JRH/DgTm9dDLzUrOksBw8VMcUm7TINyaan5KUJPkklEurl0WTapfuy5T85GSP6bRmX/BpbnA== - browser-process-hrtime@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz"