Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Di
5457d9c39a fix: extend wait time for file loading on mobile devices 2025-07-31 18:51:38 +10:00
Ryan Di
cff7516318 fix: use idb for elements and app state 2025-07-31 18:21:56 +10:00
6 changed files with 267 additions and 60 deletions

View File

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

View File

@@ -112,7 +112,9 @@ import {
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { import {
importFromLocalStorage, importFromLocalStorage,
importFromIndexedDB,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
migrateFromLocalStorageToIndexedDB,
} from "./data/localStorage"; } from "./data/localStorage";
import { loadFilesFromFirebase } from "./data/firebase"; import { loadFilesFromFirebase } from "./data/firebase";
@@ -218,7 +220,15 @@ const initializeScene = async (opts: {
); );
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); 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 & { let scene: RestoredDataState & {
scrollToContent?: boolean; scrollToContent?: boolean;
@@ -504,7 +514,7 @@ const ExcalidrawWrapper = () => {
TITLE_TIMEOUT, TITLE_TIMEOUT,
); );
const syncData = debounce(() => { const syncData = debounce(async () => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
} }
@@ -514,7 +524,12 @@ const ExcalidrawWrapper = () => {
) { ) {
// don't sync if local state is newer or identical to browser state // don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_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(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({

View File

@@ -21,11 +21,23 @@ type StorageSizes = { scene: number; total: number };
const STORAGE_SIZE_TIMEOUT = 500; const STORAGE_SIZE_TIMEOUT = 500;
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => { const getStorageSizes = debounce(async (cb: (sizes: StorageSizes) => void) => {
try {
const [scene, total] = await Promise.all([
getElementsStorageSize(),
getTotalStorageSize(),
]);
cb({ cb({
scene: getElementsStorageSize(), scene,
total: getTotalStorageSize(), total,
}); });
} catch (error) {
console.error("Failed to get storage sizes:", error);
cb({
scene: 0,
total: 0,
});
}
}, STORAGE_SIZE_TIMEOUT); }, STORAGE_SIZE_TIMEOUT);
type Props = { type Props = {

View File

@@ -65,7 +65,7 @@ class LocalFileManager extends FileManager {
}; };
} }
const saveDataStateToLocalStorage = ( const saveDataStateToIndexedDB = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
@@ -79,17 +79,15 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
localStorage.setItem( // save to IndexedDB
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, await Promise.all([
JSON.stringify(clearElementsForLocalStorage(elements)), ElementsIndexedDBAdapter.save(clearElementsForLocalStorage(elements)),
); AppStateIndexedDBAdapter.save(_appState),
localStorage.setItem( ]);
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) { } catch (error: any) {
// Unable to access window.localStorage // unable to access IndexedDB
console.error(error); console.error(error);
} }
}; };
@@ -104,7 +102,7 @@ export class LocalData {
files: BinaryFiles, files: BinaryFiles,
onFilesSaved: () => void, onFilesSaved: () => void,
) => { ) => {
saveDataStateToLocalStorage(elements, appState); await saveDataStateToIndexedDB(elements, appState);
await this.fileStorage.saveFiles({ await this.fileStorage.saveFiles({
elements, elements,
@@ -256,3 +254,63 @@ export class LibraryLocalStorageMigrationAdapter {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); 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,
);
}
}

View File

@@ -9,6 +9,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import {
AppStateIndexedDBAdapter,
ElementsIndexedDBAdapter,
} from "./LocalData";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {
localStorage.setItem( localStorage.setItem(
@@ -74,28 +79,146 @@ export const importFromLocalStorage = () => {
return { elements, appState }; return { elements, appState };
}; };
export const getElementsStorageSize = () => { export const importFromIndexedDB = async () => {
let savedElements = null;
let savedState = null;
try { try {
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); savedElements = await ElementsIndexedDBAdapter.load();
const elementsSize = elements?.length || 0; savedState = await AppStateIndexedDBAdapter.load();
return elementsSize; } catch (error: any) {
// unable to access IndexedDB
console.error(error);
}
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
elements = clearElementsForLocalStorage(savedElements);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return 0; }
}
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 { 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 ? JSON.stringify(appState).length : 0;
const collabSize = collab?.length || 0;
const elementsSize = await getElementsStorageSize();
return appStateSize + collabSize + elementsSize;
} catch (error: any) {
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 collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const appStateSize = appState?.length || 0; const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0; const collabSize = collab?.length || 0;
return appStateSize + collabSize + getElementsStorageSize(); const elementsSize = await getElementsStorageSize();
} catch (error: any) { return appStateSize + collabSize + elementsSize;
console.error(error); } catch (localStorageError: any) {
console.error(
"Failed to get total storage size from localStorage:",
localStorageError,
);
return 0; return 0;
} }
}
}; };

View File

@@ -4,7 +4,13 @@ import {
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "browser-fs-access"; } 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"; import { AbortError } from "../errors";
@@ -13,6 +19,8 @@ import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500; 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: { export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
@@ -41,13 +49,22 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => { 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 = () => { const focusHandler = () => {
checkForFile(); checkForFile();
// on mobile, be less aggressive with rejection
if (!isMobile) {
document.addEventListener(EVENT.KEYUP, scheduleRejection); document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection); document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection(); scheduleRejection();
}
}; };
const checkForFile = () => { const checkForFile = () => {
// this hack might not work when expecting multiple files // this hack might not work when expecting multiple files
if (input.files?.length) { if (input.files?.length) {
@@ -55,12 +72,15 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
resolve(ret as RetType); resolve(ret as RetType);
} }
}; };
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler); window.addEventListener(EVENT.FOCUS, focusHandler);
}); });
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
checkForFile(); checkForFile();
}, INPUT_CHANGE_INTERVAL_MS); }, intervalMs);
return (rejectPromise) => { return (rejectPromise) => {
clearInterval(interval); clearInterval(interval);
scheduleRejection.cancel(); scheduleRejection.cancel();
@@ -69,7 +89,9 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection); document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) { if (rejectPromise) {
// so that something is shown in console if we need to debug this // 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()); rejectPromise(new AbortError());
} }
}; };