Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17090f455 | ||
|
|
2df323a5c3 | ||
|
|
df25de7e68 | ||
|
|
a3763648fe | ||
|
|
178eca5828 | ||
|
|
cb33de25f4 |
+3
-18
@@ -112,9 +112,7 @@ 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";
|
||||||
@@ -220,15 +218,7 @@ const initializeScene = async (opts: {
|
|||||||
);
|
);
|
||||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||||
|
|
||||||
// migrate from localStorage to IndexedDB if needed
|
const localDataState = importFromLocalStorage();
|
||||||
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;
|
||||||
@@ -514,7 +504,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
TITLE_TIMEOUT,
|
TITLE_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncData = debounce(async () => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -524,12 +514,7 @@ 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)) {
|
||||||
// try to load from IndexedDB first, fallback to localStorage
|
const localDataState = importFromLocalStorage();
|
||||||
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({
|
||||||
|
|||||||
@@ -21,23 +21,11 @@ type StorageSizes = { scene: number; total: number };
|
|||||||
|
|
||||||
const STORAGE_SIZE_TIMEOUT = 500;
|
const STORAGE_SIZE_TIMEOUT = 500;
|
||||||
|
|
||||||
const getStorageSizes = debounce(async (cb: (sizes: StorageSizes) => void) => {
|
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||||
try {
|
cb({
|
||||||
const [scene, total] = await Promise.all([
|
scene: getElementsStorageSize(),
|
||||||
getElementsStorageSize(),
|
total: getTotalStorageSize(),
|
||||||
getTotalStorageSize(),
|
});
|
||||||
]);
|
|
||||||
cb({
|
|
||||||
scene,
|
|
||||||
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 = {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class LocalFileManager extends FileManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDataStateToIndexedDB = async (
|
const saveDataStateToLocalStorage = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
@@ -79,15 +79,17 @@ const saveDataStateToIndexedDB = async (
|
|||||||
_appState.openSidebar = null;
|
_appState.openSidebar = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// save to IndexedDB
|
localStorage.setItem(
|
||||||
await Promise.all([
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
ElementsIndexedDBAdapter.save(clearElementsForLocalStorage(elements)),
|
JSON.stringify(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 IndexedDB
|
// Unable to access window.localStorage
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -102,7 +104,7 @@ export class LocalData {
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
onFilesSaved: () => void,
|
onFilesSaved: () => void,
|
||||||
) => {
|
) => {
|
||||||
await saveDataStateToIndexedDB(elements, appState);
|
saveDataStateToLocalStorage(elements, appState);
|
||||||
|
|
||||||
await this.fileStorage.saveFiles({
|
await this.fileStorage.saveFiles({
|
||||||
elements,
|
elements,
|
||||||
@@ -254,63 +256,3 @@ 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ 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(
|
||||||
@@ -79,146 +74,28 @@ export const importFromLocalStorage = () => {
|
|||||||
return { elements, appState };
|
return { elements, appState };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importFromIndexedDB = async () => {
|
export const getElementsStorageSize = () => {
|
||||||
let savedElements = null;
|
|
||||||
let savedState = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
savedElements = await ElementsIndexedDBAdapter.load();
|
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||||
savedState = await AppStateIndexedDBAdapter.load();
|
const elementsSize = elements?.length || 0;
|
||||||
|
return elementsSize;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// unable to access IndexedDB
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the size of elements stored in IndexedDB (with localStorage fallback)
|
|
||||||
* @returns Promise<number> - Size in bytes
|
|
||||||
*/
|
|
||||||
export const getElementsStorageSize = async () => {
|
|
||||||
try {
|
|
||||||
const elements = await ElementsIndexedDBAdapter.load();
|
|
||||||
if (elements) {
|
|
||||||
// calculate size by stringifying the data
|
|
||||||
const elementsString = JSON.stringify(elements);
|
|
||||||
return elementsString.length;
|
|
||||||
}
|
|
||||||
return 0;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const getTotalStorageSize = () => {
|
||||||
* Get the total size of all data stored in IndexedDB and localStorage
|
|
||||||
* @returns Promise<number> - Size in bytes
|
|
||||||
*/
|
|
||||||
export const getTotalStorageSize = async () => {
|
|
||||||
try {
|
try {
|
||||||
const appState = await AppStateIndexedDBAdapter.load();
|
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 ? JSON.stringify(appState).length : 0;
|
const appStateSize = appState?.length || 0;
|
||||||
const collabSize = collab?.length || 0;
|
const collabSize = collab?.length || 0;
|
||||||
|
|
||||||
const elementsSize = await getElementsStorageSize();
|
return appStateSize + collabSize + getElementsStorageSize();
|
||||||
return appStateSize + collabSize + elementsSize;
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to get total storage size from IndexedDB:", error);
|
console.error(error);
|
||||||
// fallback to localStorage
|
return 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
<title>
|
||||||
|
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
|
||||||
|
</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -151,6 +151,16 @@ export class Delta<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges two deltas into a new one.
|
||||||
|
*/
|
||||||
|
public static merge<T>(delta1: Delta<T>, delta2: Delta<T>) {
|
||||||
|
return Delta.create(
|
||||||
|
{ ...delta1.deleted, ...delta2.deleted },
|
||||||
|
{ ...delta1.inserted, ...delta2.inserted },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges deleted and inserted object partials.
|
* Merges deleted and inserted object partials.
|
||||||
*/
|
*/
|
||||||
@@ -497,6 +507,11 @@ export interface DeltaContainer<T> {
|
|||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squashes the current delta with the given one.
|
||||||
|
*/
|
||||||
|
squash(delta: DeltaContainer<T>): this;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether all `Delta`s are empty.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
@@ -504,7 +519,7 @@ export interface DeltaContainer<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
@@ -535,6 +550,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return new AppStateDelta(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public squash(delta: AppStateDelta): this {
|
||||||
|
this.delta = Delta.merge(this.delta, delta.delta);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
@@ -1196,8 +1216,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(deltas)) {
|
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@@ -1395,6 +1415,42 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public squash(delta: ElementsDelta): this {
|
||||||
|
const { added, removed, updated } = delta;
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(added)) {
|
||||||
|
const prevDelta = this.added[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.added[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
this.added[id] = Delta.merge(prevDelta, nextDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||||
|
const prevDelta = this.removed[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.removed[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
this.removed[id] = Delta.merge(prevDelta, nextDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||||
|
const prevDelta = this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.updated[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
this.updated[id] = Delta.merge(prevDelta, nextDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
private static createApplier =
|
private static createApplier =
|
||||||
(
|
(
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
@@ -1624,25 +1680,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||||
// technically we could do better here if perf. would become an issue
|
this.squash(
|
||||||
const { added, removed, updated } = ElementsDelta.calculate(
|
// technically we could do better here if perf. would become an issue
|
||||||
prevAffectedElements,
|
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||||
nextAffectedElements,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(added)) {
|
|
||||||
this.added[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(removed)) {
|
|
||||||
this.removed[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(updated)) {
|
|
||||||
this.updated[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
|||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
// internally used by history
|
// for internal use by history
|
||||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
|
// for public use as part of onIncrement API
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[DurableIncrement | EphemeralIncrement]
|
[DurableIncrement | EphemeralIncrement]
|
||||||
>();
|
>();
|
||||||
@@ -239,7 +240,6 @@ export class Store {
|
|||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9107,7 +9107,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
newElement &&
|
newElement &&
|
||||||
!multiElement
|
!multiElement
|
||||||
) {
|
) {
|
||||||
if (this.device.isTouchScreen) {
|
if (this.device.isTouchScreen && newElement!.points.length > 1) {
|
||||||
const FIXED_DELTA_X = Math.min(
|
const FIXED_DELTA_X = Math.min(
|
||||||
(this.state.width * 0.7) / this.state.zoom.value,
|
(this.state.width * 0.7) / this.state.zoom.value,
|
||||||
100,
|
100,
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ import {
|
|||||||
supported as nativeFileSystemSupported,
|
supported as nativeFileSystemSupported,
|
||||||
} from "browser-fs-access";
|
} from "browser-fs-access";
|
||||||
|
|
||||||
import {
|
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||||
EVENT,
|
|
||||||
MIME_TYPES,
|
|
||||||
debounce,
|
|
||||||
isIOS,
|
|
||||||
isAndroid,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
|
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
|
|
||||||
@@ -19,8 +13,6 @@ 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[];
|
||||||
@@ -49,22 +41,13 @@ 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 isMobile = isIOS || isAndroid;
|
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||||
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
|
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||||
if (!isMobile) {
|
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
scheduleRejection();
|
||||||
document.addEventListener(EVENT.POINTER_UP, 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) {
|
||||||
@@ -72,15 +55,12 @@ 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();
|
||||||
}, intervalMs);
|
}, INPUT_CHANGE_INTERVAL_MS);
|
||||||
|
|
||||||
return (rejectPromise) => {
|
return (rejectPromise) => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
scheduleRejection.cancel();
|
scheduleRejection.cancel();
|
||||||
@@ -89,9 +69,7 @@ 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(
|
console.warn("Opening the file was canceled (legacy-fs).");
|
||||||
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
|
|
||||||
);
|
|
||||||
rejectPromise(new AbortError());
|
rejectPromise(new AbortError());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { throttleRAF } from "@excalidraw/common";
|
import { throttleRAF } from "@excalidraw/common";
|
||||||
|
|
||||||
import { isInvisiblySmallElement, renderElement } from "@excalidraw/element";
|
import {
|
||||||
|
getTargetFrame,
|
||||||
|
isInvisiblySmallElement,
|
||||||
|
renderElement,
|
||||||
|
shouldApplyFrameClip,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
||||||
|
|
||||||
|
import { frameClip } from "./staticScene";
|
||||||
|
|
||||||
import type { NewElementSceneRenderConfig } from "../scene/types";
|
import type { NewElementSceneRenderConfig } from "../scene/types";
|
||||||
|
|
||||||
const _renderNewElementScene = ({
|
const _renderNewElementScene = ({
|
||||||
@@ -29,8 +36,9 @@ const _renderNewElementScene = ({
|
|||||||
normalizedHeight,
|
normalizedHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply zoom
|
|
||||||
context.save();
|
context.save();
|
||||||
|
|
||||||
|
// Apply zoom
|
||||||
context.scale(appState.zoom.value, appState.zoom.value);
|
context.scale(appState.zoom.value, appState.zoom.value);
|
||||||
|
|
||||||
if (newElement && newElement.type !== "selection") {
|
if (newElement && newElement.type !== "selection") {
|
||||||
@@ -42,6 +50,23 @@ const _renderNewElementScene = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const frameId = newElement.frameId || appState.frameToHighlight?.id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
frameId &&
|
||||||
|
appState.frameRendering.enabled &&
|
||||||
|
appState.frameRendering.clip
|
||||||
|
) {
|
||||||
|
const frame = getTargetFrame(newElement, elementsMap, appState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
frame &&
|
||||||
|
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
|
||||||
|
) {
|
||||||
|
frameClip(frame, context, renderConfig, appState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderElement(
|
renderElement(
|
||||||
newElement,
|
newElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -54,6 +79,8 @@ const _renderNewElementScene = ({
|
|||||||
} else {
|
} else {
|
||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const strokeGrid = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
const frameClip = (
|
export const frameClip = (
|
||||||
frame: ExcalidrawFrameLikeElement,
|
frame: ExcalidrawFrameLikeElement,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getDraggedElementsBounds,
|
getDraggedElementsBounds,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
|
import { isBoundToContainer } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getMaximumGroups } from "@excalidraw/element";
|
import { getMaximumGroups } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -311,20 +311,13 @@ const getReferenceElements = (
|
|||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) =>
|
||||||
const selectedFrames = selectedElements
|
getVisibleAndNonSelectedElements(
|
||||||
.filter((element) => isFrameLikeElement(element))
|
|
||||||
.map((frame) => frame.id);
|
|
||||||
|
|
||||||
return getVisibleAndNonSelectedElements(
|
|
||||||
elements,
|
elements,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
).filter(
|
|
||||||
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export const getVisibleGaps = (
|
export const getVisibleGaps = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
|||||||
@@ -2924,7 +2924,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 11,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
@@ -3001,7 +3001,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 5,
|
"version": 11,
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 30,
|
"width": 30,
|
||||||
"x": 15,
|
"x": 15,
|
||||||
@@ -3031,14 +3031,67 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"version": 9,
|
"version": 9,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"fontFamily": 5,
|
||||||
|
"fontSize": 20,
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 100,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
|
"lineHeight": "1.25000",
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"originalText": "que pasa",
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"strokeColor": "#1e1e1e",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"text": "que pasa",
|
||||||
|
"textAlign": "left",
|
||||||
|
"type": "text",
|
||||||
"version": 8,
|
"version": 8,
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"width": 100,
|
||||||
|
"x": 15,
|
||||||
|
"y": 15,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"removed": {},
|
"removed": {},
|
||||||
"updated": {},
|
"updated": {
|
||||||
|
"id0": {
|
||||||
|
"deleted": {
|
||||||
|
"boundElements": [],
|
||||||
|
"version": 11,
|
||||||
|
},
|
||||||
|
"inserted": {
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"id": "id1",
|
||||||
|
"type": "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"version": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"id5": {
|
||||||
|
"deleted": {
|
||||||
|
"version": 11,
|
||||||
|
},
|
||||||
|
"inserted": {
|
||||||
|
"version": 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"id": "id9",
|
"id": "id9",
|
||||||
},
|
},
|
||||||
@@ -5036,9 +5089,29 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"removed": {
|
"removed": {
|
||||||
"id0": {
|
"id0": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [],
|
"boundElements": [],
|
||||||
|
"customData": undefined,
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 100,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"strokeColor": "#1e1e1e",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "rectangle",
|
||||||
"version": 8,
|
"version": 8,
|
||||||
|
"width": 100,
|
||||||
|
"x": 10,
|
||||||
|
"y": 10,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
@@ -5266,9 +5339,38 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"removed": {
|
"removed": {
|
||||||
"id1": {
|
"id1": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
|
"angle": 0,
|
||||||
|
"autoResize": true,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"fontFamily": 5,
|
||||||
|
"fontSize": 20,
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 100,
|
||||||
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
|
"lineHeight": "1.25000",
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"originalText": "que pasa",
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"strokeColor": "#1e1e1e",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"text": "que pasa",
|
||||||
|
"textAlign": "left",
|
||||||
|
"type": "text",
|
||||||
"version": 8,
|
"version": 8,
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"width": 100,
|
||||||
|
"x": 15,
|
||||||
|
"y": 15,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"containerId": "id0",
|
"containerId": "id0",
|
||||||
@@ -5525,9 +5627,11 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
|||||||
"updated": {
|
"updated": {
|
||||||
"id1": {
|
"id1": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
|
"frameId": null,
|
||||||
"version": 10,
|
"version": 10,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
|
"frameId": null,
|
||||||
"version": 8,
|
"version": 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rl.question(
|
rl.question(
|
||||||
"Do you want to commit these changes to git? (Y/n): ",
|
"Would you like to commit these changes to git? (Y/n): ",
|
||||||
(answer) => {
|
(answer) => {
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rl.question(
|
rl.question(
|
||||||
"Do you want to publish these changes to npm? (Y/n): ",
|
"Would you like to publish these changes to npm? (Y/n): ",
|
||||||
(answer) => {
|
(answer) => {
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user