mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: restore svg image DataURL dimensions (#8730)
This commit is contained in:
parent
f9815b8b4f
commit
79b181bcdc
13 changed files with 196 additions and 94 deletions
|
@ -1,6 +1,7 @@
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import { PureComponent } from "react";
|
||||||
import type {
|
import type {
|
||||||
|
BinaryFileData,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
SocketId,
|
SocketId,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
@ -9,6 +10,7 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
FileId,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
|
@ -157,7 +159,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveFilesToFirebase({
|
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
|
||||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||||
files: await encodeFilesForUpload({
|
files: await encodeFilesForUpload({
|
||||||
files: addedFiles,
|
files: addedFiles,
|
||||||
|
@ -165,6 +167,29 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedFiles: savedFiles.reduce(
|
||||||
|
(acc: Map<FileId, BinaryFileData>, id) => {
|
||||||
|
const fileData = addedFiles.get(id);
|
||||||
|
if (fileData) {
|
||||||
|
acc.set(id, fileData);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
new Map(),
|
||||||
|
),
|
||||||
|
erroredFiles: erroredFiles.reduce(
|
||||||
|
(acc: Map<FileId, BinaryFileData>, id) => {
|
||||||
|
const fileData = addedFiles.get(id);
|
||||||
|
if (fileData) {
|
||||||
|
acc.set(id, fileData);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
new Map(),
|
||||||
|
),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.excalidrawAPI = props.excalidrawAPI;
|
this.excalidrawAPI = props.excalidrawAPI;
|
||||||
|
@ -394,7 +419,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
!this.fileManager.isFileHandled(element.fileId) &&
|
!this.fileManager.isFileTracked(element.fileId) &&
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
(opts.forceFetchFiles
|
(opts.forceFetchFiles
|
||||||
? element.status !== "pending" ||
|
? element.status !== "pending" ||
|
||||||
|
|
|
@ -16,14 +16,26 @@ import type {
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
|
||||||
|
type FileVersion = Required<BinaryFileData>["version"];
|
||||||
|
|
||||||
export class FileManager {
|
export class FileManager {
|
||||||
/** files being fetched */
|
/** files being fetched */
|
||||||
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||||
|
private erroredFiles_fetch = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
true
|
||||||
|
>();
|
||||||
/** files being saved */
|
/** files being saved */
|
||||||
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private savingFiles = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
/* files already saved to persistent storage */
|
/* files already saved to persistent storage */
|
||||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
|
||||||
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private erroredFiles_save = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
|
|
||||||
private _getFiles;
|
private _getFiles;
|
||||||
private _saveFiles;
|
private _saveFiles;
|
||||||
|
@ -37,8 +49,8 @@ export class FileManager {
|
||||||
erroredFiles: Map<FileId, true>;
|
erroredFiles: Map<FileId, true>;
|
||||||
}>;
|
}>;
|
||||||
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
|
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
|
||||||
savedFiles: Map<FileId, true>;
|
savedFiles: Map<FileId, BinaryFileData>;
|
||||||
erroredFiles: Map<FileId, true>;
|
erroredFiles: Map<FileId, BinaryFileData>;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
this._getFiles = getFiles;
|
this._getFiles = getFiles;
|
||||||
|
@ -46,19 +58,28 @@ export class FileManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns whether file is already saved or being processed
|
* returns whether file is saved/errored, or being processed
|
||||||
*/
|
*/
|
||||||
isFileHandled = (id: FileId) => {
|
isFileTracked = (id: FileId) => {
|
||||||
return (
|
return (
|
||||||
this.savedFiles.has(id) ||
|
this.savedFiles.has(id) ||
|
||||||
this.fetchingFiles.has(id) ||
|
|
||||||
this.savingFiles.has(id) ||
|
this.savingFiles.has(id) ||
|
||||||
this.erroredFiles.has(id)
|
this.fetchingFiles.has(id) ||
|
||||||
|
this.erroredFiles_fetch.has(id) ||
|
||||||
|
this.erroredFiles_save.has(id)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
isFileSaved = (id: FileId) => {
|
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
|
||||||
return this.savedFiles.has(id);
|
const fileVersion = this.getFileVersion(file);
|
||||||
|
return (
|
||||||
|
this.savedFiles.get(file.id) === fileVersion ||
|
||||||
|
this.savingFiles.get(file.id) === fileVersion
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getFileVersion = (file: BinaryFileData) => {
|
||||||
|
return file.version ?? 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
saveFiles = async ({
|
saveFiles = async ({
|
||||||
|
@ -71,13 +92,16 @@ export class FileManager {
|
||||||
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
const fileData =
|
||||||
|
isInitializedImageElement(element) && files[element.fileId];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isInitializedImageElement(element) &&
|
fileData &&
|
||||||
files[element.fileId] &&
|
// NOTE if errored during save, won't retry due to this check
|
||||||
!this.isFileHandled(element.fileId)
|
!this.isFileSavedOrBeingSaved(fileData)
|
||||||
) {
|
) {
|
||||||
addedFiles.set(element.fileId, files[element.fileId]);
|
addedFiles.set(element.fileId, files[element.fileId]);
|
||||||
this.savingFiles.set(element.fileId, true);
|
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,8 +110,12 @@ export class FileManager {
|
||||||
addedFiles,
|
addedFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [fileId] of savedFiles) {
|
for (const [fileId, fileData] of savedFiles) {
|
||||||
this.savedFiles.set(fileId, true);
|
this.savedFiles.set(fileId, this.getFileVersion(fileData));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fileId, fileData] of erroredFiles) {
|
||||||
|
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -121,10 +149,10 @@ export class FileManager {
|
||||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||||
|
|
||||||
for (const file of loadedFiles) {
|
for (const file of loadedFiles) {
|
||||||
this.savedFiles.set(file.id, true);
|
this.savedFiles.set(file.id, this.getFileVersion(file));
|
||||||
}
|
}
|
||||||
for (const [fileId] of erroredFiles) {
|
for (const [fileId] of erroredFiles) {
|
||||||
this.erroredFiles.set(fileId, true);
|
this.erroredFiles_fetch.set(fileId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loadedFiles, erroredFiles };
|
return { loadedFiles, erroredFiles };
|
||||||
|
@ -160,7 +188,7 @@ export class FileManager {
|
||||||
): element is InitializedExcalidrawImageElement => {
|
): element is InitializedExcalidrawImageElement => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
this.isFileSaved(element.fileId) &&
|
this.savedFiles.has(element.fileId) &&
|
||||||
element.status === "pending"
|
element.status === "pending"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -169,7 +197,8 @@ export class FileManager {
|
||||||
this.fetchingFiles.clear();
|
this.fetchingFiles.clear();
|
||||||
this.savingFiles.clear();
|
this.savingFiles.clear();
|
||||||
this.savedFiles.clear();
|
this.savedFiles.clear();
|
||||||
this.erroredFiles.clear();
|
this.erroredFiles_fetch.clear();
|
||||||
|
this.erroredFiles_save.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -183,8 +183,8 @@ export class LocalData {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async saveFiles({ addedFiles }) {
|
async saveFiles({ addedFiles }) {
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles = new Map<FileId, BinaryFileData>();
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles = new Map<FileId, BinaryFileData>();
|
||||||
|
|
||||||
// before we use `storage` event synchronization, let's update the flag
|
// before we use `storage` event synchronization, let's update the flag
|
||||||
// optimistically. Hopefully nothing fails, and an IDB read executed
|
// optimistically. Hopefully nothing fails, and an IDB read executed
|
||||||
|
@ -195,10 +195,10 @@ export class LocalData {
|
||||||
[...addedFiles].map(async ([id, fileData]) => {
|
[...addedFiles].map(async ([id, fileData]) => {
|
||||||
try {
|
try {
|
||||||
await set(id, fileData, filesStore);
|
await set(id, fileData, filesStore);
|
||||||
savedFiles.set(id, true);
|
savedFiles.set(id, fileData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.set(id, fileData);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
|
||||||
}) => {
|
}) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles: FileId[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async ({ id, buffer }) => {
|
files.map(async ({ id, buffer }) => {
|
||||||
|
@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
|
||||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
savedFiles.set(id, true);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.push(id);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
@ -304,8 +304,10 @@ import { Toast } from "./Toast";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import {
|
import {
|
||||||
dataURLToFile,
|
dataURLToFile,
|
||||||
|
dataURLToString,
|
||||||
generateIdFromFile,
|
generateIdFromFile,
|
||||||
getDataURL,
|
getDataURL,
|
||||||
|
getDataURL_sync,
|
||||||
getFileFromEvent,
|
getFileFromEvent,
|
||||||
ImageURLToFile,
|
ImageURLToFile,
|
||||||
isImageFileHandle,
|
isImageFileHandle,
|
||||||
|
@ -2122,9 +2124,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionResult.files) {
|
if (actionResult.files) {
|
||||||
this.files = actionResult.replaceFiles
|
this.addMissingFiles(actionResult.files, actionResult.replaceFiles);
|
||||||
? actionResult.files
|
|
||||||
: { ...this.files, ...actionResult.files };
|
|
||||||
this.addNewImagesToImageCache();
|
this.addNewImagesToImageCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3237,7 +3237,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (opts.files) {
|
if (opts.files) {
|
||||||
this.files = { ...this.files, ...opts.files };
|
this.addMissingFiles(opts.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.shouldCaptureIncrement();
|
||||||
|
@ -3746,23 +3746,56 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** adds supplied files to existing files in the appState */
|
/**
|
||||||
|
* adds supplied files to existing files in the appState.
|
||||||
|
* NOTE if file already exists in editor state, the file data is not updated
|
||||||
|
* */
|
||||||
public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates(
|
public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates(
|
||||||
(files) => {
|
(files) => {
|
||||||
const filesMap = files.reduce((acc, fileData) => {
|
const { addedFiles } = this.addMissingFiles(files);
|
||||||
acc.set(fileData.id, fileData);
|
|
||||||
return acc;
|
|
||||||
}, new Map<FileId, BinaryFileData>());
|
|
||||||
|
|
||||||
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
|
this.clearImageShapeCache(addedFiles);
|
||||||
|
|
||||||
this.clearImageShapeCache(Object.fromEntries(filesMap));
|
|
||||||
this.scene.triggerUpdate();
|
this.scene.triggerUpdate();
|
||||||
|
|
||||||
this.addNewImagesToImageCache();
|
this.addNewImagesToImageCache();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private addMissingFiles = (
|
||||||
|
files: BinaryFiles | BinaryFileData[],
|
||||||
|
replace = false,
|
||||||
|
) => {
|
||||||
|
const nextFiles = replace ? {} : { ...this.files };
|
||||||
|
const addedFiles: BinaryFiles = {};
|
||||||
|
|
||||||
|
const _files = Array.isArray(files) ? files : Object.values(files);
|
||||||
|
|
||||||
|
for (const fileData of _files) {
|
||||||
|
if (nextFiles[fileData.id]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
addedFiles[fileData.id] = fileData;
|
||||||
|
nextFiles[fileData.id] = fileData;
|
||||||
|
|
||||||
|
if (fileData.mimeType === MIME_TYPES.svg) {
|
||||||
|
const restoredDataURL = getDataURL_sync(
|
||||||
|
normalizeSVG(dataURLToString(fileData.dataURL)),
|
||||||
|
MIME_TYPES.svg,
|
||||||
|
);
|
||||||
|
if (fileData.dataURL !== restoredDataURL) {
|
||||||
|
// bump version so persistence layer can update the store
|
||||||
|
fileData.version = (fileData.version ?? 1) + 1;
|
||||||
|
fileData.dataURL = restoredDataURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.files = nextFiles;
|
||||||
|
|
||||||
|
return { addedFiles };
|
||||||
|
};
|
||||||
|
|
||||||
public updateScene = withBatchedUpdates(
|
public updateScene = withBatchedUpdates(
|
||||||
<K extends keyof AppState>(sceneData: {
|
<K extends keyof AppState>(sceneData: {
|
||||||
elements?: SceneData["elements"];
|
elements?: SceneData["elements"];
|
||||||
|
@ -9285,7 +9318,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
if (mimeType === MIME_TYPES.svg) {
|
if (mimeType === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
imageFile = SVGStringToFile(
|
imageFile = SVGStringToFile(
|
||||||
await normalizeSVG(await imageFile.text()),
|
normalizeSVG(await imageFile.text()),
|
||||||
imageFile.name,
|
imageFile.name,
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
@ -9353,16 +9386,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
|
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
|
||||||
async (resolve, reject) => {
|
async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
this.files = {
|
this.addMissingFiles([
|
||||||
...this.files,
|
{
|
||||||
[fileId]: {
|
|
||||||
mimeType,
|
mimeType,
|
||||||
id: fileId,
|
id: fileId,
|
||||||
dataURL,
|
dataURL,
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
lastRetrieved: Date.now(),
|
lastRetrieved: Date.now(),
|
||||||
},
|
},
|
||||||
};
|
]);
|
||||||
const cachedImageData = this.imageCache.get(fileId);
|
const cachedImageData = this.imageCache.get(fileId);
|
||||||
if (!cachedImageData) {
|
if (!cachedImageData) {
|
||||||
this.addNewImagesToImageCache();
|
this.addNewImagesToImageCache();
|
||||||
|
|
|
@ -8,13 +8,14 @@ import { calculateScrollCenter } from "../scene";
|
||||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
import { bytesToHexString, isPromiseLike } from "../utils";
|
import { bytesToHexString, isPromiseLike } from "../utils";
|
||||||
|
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
||||||
import type { FileSystemHandle } from "./filesystem";
|
import type { FileSystemHandle } from "./filesystem";
|
||||||
import { nativeFileSystemSupported } from "./filesystem";
|
import { nativeFileSystemSupported } from "./filesystem";
|
||||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
import { restore, restoreLibraryItems } from "./restore";
|
import { restore, restoreLibraryItems } from "./restore";
|
||||||
import type { ImportedLibraryData } from "./types";
|
import type { ImportedLibraryData } from "./types";
|
||||||
|
|
||||||
const parseFileContents = async (blob: Blob | File) => {
|
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
|
|
||||||
if (blob.type === MIME_TYPES.png) {
|
if (blob.type === MIME_TYPES.png) {
|
||||||
|
@ -46,9 +47,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return await (
|
return (await import("./image")).decodeSvgMetadata({
|
||||||
await import("./image")
|
|
||||||
).decodeSvgMetadata({
|
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
@ -249,6 +248,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** async. For sync variant, use getDataURL_sync */
|
||||||
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
@ -261,6 +261,16 @@ export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDataURL_sync = (
|
||||||
|
data: string | Uint8Array | ArrayBuffer,
|
||||||
|
mimeType: ValueOf<typeof MIME_TYPES>,
|
||||||
|
): DataURL => {
|
||||||
|
return `data:${mimeType};base64,${stringToBase64(
|
||||||
|
toByteString(data),
|
||||||
|
true,
|
||||||
|
)}` as DataURL;
|
||||||
|
};
|
||||||
|
|
||||||
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
||||||
const dataIndexStart = dataURL.indexOf(",");
|
const dataIndexStart = dataURL.indexOf(",");
|
||||||
const byteString = atob(dataURL.slice(dataIndexStart + 1));
|
const byteString = atob(dataURL.slice(dataIndexStart + 1));
|
||||||
|
@ -274,6 +284,10 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
||||||
return new File([ab], filename, { type: mimeType });
|
return new File([ab], filename, { type: mimeType });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dataURLToString = (dataURL: DataURL) => {
|
||||||
|
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
|
||||||
|
};
|
||||||
|
|
||||||
export const resizeImageFile = async (
|
export const resizeImageFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
opts: {
|
opts: {
|
||||||
|
|
|
@ -5,24 +5,23 @@ import { encryptData, decryptData } from "./encryption";
|
||||||
// byte (binary) strings
|
// byte (binary) strings
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// fast, Buffer-compatible implem
|
// Buffer-compatible implem.
|
||||||
export const toByteString = (
|
//
|
||||||
data: string | Uint8Array | ArrayBuffer,
|
// Note that in V8, spreading the uint8array (by chunks) into
|
||||||
): Promise<string> => {
|
// `String.fromCharCode(...uint8array)` tends to be faster for large
|
||||||
return new Promise((resolve, reject) => {
|
// strings/buffers, in case perf is needed in the future.
|
||||||
const blob =
|
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
|
||||||
typeof data === "string"
|
const bytes =
|
||||||
? new Blob([new TextEncoder().encode(data)])
|
typeof data === "string"
|
||||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
? new TextEncoder().encode(data)
|
||||||
const reader = new FileReader();
|
: data instanceof Uint8Array
|
||||||
reader.onload = (event) => {
|
? data
|
||||||
if (!event.target || typeof event.target.result !== "string") {
|
: new Uint8Array(data);
|
||||||
return reject(new Error("couldn't convert to byte string"));
|
let bstring = "";
|
||||||
}
|
for (const byte of bytes) {
|
||||||
resolve(event.target.result);
|
bstring += String.fromCharCode(byte);
|
||||||
};
|
}
|
||||||
reader.readAsBinaryString(blob);
|
return bstring;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const byteStringToArrayBuffer = (byteString: string) => {
|
const byteStringToArrayBuffer = (byteString: string) => {
|
||||||
|
@ -46,12 +45,12 @@ const byteStringToString = (byteString: string) => {
|
||||||
* @param isByteString set to true if already byte string to prevent bloat
|
* @param isByteString set to true if already byte string to prevent bloat
|
||||||
* due to reencoding
|
* due to reencoding
|
||||||
*/
|
*/
|
||||||
export const stringToBase64 = async (str: string, isByteString = false) => {
|
export const stringToBase64 = (str: string, isByteString = false) => {
|
||||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
|
||||||
};
|
};
|
||||||
|
|
||||||
// async to align with stringToBase64
|
// async to align with stringToBase64
|
||||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
export const base64ToString = (base64: string, isByteString = false) => {
|
||||||
return isByteString
|
return isByteString
|
||||||
? window.atob(base64)
|
? window.atob(base64)
|
||||||
: byteStringToString(window.atob(base64));
|
: byteStringToString(window.atob(base64));
|
||||||
|
@ -82,18 +81,18 @@ type EncodedData = {
|
||||||
/**
|
/**
|
||||||
* Encodes (and potentially compresses via zlib) text to byte string
|
* Encodes (and potentially compresses via zlib) text to byte string
|
||||||
*/
|
*/
|
||||||
export const encode = async ({
|
export const encode = ({
|
||||||
text,
|
text,
|
||||||
compress,
|
compress,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
/** defaults to `true`. If compression fails, falls back to bstring alone. */
|
/** defaults to `true`. If compression fails, falls back to bstring alone. */
|
||||||
compress?: boolean;
|
compress?: boolean;
|
||||||
}): Promise<EncodedData> => {
|
}): EncodedData => {
|
||||||
let deflated!: string;
|
let deflated!: string;
|
||||||
if (compress !== false) {
|
if (compress !== false) {
|
||||||
try {
|
try {
|
||||||
deflated = await toByteString(deflate(text));
|
deflated = toByteString(deflate(text));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("encode: cannot deflate", error);
|
console.error("encode: cannot deflate", error);
|
||||||
}
|
}
|
||||||
|
@ -102,11 +101,11 @@ export const encode = async ({
|
||||||
version: "1",
|
version: "1",
|
||||||
encoding: "bstring",
|
encoding: "bstring",
|
||||||
compressed: !!deflated,
|
compressed: !!deflated,
|
||||||
encoded: deflated || (await toByteString(text)),
|
encoded: deflated || toByteString(text),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decode = async (data: EncodedData): Promise<string> => {
|
export const decode = (data: EncodedData): string => {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
|
|
||||||
switch (data.encoding) {
|
switch (data.encoding) {
|
||||||
|
@ -114,7 +113,7 @@ export const decode = async (data: EncodedData): Promise<string> => {
|
||||||
// if compressed, do not double decode the bstring
|
// if compressed, do not double decode the bstring
|
||||||
decoded = data.compressed
|
decoded = data.compressed
|
||||||
? data.encoded
|
? data.encoded
|
||||||
: await byteStringToString(data.encoded);
|
: byteStringToString(data.encoded);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
|
||||||
const metadataChunk = tEXt.encode(
|
const metadataChunk = tEXt.encode(
|
||||||
MIME_TYPES.excalidraw,
|
MIME_TYPES.excalidraw,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
await encode({
|
encode({
|
||||||
text: metadata,
|
text: metadata,
|
||||||
compress: true,
|
compress: true,
|
||||||
}),
|
}),
|
||||||
|
@ -59,7 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => {
|
||||||
}
|
}
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return decode(encodedData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
|
@ -72,9 +72,9 @@ export const decodePngMetadata = async (blob: Blob) => {
|
||||||
// SVG
|
// SVG
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
export const encodeSvgMetadata = ({ text }: { text: string }) => {
|
||||||
const base64 = await stringToBase64(
|
const base64 = stringToBase64(
|
||||||
JSON.stringify(await encode({ text })),
|
JSON.stringify(encode({ text })),
|
||||||
true /* is already byte string */,
|
true /* is already byte string */,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
||||||
return metadata;
|
return metadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
|
||||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||||
const match = svg.match(
|
const match = svg.match(
|
||||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||||
|
@ -100,7 +100,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||||
const isByteString = version !== "1";
|
const isByteString = version !== "1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = await base64ToString(match[1], isByteString);
|
const json = base64ToString(match[1], isByteString);
|
||||||
const encodedData = JSON.parse(json);
|
const encodedData = JSON.parse(json);
|
||||||
if (!("encoded" in encodedData)) {
|
if (!("encoded" in encodedData)) {
|
||||||
// legacy, un-encoded scene JSON
|
// legacy, un-encoded scene JSON
|
||||||
|
@ -112,7 +112,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||||
}
|
}
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return decode(encodedData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
|
|
|
@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
|
||||||
return node?.nodeName.toLowerCase() === "svg";
|
return node?.nodeName.toLowerCase() === "svg";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeSVG = async (SVGString: string) => {
|
export const normalizeSVG = (SVGString: string) => {
|
||||||
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
|
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
|
||||||
const svg = doc.querySelector("svg");
|
const svg = doc.querySelector("svg");
|
||||||
const errorNode = doc.querySelector("parsererror");
|
const errorNode = doc.querySelector("parsererror");
|
||||||
|
|
|
@ -319,9 +319,7 @@ export const exportToSvg = async (
|
||||||
// the tempScene hack which duplicates and regenerates ids
|
// the tempScene hack which duplicates and regenerates ids
|
||||||
if (exportEmbedScene) {
|
if (exportEmbedScene) {
|
||||||
try {
|
try {
|
||||||
metadata = await (
|
metadata = (await import("../data/image")).encodeSvgMetadata({
|
||||||
await import("../data/image")
|
|
||||||
).encodeSvgMetadata({
|
|
||||||
// when embedding scene, we want to embed the origionally supplied
|
// when embedding scene, we want to embed the origionally supplied
|
||||||
// elements which don't contain the temp frame labels.
|
// elements which don't contain the temp frame labels.
|
||||||
// But it also requires that the exportToSvg is being supplied with
|
// But it also requires that the exportToSvg is being supplied with
|
||||||
|
|
|
@ -62,10 +62,10 @@ describe("export", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test encoding/decoding scene for SVG export", async () => {
|
it("test encoding/decoding scene for SVG export", async () => {
|
||||||
const encoded = await encodeSvgMetadata({
|
const encoded = encodeSvgMetadata({
|
||||||
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded }));
|
const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded }));
|
||||||
expect(decoded.elements).toEqual([
|
expect(decoded.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -106,6 +106,11 @@ export type BinaryFileData = {
|
||||||
* Epoch timestamp in milliseconds.
|
* Epoch timestamp in milliseconds.
|
||||||
*/
|
*/
|
||||||
lastRetrieved?: number;
|
lastRetrieved?: number;
|
||||||
|
/**
|
||||||
|
* indicates the version of the file. This can be used to determine whether
|
||||||
|
* the file dataURL has changed e.g. as part of restore due to schema update.
|
||||||
|
*/
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
|
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
|
||||||
|
|
|
@ -27,7 +27,7 @@ describe("embedding scene data", () => {
|
||||||
|
|
||||||
const svg = svgNode.outerHTML;
|
const svg = svgNode.outerHTML;
|
||||||
|
|
||||||
const parsedString = await decodeSvgMetadata({ svg });
|
const parsedString = decodeSvgMetadata({ svg });
|
||||||
const importedData: ImportedDataState = JSON.parse(parsedString);
|
const importedData: ImportedDataState = JSON.parse(parsedString);
|
||||||
|
|
||||||
expect(sourceElements.map((x) => x.id)).toEqual(
|
expect(sourceElements.map((x) => x.id)).toEqual(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue