mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-27 19:29:55 +02:00
Compare commits
2 Commits
chore/mtol
...
ryan-di/fi
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5457d9c39a | ||
![]() |
cff7516318 |
23
.github/workflows/stale-issues.yml
vendored
23
.github/workflows/stale-issues.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 180
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 90 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 180 days since being marked as stale."
|
||||
exempt-issue-assignees: "dwelle,ryan-di,Mrazator,ad1992,zsviczian,mtolmacs"
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
@@ -112,7 +112,9 @@ import {
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importFromIndexedDB,
|
||||
importUsernameFromLocalStorage,
|
||||
migrateFromLocalStorageToIndexedDB,
|
||||
} from "./data/localStorage";
|
||||
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
@@ -218,7 +220,15 @@ const initializeScene = async (opts: {
|
||||
);
|
||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
// migrate from localStorage to IndexedDB if needed
|
||||
await migrateFromLocalStorageToIndexedDB();
|
||||
|
||||
// try to load from IndexedDB first, fallback to localStorage
|
||||
let localDataState = await importFromIndexedDB();
|
||||
if (!localDataState.elements.length && !localDataState.appState) {
|
||||
// fallback to localStorage if IndexedDB is empty
|
||||
localDataState = importFromLocalStorage();
|
||||
}
|
||||
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
@@ -504,7 +514,7 @@ const ExcalidrawWrapper = () => {
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
const syncData = debounce(async () => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
@@ -514,7 +524,12 @@ const ExcalidrawWrapper = () => {
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
// try to load from IndexedDB first, fallback to localStorage
|
||||
let localDataState = await importFromIndexedDB();
|
||||
if (!localDataState.elements.length && !localDataState.appState) {
|
||||
// fallback to localStorage if IndexedDB is empty
|
||||
localDataState = importFromLocalStorage();
|
||||
}
|
||||
const username = importUsernameFromLocalStorage();
|
||||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
|
@@ -21,11 +21,23 @@ type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
|
||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
cb({
|
||||
scene: getElementsStorageSize(),
|
||||
total: getTotalStorageSize(),
|
||||
});
|
||||
const getStorageSizes = debounce(async (cb: (sizes: StorageSizes) => void) => {
|
||||
try {
|
||||
const [scene, total] = await Promise.all([
|
||||
getElementsStorageSize(),
|
||||
getTotalStorageSize(),
|
||||
]);
|
||||
cb({
|
||||
scene,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get storage sizes:", error);
|
||||
cb({
|
||||
scene: 0,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
}, STORAGE_SIZE_TIMEOUT);
|
||||
|
||||
type Props = {
|
||||
|
@@ -65,7 +65,7 @@ class LocalFileManager extends FileManager {
|
||||
};
|
||||
}
|
||||
|
||||
const saveDataStateToLocalStorage = (
|
||||
const saveDataStateToIndexedDB = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
@@ -79,17 +79,15 @@ const saveDataStateToLocalStorage = (
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
// save to IndexedDB
|
||||
await Promise.all([
|
||||
ElementsIndexedDBAdapter.save(clearElementsForLocalStorage(elements)),
|
||||
AppStateIndexedDBAdapter.save(_appState),
|
||||
]);
|
||||
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
// unable to access IndexedDB
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -104,7 +102,7 @@ export class LocalData {
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
saveDataStateToLocalStorage(elements, appState);
|
||||
await saveDataStateToIndexedDB(elements, appState);
|
||||
|
||||
await this.fileStorage.saveFiles({
|
||||
elements,
|
||||
@@ -256,3 +254,63 @@ export class LibraryLocalStorageMigrationAdapter {
|
||||
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB Adapter for storing app state */
|
||||
export class AppStateIndexedDBAdapter {
|
||||
/** IndexedDB database and store name */
|
||||
private static idb_name = "excalidraw-app-state";
|
||||
/** app state data store key */
|
||||
private static key = "appStateData";
|
||||
|
||||
private static store = createStore(
|
||||
`${AppStateIndexedDBAdapter.idb_name}-db`,
|
||||
`${AppStateIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async load() {
|
||||
const IDBData = await get<Partial<AppState>>(
|
||||
AppStateIndexedDBAdapter.key,
|
||||
AppStateIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
return IDBData || null;
|
||||
}
|
||||
|
||||
static save(data: Partial<AppState>): MaybePromise<void> {
|
||||
return set(
|
||||
AppStateIndexedDBAdapter.key,
|
||||
data,
|
||||
AppStateIndexedDBAdapter.store,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB Adapter for storing elements */
|
||||
export class ElementsIndexedDBAdapter {
|
||||
/** IndexedDB database and store name */
|
||||
private static idb_name = "excalidraw-elements";
|
||||
/** elements data store key */
|
||||
private static key = "elementsData";
|
||||
|
||||
private static store = createStore(
|
||||
`${ElementsIndexedDBAdapter.idb_name}-db`,
|
||||
`${ElementsIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async load() {
|
||||
const IDBData = await get<ExcalidrawElement[]>(
|
||||
ElementsIndexedDBAdapter.key,
|
||||
ElementsIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
return IDBData || null;
|
||||
}
|
||||
|
||||
static save(data: ExcalidrawElement[]): MaybePromise<void> {
|
||||
return set(
|
||||
ElementsIndexedDBAdapter.key,
|
||||
data,
|
||||
ElementsIndexedDBAdapter.store,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import {
|
||||
AppStateIndexedDBAdapter,
|
||||
ElementsIndexedDBAdapter,
|
||||
} from "./LocalData";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
@@ -74,28 +79,146 @@ export const importFromLocalStorage = () => {
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
export const getElementsStorageSize = () => {
|
||||
export const importFromIndexedDB = async () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
|
||||
try {
|
||||
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
const elementsSize = elements?.length || 0;
|
||||
return elementsSize;
|
||||
savedElements = await ElementsIndexedDBAdapter.load();
|
||||
savedState = await AppStateIndexedDBAdapter.load();
|
||||
} catch (error: any) {
|
||||
// unable to access IndexedDB
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = clearElementsForLocalStorage(savedElements);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
let appState = null;
|
||||
if (savedState) {
|
||||
try {
|
||||
appState = {
|
||||
...getDefaultAppState(),
|
||||
...clearAppStateForLocalStorage(savedState),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
export const migrateFromLocalStorageToIndexedDB = async () => {
|
||||
try {
|
||||
// check if we have data in localStorage
|
||||
const savedElements = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
);
|
||||
const savedState = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
);
|
||||
|
||||
if (savedElements || savedState) {
|
||||
// parse and migrate elements
|
||||
if (savedElements) {
|
||||
try {
|
||||
const elements = JSON.parse(savedElements);
|
||||
await ElementsIndexedDBAdapter.save(elements);
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate elements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// parse and migrate app state
|
||||
if (savedState) {
|
||||
try {
|
||||
const appState = JSON.parse(savedState);
|
||||
await AppStateIndexedDBAdapter.save(appState);
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate app state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// clear localStorage after successful migration
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getTotalStorageSize = () => {
|
||||
/**
|
||||
* Get the size of elements stored in IndexedDB (with localStorage fallback)
|
||||
* @returns Promise<number> - Size in bytes
|
||||
*/
|
||||
export const getElementsStorageSize = async () => {
|
||||
try {
|
||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
const elements = await ElementsIndexedDBAdapter.load();
|
||||
if (elements) {
|
||||
// calculate size by stringifying the data
|
||||
const elementsString = JSON.stringify(elements);
|
||||
return elementsString.length;
|
||||
}
|
||||
return 0;
|
||||
} catch (error: any) {
|
||||
console.error("Failed to get elements size from IndexedDB:", error);
|
||||
// fallback to localStorage
|
||||
try {
|
||||
const elements = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
);
|
||||
return elements?.length || 0;
|
||||
} catch (localStorageError: any) {
|
||||
console.error(
|
||||
"Failed to get elements size from localStorage:",
|
||||
localStorageError,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the total size of all data stored in IndexedDB and localStorage
|
||||
* @returns Promise<number> - Size in bytes
|
||||
*/
|
||||
export const getTotalStorageSize = async () => {
|
||||
try {
|
||||
const appState = await AppStateIndexedDBAdapter.load();
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
|
||||
const appStateSize = appState?.length || 0;
|
||||
const appStateSize = appState ? JSON.stringify(appState).length : 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
|
||||
return appStateSize + collabSize + getElementsStorageSize();
|
||||
const elementsSize = await getElementsStorageSize();
|
||||
return appStateSize + collabSize + elementsSize;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return 0;
|
||||
console.error("Failed to get total storage size from IndexedDB:", error);
|
||||
// fallback to localStorage
|
||||
try {
|
||||
const appState = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
|
||||
const elementsSize = await getElementsStorageSize();
|
||||
return appStateSize + collabSize + elementsSize;
|
||||
} catch (localStorageError: any) {
|
||||
console.error(
|
||||
"Failed to get total storage size from localStorage:",
|
||||
localStorageError,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -4,7 +4,13 @@ import {
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "browser-fs-access";
|
||||
|
||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||
import {
|
||||
EVENT,
|
||||
MIME_TYPES,
|
||||
debounce,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
@@ -13,6 +19,8 @@ import type { FileSystemHandle } from "browser-fs-access";
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
// increase timeout for mobile devices to give more time for file selection
|
||||
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
|
||||
|
||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
@@ -41,13 +49,22 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const isMobile = isIOS || isAndroid;
|
||||
const intervalMs = isMobile
|
||||
? MOBILE_INPUT_CHANGE_INTERVAL_MS
|
||||
: INPUT_CHANGE_INTERVAL_MS;
|
||||
const scheduleRejection = debounce(reject, intervalMs);
|
||||
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
// on mobile, be less aggressive with rejection
|
||||
if (!isMobile) {
|
||||
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) {
|
||||
@@ -55,12 +72,15 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||
});
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
}, intervalMs);
|
||||
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
@@ -69,7 +89,9 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
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).");
|
||||
console.warn(
|
||||
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
|
||||
);
|
||||
rejectPromise(new AbortError());
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user