fix: abstract and fix legacy fs (#4032)

This commit is contained in:
David Luzar 2021-10-07 13:19:40 +02:00 committed by GitHub
parent 75aeaa6c38
commit 54739cd2df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 179 additions and 50 deletions

View File

@ -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
},

View File

@ -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"
/>

View File

@ -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<AppProps, AppState> {
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<AppProps, AppState> {
// 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.

View File

@ -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")}
</div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton

View File

@ -11,7 +11,7 @@ import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
import { supported as fsSupported } from "browser-fs-access";
import { nativeFileSystemSupported } from "../data/filesystem";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@ -42,7 +42,8 @@ const JSONExportModal = ({
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!fsSupported && actionManager.renderAction("changeProjectName")}
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"

View File

@ -35,6 +35,7 @@ export enum EVENT {
MOUSE_MOVE = "mousemove",
RESIZE = "resize",
UNLOAD = "unload",
FOCUS = "focus",
BLUR = "blur",
DRAG_OVER = "dragover",
DROP = "drop",
@ -84,7 +85,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
};
} as const;
export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw",

View File

@ -1,4 +1,3 @@
import { FileSystemHandle } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState";
import { EXPORT_DATA_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
@ -7,6 +6,7 @@ import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState } from "../types";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
import { ImportedLibraryData } from "./types";

122
src/data/filesystem.ts Normal file
View File

@ -0,0 +1,122 @@
import {
FileWithHandle,
fileOpen as _fileOpen,
fileSave as _fileSave,
FileSystemHandle,
supported as nativeFileSystemSupported,
} from "@dwelle/browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION =
| "jpg"
| "png"
| "svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
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 = <M extends boolean | undefined = false>(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<RetType>;
};
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 };

View File

@ -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);

View File

@ -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) => {

View File

@ -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");
}
}

View File

@ -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<RoughPoint>;
@ -112,7 +113,7 @@ export type AppState = {
offsetLeft: number;
isLibraryOpen: boolean;
fileHandle: import("browser-fs-access").FileSystemHandle | null;
fileHandle: FileSystemHandle | null;
collaborators: Map<string, Collaborator>;
showStats: boolean;
currentChartType: ChartType;

View File

@ -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"