mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-05-17 13:40:38 +00:00
feat(packages/excalidraw): state tracking, api hook, and others (#10870)
This commit is contained in:
+22
-1
@@ -39,5 +39,26 @@
|
||||
"allowReferrer": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
|
||||
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@excalidraw/excalidraw"],
|
||||
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
|
||||
"allowTypeImports": true
|
||||
}
|
||||
],
|
||||
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.29.1"
|
||||
"browser-fs-access": "0.38.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.0.12",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "5.0.12"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener("keyup", scheduleRejection);
|
||||
document.addEventListener("pointerup", scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("focus", focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener("focus", focusHandler);
|
||||
document.removeEventListener("keyup", scheduleRejection);
|
||||
document.removeEventListener("pointerup", scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new Error("Request Aborted"));
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
};
|
||||
|
||||
|
||||
+92
-20
@@ -5,6 +5,8 @@ import {
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
useEditorInterface,
|
||||
ExcalidrawAPIProvider,
|
||||
useExcalidrawAPI,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import {
|
||||
@@ -74,6 +75,7 @@ import type {
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
ExcalidrawProps,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
@@ -114,6 +116,7 @@ import {
|
||||
} from "./data";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { FileStatusStore } from "./data/fileStatusStore";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
|
||||
};
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const excalidrawAPI = useExcalidrawAPI();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@@ -433,18 +435,15 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadImages = useCallback(
|
||||
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||
if (!data.scene || !excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
@@ -471,6 +470,12 @@ const ExcalidrawWrapper = () => {
|
||||
}, [] as FileId[]) || [];
|
||||
|
||||
if (data.isExternalScene) {
|
||||
if (fileIds.length) {
|
||||
// Direct Firebase call (not through FileManager), so track manually
|
||||
FileStatusStore.updateStatuses(
|
||||
fileIds.map((id) => [id, "loading"]),
|
||||
);
|
||||
}
|
||||
loadFilesFromFirebase(
|
||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||
data.key,
|
||||
@@ -482,12 +487,18 @@ const ExcalidrawWrapper = () => {
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
FileStatusStore.updateStatuses([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
.then(async ({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
@@ -500,10 +511,19 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
LocalData.fileStorage.clearObsoleteFiles({
|
||||
currentFileIds: fileIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[collabAPI, excalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
|
||||
false,
|
||||
);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@@ -773,6 +793,56 @@ const ExcalidrawWrapper = () => {
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport — intercepts file save to wait for pending image loads
|
||||
// ---------------------------------------------------------------------------
|
||||
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
|
||||
async function* () {
|
||||
let snapshot = FileStatusStore.getSnapshot();
|
||||
const { pending, total } = FileStatusStore.getPendingCount(
|
||||
snapshot.value,
|
||||
);
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yield initial progress
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (total - pending) / total,
|
||||
message: `Loading images (${total - pending}/${total})...`,
|
||||
};
|
||||
|
||||
// Wait for all pending images to finish
|
||||
while (true) {
|
||||
snapshot = await FileStatusStore.pull(snapshot.version);
|
||||
const { pending: nowPending, total: nowTotal } =
|
||||
FileStatusStore.getPendingCount(snapshot.value);
|
||||
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (nowTotal - nowPending) / nowTotal,
|
||||
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
|
||||
};
|
||||
|
||||
if (nowPending === 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
yield {
|
||||
type: "progress",
|
||||
message: `Preparing export...`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// const onExport = () => {
|
||||
// return new Promise((r) => setTimeout(r, 2500));
|
||||
// // console.log("onExport");
|
||||
// };
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
<ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</ExcalidrawAPIProvider>
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { FileStatusStore } from "../data/fileStatusStore";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
|
||||
@@ -40,10 +40,12 @@ export class FileManager {
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
private _onFileStatusChange;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
onFileStatusChange,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
@@ -53,9 +55,13 @@ export class FileManager {
|
||||
savedFiles: Map<FileId, BinaryFileData>;
|
||||
erroredFiles: Map<FileId, BinaryFileData>;
|
||||
}>;
|
||||
onFileStatusChange?: (
|
||||
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
|
||||
) => void;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
this._saveFiles = saveFiles;
|
||||
this._onFileStatusChange = onFileStatusChange;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +152,8 @@ export class FileManager {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
@@ -156,6 +164,13 @@ export class FileManager {
|
||||
this.erroredFiles_fetch.set(fileId, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
@@ -195,6 +210,13 @@ export class FileManager {
|
||||
};
|
||||
|
||||
reset() {
|
||||
if (this._onFileStatusChange && this.fetchingFiles.size) {
|
||||
this._onFileStatusChange(
|
||||
[...this.fetchingFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
);
|
||||
}
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import { FileManager } from "./FileManager";
|
||||
import { FileStatusStore } from "./fileStatusStore";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
@@ -166,6 +167,7 @@ export class LocalData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { VersionedSnapshotStore } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
export type FileLoadingStatus = "loading" | "loaded" | "error";
|
||||
|
||||
export class FileStatusStore {
|
||||
private static store = new VersionedSnapshotStore<
|
||||
Map<FileId, FileLoadingStatus>
|
||||
>(new Map());
|
||||
|
||||
static getSnapshot() {
|
||||
return this.store.getSnapshot();
|
||||
}
|
||||
|
||||
static pull(sinceVersion?: number) {
|
||||
return this.store.pull(sinceVersion);
|
||||
}
|
||||
|
||||
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
this.store.update((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, status] of updates) {
|
||||
if (next.get(id) !== status) {
|
||||
next.set(id, status);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
|
||||
let pending = 0;
|
||||
let total = 0;
|
||||
for (const status of statuses.values()) {
|
||||
total++;
|
||||
if (status === "loading") {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return { pending, total };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppEventBus } from "./appEventBus";
|
||||
|
||||
type TestEvents = {
|
||||
initialize: [api: number];
|
||||
pointerUp: [pointerId: string];
|
||||
viewState: [zoom: number];
|
||||
};
|
||||
|
||||
const behavior = {
|
||||
initialize: { cardinality: "once", replay: "last" },
|
||||
pointerUp: { cardinality: "many", replay: "none" },
|
||||
viewState: { cardinality: "many", replay: "last" },
|
||||
} as const;
|
||||
|
||||
const flushMicrotasks = async () => Promise.resolve();
|
||||
|
||||
describe("AppEventBus", () => {
|
||||
it("replays once events to late callback and Promise subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 42);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("initialize", (value) => {
|
||||
calls.push(value);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([42]);
|
||||
|
||||
await expect(bus.on("initialize")).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not replay stream events to late subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("pointerUp", "first");
|
||||
|
||||
const calls: string[] = [];
|
||||
bus.on("pointerUp", (pointerId) => {
|
||||
calls.push(pointerId);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
bus.emit("pointerUp", "second");
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("replays replay-last stream events and stays subscribed", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("viewState", 1);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("viewState", (zoom) => {
|
||||
calls.push(zoom);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([1]);
|
||||
|
||||
bus.emit("viewState", 2);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("throws when emitting a once event twice", () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 1);
|
||||
|
||||
expect(() => {
|
||||
bus.emit("initialize", 2);
|
||||
}).toThrow('Event "initialize" can only be emitted once');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Emitter } from "./emitter";
|
||||
import { isProdEnv } from "./utils";
|
||||
|
||||
export type AppEventPayloadMap = Record<string, unknown[]>;
|
||||
|
||||
export type AppEventBehavior = {
|
||||
cardinality: "once" | "many";
|
||||
replay: "none" | "last";
|
||||
};
|
||||
|
||||
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
|
||||
[K in keyof Events]: AppEventBehavior;
|
||||
};
|
||||
|
||||
type AwaitableAppEventKeys<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> = {
|
||||
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
|
||||
? Behavior[K]["replay"] extends "last"
|
||||
? K
|
||||
: never
|
||||
: never;
|
||||
}[keyof Events];
|
||||
|
||||
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
|
||||
? Only
|
||||
: Args;
|
||||
|
||||
export class AppEventBus<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> {
|
||||
private readonly emitters = new Map<keyof Events, Emitter<any>>();
|
||||
private readonly lastPayload = new Map<keyof Events, any[]>();
|
||||
private readonly emittedOnce = new Set<keyof Events>();
|
||||
|
||||
constructor(private readonly behavior: Behavior) {}
|
||||
|
||||
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
|
||||
let emitter = this.emitters.get(name);
|
||||
if (!emitter) {
|
||||
emitter = new Emitter<any>();
|
||||
this.emitters.set(name, emitter);
|
||||
}
|
||||
return emitter as Emitter<Events[K]>;
|
||||
}
|
||||
|
||||
private toPromiseValue<Args extends any[]>(
|
||||
args: Args,
|
||||
): AppEventPromiseValue<Args> {
|
||||
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
|
||||
}
|
||||
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback;
|
||||
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
|
||||
name: K,
|
||||
): Promise<AppEventPromiseValue<Events[K]>>;
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback?: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
|
||||
const eventBehavior = this.behavior[name];
|
||||
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
|
||||
|
||||
if (callback) {
|
||||
if (eventBehavior.replay === "last" && cachedPayload) {
|
||||
queueMicrotask(() => callback(...cachedPayload));
|
||||
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.getEmitter(name).on(callback);
|
||||
}
|
||||
|
||||
if (
|
||||
eventBehavior.cardinality !== "once" ||
|
||||
eventBehavior.replay !== "last"
|
||||
) {
|
||||
throw new Error(`Event "${String(name)}" requires a callback`);
|
||||
}
|
||||
|
||||
if (cachedPayload) {
|
||||
return Promise.resolve(this.toPromiseValue(cachedPayload));
|
||||
}
|
||||
|
||||
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
|
||||
this.getEmitter(name).once((...args: Events[K]) => {
|
||||
resolve(this.toPromiseValue(args));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
|
||||
const eventBehavior = this.behavior[name];
|
||||
|
||||
if (!isProdEnv()) {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
if (this.emittedOnce.has(name)) {
|
||||
throw new Error(`Event "${String(name)}" can only be emitted once`);
|
||||
}
|
||||
this.emittedOnce.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventBehavior.replay === "last") {
|
||||
this.lastPayload.set(name, args);
|
||||
}
|
||||
|
||||
try {
|
||||
this.getEmitter(name).trigger(...args);
|
||||
} finally {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
this.getEmitter(name).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.lastPayload.clear();
|
||||
this.emittedOnce.clear();
|
||||
|
||||
for (const emitter of this.emitters.values()) {
|
||||
emitter.clear();
|
||||
}
|
||||
|
||||
this.emitters.clear();
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,7 @@ export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
export * from "./appEventBus";
|
||||
export * from "./editorInterface";
|
||||
export * from "./versionedSnapshotStore";
|
||||
export { Debug } from "../debug";
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type VersionedSnapshot<T> = Readonly<{
|
||||
version: number;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export class VersionedSnapshotStore<T> {
|
||||
private version = 0;
|
||||
private value: T;
|
||||
private readonly waiters = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
private readonly subscribers = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
|
||||
constructor(
|
||||
initialValue: T,
|
||||
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
|
||||
) {
|
||||
this.value = initialValue;
|
||||
}
|
||||
|
||||
public getSnapshot(): VersionedSnapshot<T> {
|
||||
return { version: this.version, value: this.value };
|
||||
}
|
||||
|
||||
public set(nextValue: T): boolean {
|
||||
if (this.isEqual(this.value, nextValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.value = nextValue;
|
||||
this.version += 1;
|
||||
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(snapshot);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
waiter(snapshot);
|
||||
}
|
||||
this.waiters.clear();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public update(updater: (prev: T) => T): boolean {
|
||||
return this.set(updater(this.value));
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
subscriber: (snapshot: VersionedSnapshot<T>) => void,
|
||||
): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
|
||||
if (this.version !== sinceVersion) {
|
||||
return Promise.resolve(this.getSnapshot());
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.add(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,92 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
||||
|
||||
### Features
|
||||
|
||||
- Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onMount={({ excalidrawAPI, container }) => {
|
||||
console.log(container);
|
||||
excalidrawAPI.scrollToContent();
|
||||
}}
|
||||
onInitialize={(api) => {
|
||||
api.refresh();
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
- Same events are also accessible imperatively through `api.onEvent(...)`.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => {
|
||||
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
|
||||
excalidrawAPI.scrollToContent();
|
||||
console.log(container);
|
||||
});
|
||||
|
||||
api.onEvent("editor:initialize").then((readyApi) => {
|
||||
readyApi.scrollToContent();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
|
||||
|
||||
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`.
|
||||
|
||||
```tsx
|
||||
<ExcalidrawAPIProvider>
|
||||
<Excalidraw />
|
||||
<Logger />
|
||||
</ExcalidrawAPIProvider>;
|
||||
|
||||
function Logger() {
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
|
||||
console.log("view mode changed:", viewModeEnabled);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (api) {
|
||||
console.log("editor instance id:", api.id);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExport={async function* (_type, { files }, { signal }) {
|
||||
yield { type: "progress", message: "Waiting for images..." };
|
||||
|
||||
await waitForImagesToLoad(files, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield { type: "progress", message: "Export ready", progress: 1 };
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
## 0.18.0 (2025-03-11)
|
||||
|
||||
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useEditorInterface } from "../components/App";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
|
||||
import { t } from "../i18n";
|
||||
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
import type { JSONExportData } from "../data/json";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
OnExportProgress,
|
||||
} from "../types";
|
||||
|
||||
export const actionChangeProjectName = register<AppState["name"]>({
|
||||
name: "changeProjectName",
|
||||
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport interception helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let onExportInProgress = false;
|
||||
|
||||
const onProgressToast = (
|
||||
app: AppClassProperties,
|
||||
progress: {
|
||||
message?: OnExportProgress["message"];
|
||||
progress?: number | null;
|
||||
},
|
||||
) => {
|
||||
const message = progress.message ?? t("progressDialog.defaultMessage");
|
||||
app.setAppState({
|
||||
toast: {
|
||||
message:
|
||||
progress.progress != null ? (
|
||||
<>
|
||||
{message}
|
||||
<Toast.ProgressBar progress={progress.progress} />
|
||||
</>
|
||||
) : (
|
||||
message
|
||||
),
|
||||
duration: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** awaits host app's onExport result, and renders progress to the UI */
|
||||
async function handleOnExportResult(
|
||||
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
|
||||
opts: {
|
||||
signal: AbortSignal;
|
||||
app: AppClassProperties;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.app.state.isLoading) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
|
||||
}
|
||||
|
||||
if (
|
||||
onExportResult != null &&
|
||||
typeof onExportResult === "object" &&
|
||||
Symbol.asyncIterator in onExportResult
|
||||
) {
|
||||
for await (const value of onExportResult) {
|
||||
if (opts.signal.aborted) {
|
||||
onExportResult.return();
|
||||
return;
|
||||
}
|
||||
if (value.type === "progress") {
|
||||
onProgressToast(opts.app, {
|
||||
message: value.message,
|
||||
progress: value.progress ?? null,
|
||||
});
|
||||
} else if (value.type === "done") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generator completed without explicit "done" message
|
||||
return;
|
||||
}
|
||||
|
||||
if (onExportResult instanceof Promise) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await onExportResult;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDataForJSONExport(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
app: AppClassProperties,
|
||||
): { abortController: AbortController; data: Promise<JSONExportData> } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
|
||||
try {
|
||||
if (app.props.onExport) {
|
||||
await handleOnExportResult(
|
||||
app.props.onExport(
|
||||
"json",
|
||||
{
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
),
|
||||
{
|
||||
app,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
// if abort error, assume it's a reaction on the signal being aborted
|
||||
console.warn(
|
||||
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
|
||||
);
|
||||
} else {
|
||||
// non-abort error
|
||||
//
|
||||
console.error("Error during props.onExport() handling", error);
|
||||
}
|
||||
|
||||
// either way, we currently don't allow host apps to cancel save actions
|
||||
// so we resolve to orig data
|
||||
}
|
||||
|
||||
resolve({
|
||||
elements,
|
||||
appState,
|
||||
// return latest files in case they finished loading during onExport
|
||||
files: app.files,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
abortController,
|
||||
data: dataPromise,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const previousFileHandle = appState.fileHandle;
|
||||
const filename = app.getName();
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
const { fileHandle } = isImageFileHandle(previousFileHandle)
|
||||
? await resaveAsImageWithScene(
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
exportedDataPromise,
|
||||
previousFileHandle,
|
||||
filename,
|
||||
)
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
: await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename,
|
||||
fileHandle: previousFileHandle,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toast: fileHandleExists
|
||||
? {
|
||||
message: fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
}
|
||||
: null,
|
||||
toast: {
|
||||
message:
|
||||
previousFileHandle && fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
duration: 1500,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
const { fileHandle: savedFileHandle } = await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename: app.getName(),
|
||||
fileHandle: null,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
fileHandle,
|
||||
fileHandle: savedFileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||
event.key.toLowerCase() === KEYS.S &&
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
@@ -33,8 +33,6 @@ import {
|
||||
normalizeFile,
|
||||
} from "./data/blob";
|
||||
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
|
||||
import type { BinaryFiles } from "./types";
|
||||
|
||||
type ElementsClipboard = {
|
||||
@@ -369,7 +367,7 @@ type AllowedParsedDataTransferItem =
|
||||
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
}
|
||||
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||
|
||||
@@ -378,7 +376,7 @@ type ParsedDataTransferItem =
|
||||
type: string;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
}
|
||||
| { type: string; kind: "string"; value: string };
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
applyDarkModeFilter,
|
||||
AppEventBus,
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
randomInteger,
|
||||
CLASSES,
|
||||
@@ -448,7 +449,7 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||
import { isPointHittingLink } from "./hyperlink/helpers";
|
||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { Toast } from "./Toast";
|
||||
import { AppStateObserver, type OnStateChange } from "./AppStateObserver";
|
||||
|
||||
import { findShapeByKey } from "./shapes";
|
||||
|
||||
@@ -464,7 +465,6 @@ import type {
|
||||
import type { ClipboardData, PastedMixedContent } from "../clipboard";
|
||||
import type { ExportedElements } from "../data";
|
||||
import type { ContextMenuItems } from "./ContextMenu";
|
||||
import type { FileSystemHandle } from "../data/filesystem";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -488,6 +488,7 @@ import type {
|
||||
UnsubscribeCallback,
|
||||
EmbedsValidationStatus,
|
||||
ElementsPendingErasure,
|
||||
ExcalidrawImperativeAPIEventMap,
|
||||
GenerateDiagramToCode,
|
||||
NullableGridSize,
|
||||
Offsets,
|
||||
@@ -513,6 +514,11 @@ const EditorInterfaceContext = React.createContext<EditorInterface>(
|
||||
);
|
||||
EditorInterfaceContext.displayName = "EditorInterfaceContext";
|
||||
|
||||
const editorLifecycleEventBehavior = {
|
||||
"editor:mount": { cardinality: "once", replay: "last" },
|
||||
"editor:initialize": { cardinality: "once", replay: "last" },
|
||||
} as const;
|
||||
|
||||
export const ExcalidrawContainerContext = React.createContext<{
|
||||
container: HTMLDivElement | null;
|
||||
id: string | null;
|
||||
@@ -545,6 +551,15 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
||||
);
|
||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
||||
|
||||
export const ExcalidrawAPIContext =
|
||||
React.createContext<ExcalidrawImperativeAPI | null>(null);
|
||||
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
|
||||
|
||||
export const ExcalidrawAPISetContext = React.createContext<
|
||||
((api: ExcalidrawImperativeAPI) => void) | null
|
||||
>(null);
|
||||
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
|
||||
|
||||
export const useApp = () => useContext(AppContext);
|
||||
export const useAppProps = () => useContext(AppPropsContext);
|
||||
export const useEditorInterface = () =>
|
||||
@@ -561,6 +576,10 @@ export const useExcalidrawSetAppState = () =>
|
||||
useContext(ExcalidrawSetAppStateContext);
|
||||
export const useExcalidrawActionManager = () =>
|
||||
useContext(ExcalidrawActionManagerContext);
|
||||
/**
|
||||
* Requires wrapping your component in <ExcalidrawAPIContext.Provider>
|
||||
*/
|
||||
export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext);
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
@@ -635,12 +654,26 @@ class App extends React.Component<AppProps, AppState> {
|
||||
* insert to DOM before user initially scrolls to them) */
|
||||
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
|
||||
|
||||
private handleToastClose = () => {
|
||||
this.setToast(null);
|
||||
};
|
||||
|
||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||
|
||||
private _initialized = false;
|
||||
|
||||
private readonly editorLifecycleEvents = new AppEventBus<
|
||||
ExcalidrawImperativeAPIEventMap,
|
||||
typeof editorLifecycleEventBehavior
|
||||
>(editorLifecycleEventBehavior);
|
||||
|
||||
public onEvent = this.editorLifecycleEvents.on.bind(
|
||||
this.editorLifecycleEvents,
|
||||
) as AppEventBus<
|
||||
ExcalidrawImperativeAPIEventMap,
|
||||
typeof editorLifecycleEventBehavior
|
||||
>["on"];
|
||||
|
||||
private appStateObserver = new AppStateObserver(() => this.state);
|
||||
|
||||
public onStateChange: OnStateChange = this.appStateObserver.onStateChange;
|
||||
|
||||
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
||||
|
||||
@@ -696,11 +729,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||
|
||||
api: ExcalidrawImperativeAPI;
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const {
|
||||
excalidrawAPI,
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
@@ -708,6 +742,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
theme = defaultAppState.theme,
|
||||
name = `${t("labels.untitled")}-${getDateTime()}`,
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
theme,
|
||||
@@ -744,51 +779,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.store = new Store(this);
|
||||
this.history = new History(this.store);
|
||||
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
updateScene: this.updateScene,
|
||||
applyDeltas: this.applyDeltas,
|
||||
mutateElement: this.mutateElement,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
resetScene: this.resetScene,
|
||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||
getSceneElementsMapIncludingDeleted:
|
||||
this.getSceneElementsMapIncludingDeleted,
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
scrollToContent: this.scrollToContent,
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
getFiles: () => this.files,
|
||||
getName: this.getName,
|
||||
registerAction: (action: Action) => {
|
||||
this.actionManager.registerAction(action);
|
||||
},
|
||||
refresh: this.refresh,
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
getEditorInterface: () => this.editorInterface,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
|
||||
} as const;
|
||||
if (typeof excalidrawAPI === "function") {
|
||||
excalidrawAPI(api);
|
||||
} else {
|
||||
console.error("excalidrawAPI should be a function!");
|
||||
}
|
||||
}
|
||||
|
||||
this.excalidrawContainerValue = {
|
||||
container: this.excalidrawContainerRef.current,
|
||||
id: this.id,
|
||||
@@ -800,6 +790,48 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
|
||||
this.api = {
|
||||
updateScene: this.updateScene,
|
||||
applyDeltas: this.applyDeltas,
|
||||
mutateElement: this.mutateElement,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
resetScene: this.resetScene,
|
||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||
getSceneElementsMapIncludingDeleted:
|
||||
this.getSceneElementsMapIncludingDeleted,
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
scrollToContent: this.scrollToContent,
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
getFiles: () => this.files,
|
||||
getName: this.getName,
|
||||
registerAction: (action: Action) => {
|
||||
this.actionManager.registerAction(action);
|
||||
},
|
||||
refresh: this.refresh,
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
getEditorInterface: () => this.editorInterface,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
|
||||
onStateChange: this.onStateChange,
|
||||
onEvent: this.onEvent,
|
||||
} as const;
|
||||
|
||||
props.onExcalidrawAPI?.(this.api);
|
||||
}
|
||||
|
||||
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
||||
@@ -2042,282 +2074,279 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onPointerEnter={this.toggleOverscrollBehavior}
|
||||
onPointerLeave={this.toggleOverscrollBehavior}
|
||||
>
|
||||
<AppContext.Provider value={this}>
|
||||
<AppPropsContext.Provider value={this.props}>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<EditorInterfaceContext.Provider value={this.editorInterface}>
|
||||
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<ExcalidrawActionManagerContext.Provider
|
||||
value={this.actionManager}
|
||||
<ExcalidrawAPIContext.Provider value={this.api}>
|
||||
<AppContext.Provider value={this}>
|
||||
<AppPropsContext.Provider value={this.props}>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<EditorInterfaceContext.Provider value={this.editorInterface}>
|
||||
<ExcalidrawSetAppStateContext.Provider
|
||||
value={this.setAppState}
|
||||
>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
langCode={getLanguage().code}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
UIOptions={this.props.UIOptions}
|
||||
onExportImage={this.onExportImage}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type ===
|
||||
this.state.preferredSelectionTool.type &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
generateLinkForSelection={
|
||||
this.props.generateLinkForSelection
|
||||
}
|
||||
<ExcalidrawActionManagerContext.Provider
|
||||
value={this.actionManager}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
langCode={getLanguage().code}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled ===
|
||||
"undefined" && this.state.zenModeEnabled
|
||||
}
|
||||
UIOptions={this.props.UIOptions}
|
||||
onExportImage={this.onExportImage}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type ===
|
||||
this.state.preferredSelectionTool.type &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
generateLinkForSelection={
|
||||
this.props.generateLinkForSelection
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<SVGLayer
|
||||
trails={[
|
||||
this.laserTrails,
|
||||
this.lassoTrail,
|
||||
this.eraserTrail,
|
||||
]}
|
||||
/>
|
||||
{selectedElements.length === 1 &&
|
||||
this.state.openDialog?.name !==
|
||||
"elementLinkSelector" &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={firstSelectedElement.id}
|
||||
element={firstSelectedElement}
|
||||
scene={this.scene}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
setToast={this.setToast}
|
||||
updateEmbedValidationStatus={
|
||||
this.updateEmbedValidationStatus
|
||||
}
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<SVGLayer
|
||||
trails={[
|
||||
this.laserTrails,
|
||||
this.lassoTrail,
|
||||
this.eraserTrail,
|
||||
]}
|
||||
/>
|
||||
{selectedElements.length === 1 &&
|
||||
this.state.openDialog?.name !==
|
||||
"elementLinkSelector" &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={firstSelectedElement.id}
|
||||
element={firstSelectedElement}
|
||||
scene={this.scene}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
setToast={this.setToast}
|
||||
updateEmbedValidationStatus={
|
||||
this.updateEmbedValidationStatus
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{this.props.aiEnabled !== false &&
|
||||
selectedElements.length === 1 &&
|
||||
isMagicFrameElement(firstSelectedElement) && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.convertToCode")}
|
||||
icon={MagicIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onMagicFrameGenerate(
|
||||
firstSelectedElement,
|
||||
"button",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
{selectedElements.length === 1 &&
|
||||
isIframeElement(firstSelectedElement) &&
|
||||
firstSelectedElement.customData?.generationData
|
||||
?.status === "done" && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.copySource")}
|
||||
icon={copyIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onIframeSrcCopy(firstSelectedElement)
|
||||
}
|
||||
/>
|
||||
<ElementCanvasButton
|
||||
title="Enter fullscreen"
|
||||
icon={fullscreenIcon}
|
||||
checked={false}
|
||||
onChange={() => {
|
||||
const iframe =
|
||||
this.getHTMLIFrameElement(
|
||||
firstSelectedElement,
|
||||
);
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.requestFullscreen();
|
||||
this.setState({
|
||||
activeEmbeddable: {
|
||||
element: firstSelectedElement,
|
||||
state: "active",
|
||||
},
|
||||
selectedElementIds: {
|
||||
[firstSelectedElement.id]: true,
|
||||
},
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(err);
|
||||
this.setState({
|
||||
errorMessage:
|
||||
"Couldn't enter fullscreen",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
|
||||
{this.state.contextMenu && (
|
||||
<ContextMenu
|
||||
items={this.state.contextMenu.items}
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
onClose={(callback) => {
|
||||
this.setState({ contextMenu: null }, () => {
|
||||
this.focusContainer();
|
||||
callback?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{this.props.aiEnabled !== false &&
|
||||
selectedElements.length === 1 &&
|
||||
isMagicFrameElement(firstSelectedElement) && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.convertToCode")}
|
||||
icon={MagicIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onMagicFrameGenerate(
|
||||
firstSelectedElement,
|
||||
"button",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
{selectedElements.length === 1 &&
|
||||
isIframeElement(firstSelectedElement) &&
|
||||
firstSelectedElement.customData?.generationData
|
||||
?.status === "done" && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.copySource")}
|
||||
icon={copyIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onIframeSrcCopy(firstSelectedElement)
|
||||
}
|
||||
/>
|
||||
<ElementCanvasButton
|
||||
title="Enter fullscreen"
|
||||
icon={fullscreenIcon}
|
||||
checked={false}
|
||||
onChange={() => {
|
||||
const iframe =
|
||||
this.getHTMLIFrameElement(
|
||||
firstSelectedElement,
|
||||
);
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.requestFullscreen();
|
||||
this.setState({
|
||||
activeEmbeddable: {
|
||||
element: firstSelectedElement,
|
||||
state: "active",
|
||||
},
|
||||
selectedElementIds: {
|
||||
[firstSelectedElement.id]: true,
|
||||
},
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(err);
|
||||
this.setState({
|
||||
errorMessage:
|
||||
"Couldn't enter fullscreen",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={this.handleToastClose}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.state.contextMenu && (
|
||||
<ContextMenu
|
||||
items={this.state.contextMenu.items}
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
onClose={(callback) => {
|
||||
this.setState({ contextMenu: null }, () => {
|
||||
this.focusContainer();
|
||||
callback?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StaticCanvas
|
||||
canvas={this.canvas}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
visibleElements={visibleElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderConfig={{
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: isGridModeEnabled(this),
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus: this.embedsValidationStatus,
|
||||
elementsPendingErasure: this.elementsPendingErasure,
|
||||
pendingFlowchartNodes:
|
||||
this.flowChartCreator.pendingNodes,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
{this.state.newElement && (
|
||||
<NewElementCanvas
|
||||
appState={this.state}
|
||||
scale={window.devicePixelRatio}
|
||||
<StaticCanvas
|
||||
canvas={this.canvas}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
visibleElements={visibleElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderConfig={{
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: false,
|
||||
renderGrid: isGridModeEnabled(this),
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus:
|
||||
this.embedsValidationStatus,
|
||||
elementsPendingErasure:
|
||||
this.elementsPendingErasure,
|
||||
pendingFlowchartNodes: null,
|
||||
pendingFlowchartNodes:
|
||||
this.flowChartCreator.pendingNodes,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InteractiveCanvas
|
||||
app={this}
|
||||
containerRef={this.excalidrawContainerRef}
|
||||
canvas={this.interactiveCanvas}
|
||||
elementsMap={elementsMap}
|
||||
visibleElements={visibleElements}
|
||||
allElementsMap={allElementsMap}
|
||||
selectedElements={selectedElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderScrollbars={
|
||||
this.props.renderScrollbars === true
|
||||
}
|
||||
editorInterface={this.editorInterface}
|
||||
renderInteractiveSceneCallback={
|
||||
this.renderInteractiveSceneCallback
|
||||
}
|
||||
handleCanvasRef={this.handleInteractiveCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.handleCanvasPointerUp}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
/>
|
||||
{this.state.userToFollow && (
|
||||
<FollowMode
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
userToFollow={this.state.userToFollow}
|
||||
onDisconnect={this.maybeUnfollowRemoteUser}
|
||||
/>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
{this.state.activeLockedId && (
|
||||
<UnlockPopup
|
||||
{this.state.newElement && (
|
||||
<NewElementCanvas
|
||||
appState={this.state}
|
||||
scale={window.devicePixelRatio}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
renderConfig={{
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: false,
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus:
|
||||
this.embedsValidationStatus,
|
||||
elementsPendingErasure:
|
||||
this.elementsPendingErasure,
|
||||
pendingFlowchartNodes: null,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InteractiveCanvas
|
||||
app={this}
|
||||
activeLockedId={this.state.activeLockedId}
|
||||
containerRef={this.excalidrawContainerRef}
|
||||
canvas={this.interactiveCanvas}
|
||||
elementsMap={elementsMap}
|
||||
visibleElements={visibleElements}
|
||||
allElementsMap={allElementsMap}
|
||||
selectedElements={selectedElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderScrollbars={
|
||||
this.props.renderScrollbars === true
|
||||
}
|
||||
editorInterface={this.editorInterface}
|
||||
renderInteractiveSceneCallback={
|
||||
this.renderInteractiveSceneCallback
|
||||
}
|
||||
handleCanvasRef={this.handleInteractiveCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.handleCanvasPointerUp}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
/>
|
||||
)}
|
||||
{showShapeSwitchPanel && (
|
||||
<ConvertElementTypePopup app={this} />
|
||||
)}
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
{this.renderEmbeddables()}
|
||||
</ExcalidrawElementsContext.Provider>
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContext.Provider>
|
||||
</EditorInterfaceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</AppPropsContext.Provider>
|
||||
</AppContext.Provider>
|
||||
{this.state.userToFollow && (
|
||||
<FollowMode
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
userToFollow={this.state.userToFollow}
|
||||
onDisconnect={this.maybeUnfollowRemoteUser}
|
||||
/>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
{this.state.activeLockedId && (
|
||||
<UnlockPopup
|
||||
app={this}
|
||||
activeLockedId={this.state.activeLockedId}
|
||||
/>
|
||||
)}
|
||||
{showShapeSwitchPanel && (
|
||||
<ConvertElementTypePopup app={this} />
|
||||
)}
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
{this.renderEmbeddables()}
|
||||
</ExcalidrawElementsContext.Provider>
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContext.Provider>
|
||||
</EditorInterfaceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</AppPropsContext.Provider>
|
||||
</AppContext.Provider>
|
||||
</ExcalidrawAPIContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3015,12 +3044,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.history.record(increment.delta);
|
||||
});
|
||||
|
||||
const { onIncrement } = this.props;
|
||||
|
||||
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
|
||||
if (onIncrement) {
|
||||
if (this.props.onIncrement) {
|
||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||
onIncrement(increment);
|
||||
this.props.onIncrement?.(increment);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3054,6 +3081,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
errorMessage: <BraveMeasureTextError />,
|
||||
});
|
||||
}
|
||||
|
||||
const mountPayload = {
|
||||
excalidrawAPI: this.api,
|
||||
container: this.excalidrawContainerRef.current,
|
||||
};
|
||||
|
||||
this.editorLifecycleEvents.emit("editor:mount", mountPayload);
|
||||
this.props.onMount?.(mountPayload);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@@ -3074,6 +3109,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.onChangeEmitter.clear();
|
||||
this.store.onStoreIncrementEmitter.clear();
|
||||
this.store.onDurableIncrementEmitter.clear();
|
||||
this.appStateObserver.clear();
|
||||
this.editorLifecycleEvents.clear();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
@@ -3239,6 +3276,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
|
||||
// must be updated *before* state change listeners are triggered below
|
||||
if (!this._initialized && !this.state.isLoading) {
|
||||
this._initialized = true;
|
||||
this.editorLifecycleEvents.emit("editor:initialize", this.api);
|
||||
this.props.onInitialize?.(this.api);
|
||||
}
|
||||
|
||||
this.appStateObserver.flush(prevState);
|
||||
|
||||
this.updateEmbeddables();
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||
@@ -4324,13 +4370,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState(state);
|
||||
};
|
||||
|
||||
setToast = (
|
||||
toast: {
|
||||
message: string;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
} | null,
|
||||
) => {
|
||||
setToast = (toast: AppState["toast"]) => {
|
||||
this.setState({ toast });
|
||||
};
|
||||
|
||||
@@ -5155,7 +5195,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// eye dropper
|
||||
// -----------------------------------------------------------------------
|
||||
const lowerCased = event.key.toLocaleLowerCase();
|
||||
const isPickingStroke = lowerCased === KEYS.S && event.shiftKey;
|
||||
const isPickingStroke =
|
||||
lowerCased === KEYS.S && event.shiftKey && !event[KEYS.CTRL_OR_CMD];
|
||||
const isPickingBackground =
|
||||
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
|
||||
|
||||
@@ -11602,7 +11643,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
loadFileToCanvas = async (
|
||||
file: File,
|
||||
fileHandle: FileSystemHandle | null,
|
||||
fileHandle: FileSystemFileHandle | null,
|
||||
) => {
|
||||
file = await normalizeFile(file);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { AppState, UnsubscribeCallback } from "../types";
|
||||
|
||||
type StateChangeSelector =
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown);
|
||||
|
||||
type StateChangePredicateOptions = {
|
||||
predicate: (appState: AppState) => boolean;
|
||||
callback?: (appState: AppState) => void;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
|
||||
|
||||
type StateChangeListener = {
|
||||
predicate: (appState: AppState, prevState: AppState) => boolean;
|
||||
getValue: (appState: AppState) => unknown;
|
||||
callback: (value: any, appState: AppState) => void;
|
||||
once: boolean;
|
||||
};
|
||||
|
||||
type NormalizedStateChange = {
|
||||
predicate: StateChangeListener["predicate"];
|
||||
getValue: StateChangeListener["getValue"];
|
||||
callback?: StateChangeListener["callback"];
|
||||
once: boolean;
|
||||
matchesImmediately: boolean;
|
||||
};
|
||||
|
||||
export type OnStateChange = {
|
||||
<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback: (value: AppState[K], appState: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): UnsubscribeCallback;
|
||||
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
|
||||
(
|
||||
prop: (keyof AppState)[],
|
||||
callback: (appState: AppState, appState2: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): UnsubscribeCallback;
|
||||
(prop: (keyof AppState)[]): Promise<AppState>;
|
||||
<T>(
|
||||
prop: (appState: AppState) => T,
|
||||
callback: (value: T, appState: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): UnsubscribeCallback;
|
||||
<T>(prop: (appState: AppState) => T): Promise<T>;
|
||||
(opts: {
|
||||
predicate: (appState: AppState) => boolean;
|
||||
callback: (appState: AppState) => void;
|
||||
once?: boolean;
|
||||
}): UnsubscribeCallback;
|
||||
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
|
||||
(
|
||||
selector: StateChangeSelector,
|
||||
callback: (value: any, appState: AppState) => void,
|
||||
): any;
|
||||
};
|
||||
|
||||
export class AppStateObserver {
|
||||
private listeners: StateChangeListener[] = [];
|
||||
|
||||
constructor(private readonly getState: () => AppState) {}
|
||||
|
||||
private isStateChangePredicateOptions(
|
||||
propOrOpts: StateChangeArg,
|
||||
): propOrOpts is StateChangePredicateOptions {
|
||||
return (
|
||||
typeof propOrOpts === "object" &&
|
||||
!Array.isArray(propOrOpts) &&
|
||||
"predicate" in propOrOpts
|
||||
);
|
||||
}
|
||||
|
||||
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(
|
||||
(existingListener) => existingListener !== listener,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private normalize(
|
||||
propOrOpts: StateChangeArg,
|
||||
callback?: (value: any, appState: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): NormalizedStateChange {
|
||||
let predicate: StateChangeListener["predicate"];
|
||||
let getValue: StateChangeListener["getValue"];
|
||||
let normalizedCallback = callback;
|
||||
let once = opts?.once ?? false;
|
||||
let matchesImmediately = false;
|
||||
|
||||
if (this.isStateChangePredicateOptions(propOrOpts)) {
|
||||
const {
|
||||
predicate: predicateFn,
|
||||
callback: callbackFromOpts,
|
||||
once: onceFromOpts,
|
||||
} = propOrOpts;
|
||||
|
||||
predicate = predicateFn;
|
||||
getValue = (appState: AppState) => appState;
|
||||
normalizedCallback = callbackFromOpts
|
||||
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
|
||||
: undefined;
|
||||
once = onceFromOpts ?? false;
|
||||
matchesImmediately = predicateFn(this.getState());
|
||||
} else if (typeof propOrOpts === "function") {
|
||||
const selector = propOrOpts;
|
||||
predicate = (appState: AppState, prevState: AppState) =>
|
||||
selector(appState) !== selector(prevState);
|
||||
getValue = (appState: AppState) => selector(appState);
|
||||
} else if (Array.isArray(propOrOpts)) {
|
||||
const keys = propOrOpts;
|
||||
predicate = (appState: AppState, prevState: AppState) =>
|
||||
keys.some((key) => appState[key] !== prevState[key]);
|
||||
getValue = (appState: AppState) => appState;
|
||||
} else {
|
||||
const key = propOrOpts;
|
||||
predicate = (appState: AppState, prevState: AppState) =>
|
||||
appState[key] !== prevState[key];
|
||||
getValue = (appState: AppState) => appState[key];
|
||||
}
|
||||
|
||||
return {
|
||||
predicate,
|
||||
getValue,
|
||||
callback: normalizedCallback,
|
||||
once,
|
||||
matchesImmediately,
|
||||
};
|
||||
}
|
||||
|
||||
public onStateChange: OnStateChange = ((
|
||||
propOrOpts: StateChangeArg,
|
||||
callback?: any,
|
||||
opts?: { once: boolean },
|
||||
) => {
|
||||
const {
|
||||
predicate,
|
||||
getValue,
|
||||
callback: stateChangeCallback,
|
||||
once,
|
||||
matchesImmediately,
|
||||
} = this.normalize(propOrOpts, callback, opts);
|
||||
|
||||
if (stateChangeCallback) {
|
||||
if (matchesImmediately) {
|
||||
queueMicrotask(() => {
|
||||
const state = this.getState();
|
||||
stateChangeCallback(getValue(state), state);
|
||||
});
|
||||
if (once) {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.subscribe({
|
||||
predicate,
|
||||
getValue,
|
||||
callback: stateChangeCallback,
|
||||
once,
|
||||
});
|
||||
}
|
||||
|
||||
if (matchesImmediately) {
|
||||
return Promise.resolve(getValue(this.getState()));
|
||||
}
|
||||
|
||||
return new Promise<any>((resolve) => {
|
||||
this.subscribe({
|
||||
predicate,
|
||||
getValue,
|
||||
callback: (value) => resolve(value),
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}) as OnStateChange;
|
||||
|
||||
public flush(prevState: AppState) {
|
||||
if (!this.listeners.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getState();
|
||||
const listenersToKeep: StateChangeListener[] = [];
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
if (listener.predicate(state, prevState)) {
|
||||
listener.callback(listener.getValue(state), state);
|
||||
if (!listener.once) {
|
||||
listenersToKeep.push(listener);
|
||||
}
|
||||
} else {
|
||||
listenersToKeep.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
this.listeners = listenersToKeep;
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.listeners = [];
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,11 @@ import {
|
||||
isWritableElement,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
||||
|
||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||
|
||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
|
||||
import { getShortcutKey } from "../../shortcut";
|
||||
|
||||
import {
|
||||
actionClearCanvas,
|
||||
actionLink,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
getLinearElementSubType,
|
||||
mutateElement,
|
||||
updateElbowArrowPoints,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -37,6 +39,8 @@ import {
|
||||
isProdEnv,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
ROUNDNESS,
|
||||
sceneCoordsToViewportCoords,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -71,12 +75,6 @@ import type {
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
mutateElement,
|
||||
ROUNDNESS,
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "..";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { atom } from "../editor-jotai";
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import { ImageExportDialog } from "./ImageExportDialog";
|
||||
import { Island } from "./Island";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { Toast } from "./Toast";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
@@ -605,18 +606,30 @@ const LayerUI = ({
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
{(appState.toast || appState.scrolledOutside) && (
|
||||
<div className="floating-status-stack">
|
||||
{appState.toast && (
|
||||
<Toast
|
||||
message={appState.toast.message}
|
||||
onClose={() => setAppState({ toast: null })}
|
||||
duration={appState.toast.duration}
|
||||
closable={appState.toast.closable}
|
||||
/>
|
||||
)}
|
||||
{!appState.toast && appState.scrolledOutside && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSidebars()}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
|
||||
|
||||
import { ChatHistoryMenu } from "./ChatHistoryMenu";
|
||||
|
||||
import { ChatInterface } from ".";
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
|
||||
import type { TTDPanelAction } from "../TTDDialogPanel";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||
import { getShortcutKey } from "../../shortcut";
|
||||
|
||||
export const TTDDialogSubmitShortcut = () => {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
EDITOR_LS_KEYS,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { convertToExcalidrawElements } from "@excalidraw/element";
|
||||
|
||||
import { exportToCanvas } from "@excalidraw/utils";
|
||||
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -6,11 +14,6 @@ import type {
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
exportToCanvas,
|
||||
THEME,
|
||||
} from "../../index";
|
||||
|
||||
import type { MermaidToExcalidrawLibProps } from "./types";
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { RequestError } from "@excalidraw/excalidraw/errors";
|
||||
import { RequestError } from "../../../errors";
|
||||
|
||||
import type {
|
||||
LLMMessage,
|
||||
TTTDDialog,
|
||||
} from "@excalidraw/excalidraw/components/TTDDialog/types";
|
||||
import type { LLMMessage, TTTDDialog } from "../types";
|
||||
|
||||
interface RateLimitInfo {
|
||||
rateLimit?: number;
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.Toast {
|
||||
$closeButtonSize: 1.2rem;
|
||||
$closeButtonPadding: 0.4rem;
|
||||
|
||||
animation: fade-in 0.5s;
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 4px;
|
||||
bottom: 10px;
|
||||
animation: Toast-fade-in 0.5s;
|
||||
min-width: 220px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--text-primary-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
left: 50%;
|
||||
margin-left: -150px;
|
||||
padding: 4px 0;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
z-index: 999999;
|
||||
pointer-events: none;
|
||||
|
||||
.Toast__message {
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||
color: var(--popup-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.Toast__progress-bar {
|
||||
margin-top: 0.35rem;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--button-gray-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Toast__progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: $closeButtonPadding;
|
||||
pointer-events: auto;
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: $closeButtonSize;
|
||||
@@ -38,7 +54,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
@keyframes Toast-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,22 @@ import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./Toast.scss";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
const DEFAULT_TOAST_TIMEOUT = 5000;
|
||||
|
||||
export const Toast = ({
|
||||
const ProgressBar = ({ progress }: { progress: number }) => (
|
||||
<div className="Toast__progress-bar">
|
||||
<div
|
||||
className="Toast__progress-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(5, Math.round(progress * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ToastComponent = ({
|
||||
message,
|
||||
onClose,
|
||||
closable = false,
|
||||
@@ -17,7 +28,7 @@ export const Toast = ({
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
style,
|
||||
}: {
|
||||
message: string;
|
||||
message: ReactNode;
|
||||
onClose: () => void;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
@@ -47,11 +58,12 @@ export const Toast = ({
|
||||
return (
|
||||
<div
|
||||
className="Toast"
|
||||
role="status"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={style}
|
||||
>
|
||||
<p className="Toast__message">{message}</p>
|
||||
<div className="Toast__message">{message}</div>
|
||||
{closable && (
|
||||
<ToolButton
|
||||
icon={CloseIcon}
|
||||
@@ -64,3 +76,5 @@ export const Toast = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Toast = Object.assign(ToastComponent, { ProgressBar });
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
sceneCoordsToViewportCoords,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
|
||||
|
||||
import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
@@ -24,6 +23,8 @@ import type {
|
||||
import { t } from "../../i18n";
|
||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||
|
||||
import { AnimationController } from "../../renderer/animation";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
|
||||
@@ -500,6 +500,26 @@ body.excalidraw-cursor-resize * {
|
||||
}
|
||||
}
|
||||
|
||||
.floating-status-stack {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 30px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
|
||||
.scroll-back-to-content {
|
||||
position: static;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
transform: none;
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
@include outlineButtonStyles;
|
||||
@include filledButtonOnCanvas;
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
|
||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||
|
||||
import type { FileSystemHandle } from "browser-fs-access";
|
||||
import type { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||
@@ -104,7 +103,7 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
export const getFileHandleType = (handle: FileSystemHandle | null) => {
|
||||
export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
@@ -118,7 +117,9 @@ export const isImageFileHandleType = (
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
|
||||
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||
export const isImageFileHandle = (
|
||||
handle: FileSystemFileHandle | null,
|
||||
): handle is FileSystemFileHandle => {
|
||||
const type = getFileHandleType(handle);
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
@@ -139,8 +140,8 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemFileHandle | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
let data;
|
||||
@@ -198,8 +199,8 @@ export const loadFromBlob = async (
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemFileHandle | null,
|
||||
) => {
|
||||
const ret = await loadSceneOrLibraryFromBlob(
|
||||
blob,
|
||||
@@ -392,7 +393,7 @@ export const ImageURLToFile = async (
|
||||
|
||||
export const getFileHandle = async (
|
||||
event: DragEvent | React.DragEvent | DataTransferItem,
|
||||
): Promise<FileSystemHandle | null> => {
|
||||
): Promise<FileSystemFileHandle | null> => {
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
const dataTransferItem =
|
||||
@@ -400,7 +401,7 @@ export const getFileHandle = async (
|
||||
? event
|
||||
: (event as DragEvent).dataTransfer?.items?.[0];
|
||||
|
||||
const handle: FileSystemHandle | null =
|
||||
const handle: FileSystemFileHandle | null =
|
||||
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
|
||||
|
||||
return handle;
|
||||
|
||||
@@ -4,18 +4,12 @@ import {
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "browser-fs-access";
|
||||
|
||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
import { MIME_TYPES } from "@excalidraw/common";
|
||||
|
||||
import { normalizeFile } from "./blob";
|
||||
|
||||
import type { FileSystemHandle } from "browser-fs-access";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 5000;
|
||||
|
||||
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
description: string;
|
||||
@@ -42,40 +36,6 @@ export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
||||
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new AbortError());
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(files)) {
|
||||
@@ -95,8 +55,8 @@ export const fileSave = (
|
||||
extension: FILE_EXTENSION;
|
||||
mimeTypes?: string[];
|
||||
description: string;
|
||||
/** existing FileSystemHandle */
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
/** existing FileSystemFileHandle */
|
||||
fileHandle?: FileSystemFileHandle | null;
|
||||
},
|
||||
) => {
|
||||
return _fileSave(
|
||||
@@ -108,8 +68,8 @@ export const fileSave = (
|
||||
mimeTypes: opts.mimeTypes,
|
||||
},
|
||||
opts.fileHandle,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
export { nativeFileSystemSupported };
|
||||
export type { FileSystemHandle };
|
||||
|
||||
@@ -33,8 +33,6 @@ import { canvasToBlob } from "./blob";
|
||||
import { fileSave } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
|
||||
import type { FileSystemHandle } from "./filesystem";
|
||||
|
||||
import type { ExportType } from "../scene/types";
|
||||
import type { AppState, BinaryFiles } from "../types";
|
||||
|
||||
@@ -110,7 +108,7 @@ export const exportCanvas = async (
|
||||
viewBackgroundColor: string;
|
||||
/** filename, if applicable */
|
||||
name?: string;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
fileHandle?: FileSystemFileHandle | null;
|
||||
exportingFrame: ExcalidrawFrameLikeElement | null;
|
||||
},
|
||||
) => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
DEFAULT_FILENAME,
|
||||
EXPORT_DATA_TYPES,
|
||||
getExportSource,
|
||||
MIME_TYPES,
|
||||
VERSIONS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, NonDeleted } from "@excalidraw/element/types";
|
||||
|
||||
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
|
||||
@@ -21,6 +22,12 @@ import type {
|
||||
ImportedLibraryData,
|
||||
} from "./types";
|
||||
|
||||
export type JSONExportData = {
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips out files which are only referenced by deleted elements
|
||||
*/
|
||||
@@ -67,27 +74,29 @@ export const serializeAsJSON = (
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
/** filename */
|
||||
name: string = appState.name || DEFAULT_FILENAME,
|
||||
) => {
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
const blob = new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
export const saveAsJSON = async ({
|
||||
data,
|
||||
filename,
|
||||
fileHandle,
|
||||
}: {
|
||||
data: MaybePromise<JSONExportData>;
|
||||
filename: string;
|
||||
fileHandle: AppState["fileHandle"];
|
||||
}) => {
|
||||
const blob = Promise.resolve(data).then(({ elements, appState, files }) => {
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
return new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
});
|
||||
});
|
||||
|
||||
const fileHandle = await fileSave(blob, {
|
||||
name,
|
||||
const savedFileHandle = await fileSave(blob, {
|
||||
name: filename,
|
||||
extension: "excalidraw",
|
||||
description: "Excalidraw file",
|
||||
fileHandle: isImageFileHandle(appState.fileHandle)
|
||||
? null
|
||||
: appState.fileHandle,
|
||||
fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
|
||||
});
|
||||
return { fileHandle };
|
||||
return { fileHandle: savedFileHandle };
|
||||
};
|
||||
|
||||
export const loadFromJSON = async (
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { exportCanvas, prepareElementsForExport } from ".";
|
||||
|
||||
import type { AppState, BinaryFiles } from "../types";
|
||||
|
||||
export const resaveAsImageWithScene = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
name: string,
|
||||
data: MaybePromise<{
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
}>,
|
||||
fileHandle: FileSystemFileHandle,
|
||||
filename: string,
|
||||
) => {
|
||||
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
|
||||
|
||||
const fileHandleType = getFileHandleType(fileHandle);
|
||||
|
||||
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
|
||||
if (Math.random() < 1) {
|
||||
throw new Error("OLALALALA");
|
||||
}
|
||||
|
||||
if (!isImageFileHandleType(fileHandleType)) {
|
||||
throw new Error(
|
||||
"fileHandle should exist and should be of type svg or png when resaving",
|
||||
);
|
||||
}
|
||||
|
||||
let { elements, appState, files } = await data;
|
||||
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
appState = {
|
||||
...appState,
|
||||
exportEmbedScene: true,
|
||||
@@ -35,7 +48,7 @@ export const resaveAsImageWithScene = async (
|
||||
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
name: filename,
|
||||
fileHandle,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -52,7 +52,7 @@ declare module "png-chunks-extract" {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface Blob {
|
||||
handle?: import("browser-fs-acces").FileSystemHandle;
|
||||
handle?: FileSystemFileHandle;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useExcalidrawAPI } from "../components/App";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
/**
|
||||
* Subscribes to specific appState changes. The component re-renders
|
||||
* only when the specified prop(s) change — not on every appState update.
|
||||
*
|
||||
* Works both inside and outside the <Excalidraw> tree, as long as
|
||||
* ExcalidrawAPIContext.Provider is an ancestor (automatically provided
|
||||
* inside <Excalidraw>, or manually by the host app).
|
||||
*
|
||||
* Returns the narrowed value depending on prop form:
|
||||
* - `keyof AppState` → `AppState[K]`
|
||||
* - `(keyof AppState)[]` → whole `AppState`
|
||||
* - selector function → selector's return type `T`
|
||||
*
|
||||
* Calls the optional callback with the latest value on every change (not called
|
||||
* on initial render).
|
||||
*
|
||||
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
|
||||
* value once available.
|
||||
*/
|
||||
export function useAppStateValue<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback?: (value: AppState[K], appState: AppState) => void,
|
||||
_internal?: boolean,
|
||||
): AppState[K];
|
||||
export function useAppStateValue(
|
||||
props: (keyof AppState)[],
|
||||
callback?: (props: AppState, appState: AppState) => void,
|
||||
_internal?: boolean,
|
||||
): AppState;
|
||||
export function useAppStateValue<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
callback?: (value: T, appState: AppState) => void,
|
||||
_internal?: boolean,
|
||||
): T;
|
||||
export function useAppStateValue(
|
||||
selector:
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown),
|
||||
callback?: (value: any, appState: AppState) => void,
|
||||
_internal: boolean = true,
|
||||
): unknown {
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
const getLatestValue = () => {
|
||||
let appState = api?.getAppState();
|
||||
if (!appState) {
|
||||
if (!_internal) {
|
||||
return undefined;
|
||||
}
|
||||
console.warn(
|
||||
"useAppStateValue: excalidrawAPI not defined yet for internal component in which case it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
|
||||
);
|
||||
// fall back in case there's a bug so we don't break the app
|
||||
// (internal components using this internal useAppStateValue expect
|
||||
// non-undefined values on init)
|
||||
appState = Object.assign(
|
||||
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
|
||||
getDefaultAppState(),
|
||||
);
|
||||
}
|
||||
if (typeof selector === "function") {
|
||||
return selector(appState);
|
||||
}
|
||||
if (Array.isArray(selector)) {
|
||||
return appState;
|
||||
}
|
||||
return appState[selector];
|
||||
};
|
||||
|
||||
const [value, setValue] = useState<unknown>(getLatestValue);
|
||||
|
||||
const stateRef = useRef({
|
||||
selector,
|
||||
callback,
|
||||
isInitialized: !!api,
|
||||
latestValue: value,
|
||||
});
|
||||
stateRef.current.selector = selector;
|
||||
stateRef.current.callback = callback;
|
||||
if (!stateRef.current.isInitialized && api) {
|
||||
stateRef.current.isInitialized = true;
|
||||
stateRef.current.latestValue = getLatestValue();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
return api.onStateChange(
|
||||
stateRef.current.selector,
|
||||
(newValue: any, state: AppState) => {
|
||||
stateRef.current.latestValue = newValue;
|
||||
stateRef.current.callback?.(newValue, state);
|
||||
setValue(newValue);
|
||||
},
|
||||
);
|
||||
}, [api]);
|
||||
|
||||
return stateRef.current.latestValue;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
import App from "./components/App";
|
||||
import App, {
|
||||
ExcalidrawAPIContext,
|
||||
ExcalidrawAPISetContext,
|
||||
} from "./components/App";
|
||||
import { InitializeApp } from "./components/InitializeApp";
|
||||
import Footer from "./components/footer/FooterCenter";
|
||||
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
|
||||
import MainMenu from "./components/main-menu/MainMenu";
|
||||
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
||||
import { defaultLang } from "./i18n";
|
||||
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
|
||||
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
||||
import polyfill from "./polyfill";
|
||||
|
||||
@@ -16,16 +20,44 @@ import "./css/app.scss";
|
||||
import "./css/styles.scss";
|
||||
import "./fonts/fonts.css";
|
||||
|
||||
import type { AppProps, ExcalidrawProps } from "./types";
|
||||
import type {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawImperativeAPI,
|
||||
ExcalidrawProps,
|
||||
} from "./types";
|
||||
|
||||
polyfill();
|
||||
|
||||
/**
|
||||
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
|
||||
* on it, such as `useAppStateValue()`) to work outside the <Excalidraw>
|
||||
* component tree.
|
||||
*/
|
||||
export const ExcalidrawAPIProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||
return (
|
||||
<ExcalidrawAPIContext.Provider value={api}>
|
||||
<ExcalidrawAPISetContext.Provider value={setApi}>
|
||||
{children}
|
||||
</ExcalidrawAPISetContext.Provider>
|
||||
</ExcalidrawAPIContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
const {
|
||||
onExport,
|
||||
onChange,
|
||||
onIncrement,
|
||||
initialData,
|
||||
excalidrawAPI,
|
||||
onExcalidrawAPI,
|
||||
onMount,
|
||||
onInitialize,
|
||||
isCollaborating = false,
|
||||
onPointerUpdate,
|
||||
renderTopLeftUI,
|
||||
@@ -86,6 +118,15 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
UIOptions.canvasActions.toggleTheme = true;
|
||||
}
|
||||
|
||||
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
|
||||
const handleExcalidrawAPI = useCallback(
|
||||
(api: ExcalidrawImperativeAPI) => {
|
||||
setExcalidrawAPI?.(api);
|
||||
onExcalidrawAPI?.(api);
|
||||
},
|
||||
[onExcalidrawAPI, setExcalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const importPolyfill = async () => {
|
||||
//@ts-ignore
|
||||
@@ -115,10 +156,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
<EditorJotaiProvider store={editorJotaiStore}>
|
||||
<InitializeApp langCode={langCode} theme={theme}>
|
||||
<App
|
||||
onExport={onExport}
|
||||
onChange={onChange}
|
||||
onIncrement={onIncrement}
|
||||
initialData={initialData}
|
||||
excalidrawAPI={excalidrawAPI}
|
||||
onExcalidrawAPI={handleExcalidrawAPI}
|
||||
onMount={onMount}
|
||||
onInitialize={onInitialize}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
@@ -285,7 +329,13 @@ export { Button } from "./components/Button";
|
||||
export { Footer };
|
||||
export { MainMenu };
|
||||
export { Ellipsify } from "./components/Ellipsify";
|
||||
export { useEditorInterface, useStylesPanelMode } from "./components/App";
|
||||
export {
|
||||
useEditorInterface,
|
||||
useStylesPanelMode,
|
||||
useExcalidrawAPI,
|
||||
ExcalidrawAPIContext,
|
||||
} from "./components/App";
|
||||
|
||||
export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
export { Stats } from "./components/Stats";
|
||||
@@ -326,3 +376,37 @@ export {
|
||||
tryParseSpreadsheet,
|
||||
isSpreadsheetValidForChartType,
|
||||
} from "./charts";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// useAppStateValue() wrapper for host apps for the return type to reflect the
|
||||
// the potentially `undefined` value for initial render before the excalidrawAPI
|
||||
// is ready.
|
||||
//
|
||||
/**
|
||||
* hook that subscribes to specific appState prop(s)
|
||||
*
|
||||
* @param prop - appState prop(s) to subscribe to, or a selector function.
|
||||
* NOTE `prop/selector` is memoized and will not change after initial render
|
||||
*/
|
||||
export function useAppStateValue<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback?: (value: AppState[K], appState: AppState) => void,
|
||||
): AppState[K] | undefined;
|
||||
export function useAppStateValue<T extends keyof AppState>(
|
||||
props: T[],
|
||||
callback?: (values: AppState, appState: AppState) => void,
|
||||
): AppState | undefined;
|
||||
export function useAppStateValue<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
callback?: (value: T, appState: AppState) => void,
|
||||
): T | undefined;
|
||||
export function useAppStateValue(
|
||||
selector:
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown),
|
||||
callback?: (value: any, appState: AppState) => void,
|
||||
) {
|
||||
return _useAppStateValue(selector as any, callback, false);
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -403,6 +403,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "Saving",
|
||||
"defaultMessage": "Preparing to save..."
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Save to disk",
|
||||
"disk_details": "Export the scene data to a file from which you can import later.",
|
||||
|
||||
@@ -90,8 +90,7 @@
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"radix-ui": "1.4.3",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
@@ -112,6 +111,7 @@
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "1.0.1",
|
||||
"pwacompat": "2.0.17",
|
||||
"radix-ui": "1.4.3",
|
||||
"roughjs": "4.6.4",
|
||||
"sass": "1.51.0",
|
||||
"tunnel-rat": "0.1.2"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolvablePromise } from "@excalidraw/common";
|
||||
import { Excalidraw, CaptureUpdateAction } from "../../index";
|
||||
import { API } from "../helpers/api";
|
||||
import { Pointer } from "../helpers/ui";
|
||||
import { render } from "../test-utils";
|
||||
import { render, unmountComponent } from "../test-utils";
|
||||
|
||||
import type { ExcalidrawImperativeAPI } from "../../types";
|
||||
|
||||
@@ -21,12 +21,91 @@ describe("event callbacks", () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
/>,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
});
|
||||
|
||||
it("should resolve editor:mount/editor:initialize when subscribed before mount", async () => {
|
||||
unmountComponent();
|
||||
|
||||
const lifecyclePromise = resolvablePromise<{
|
||||
api: ExcalidrawImperativeAPI;
|
||||
mount: Promise<{
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
container: HTMLDivElement | null;
|
||||
}>;
|
||||
initialize: Promise<ExcalidrawImperativeAPI>;
|
||||
}>();
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) =>
|
||||
lifecyclePromise.resolve({
|
||||
api: api as ExcalidrawImperativeAPI,
|
||||
mount: api.onEvent("editor:mount"),
|
||||
initialize: api.onEvent("editor:initialize"),
|
||||
})
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
const { api, mount, initialize } = await lifecyclePromise;
|
||||
await expect(mount).resolves.toEqual({
|
||||
excalidrawAPI: api,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
await expect(initialize).resolves.toBe(api);
|
||||
});
|
||||
|
||||
it("should replay editor:mount/editor:initialize to late subscribers", async () => {
|
||||
const onMount = vi.fn();
|
||||
const onInitialize = vi.fn();
|
||||
|
||||
excalidrawAPI.onEvent("editor:mount", onMount);
|
||||
excalidrawAPI.onEvent("editor:initialize", onInitialize);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(onMount).toHaveBeenCalledWith({
|
||||
excalidrawAPI,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
expect(onInitialize).toHaveBeenCalledTimes(1);
|
||||
expect(onInitialize).toHaveBeenCalledWith(excalidrawAPI);
|
||||
|
||||
await expect(excalidrawAPI.onEvent("editor:mount")).resolves.toEqual({
|
||||
excalidrawAPI,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
await expect(excalidrawAPI.onEvent("editor:initialize")).resolves.toBe(
|
||||
excalidrawAPI,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onMount before onInitialize props", async () => {
|
||||
unmountComponent();
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
onMount={({ excalidrawAPI, container }) => {
|
||||
expect(excalidrawAPI).toBeDefined();
|
||||
expect(container).toBeInstanceOf(HTMLDivElement);
|
||||
calls.push("mount");
|
||||
}}
|
||||
onInitialize={() => {
|
||||
calls.push("initialize");
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(calls).toEqual(["mount", "initialize"]);
|
||||
});
|
||||
|
||||
it("should trigger onChange on render", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("setActiveTool()", () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
/>,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
|
||||
@@ -53,7 +53,6 @@ import type { Spreadsheet } from "./charts";
|
||||
import type { ClipboardData } from "./clipboard";
|
||||
import type App from "./components/App";
|
||||
import type Library from "./data/library";
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||
import type { SnapLine } from "./snapping";
|
||||
import type { ImportedDataState } from "./data/types";
|
||||
@@ -408,7 +407,11 @@ export interface AppState {
|
||||
previousSelectedElementIds: { [id: string]: true };
|
||||
selectedElementsAreBeingDragged: boolean;
|
||||
shouldCacheIgnoreZoom: boolean;
|
||||
toast: { message: string; closable?: boolean; duration?: number } | null;
|
||||
toast: {
|
||||
message: React.ReactNode;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
} | null;
|
||||
zenModeEnabled: boolean;
|
||||
theme: Theme;
|
||||
/** grid cell px size */
|
||||
@@ -427,7 +430,7 @@ export interface AppState {
|
||||
offsetTop: number;
|
||||
offsetLeft: number;
|
||||
|
||||
fileHandle: FileSystemHandle | null;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
collaborators: Map<SocketId, Collaborator>;
|
||||
stats: {
|
||||
open: boolean;
|
||||
@@ -546,17 +549,39 @@ export type OnUserFollowedPayload = {
|
||||
action: "FOLLOW" | "UNFOLLOW";
|
||||
};
|
||||
|
||||
export type OnExportProgress = {
|
||||
type: "progress";
|
||||
message?: React.ReactNode;
|
||||
/** 0-1 range */
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
export interface ExcalidrawProps {
|
||||
onChange?: (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => void;
|
||||
/**
|
||||
* note: only subscribes if the props.onIncrement is defined on initial render
|
||||
*/
|
||||
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
|
||||
initialData?:
|
||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||
excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
|
||||
/**
|
||||
* Invoked as soon as the Excalidraw API is available
|
||||
* NOTE editor is not yet mounted, and state is not yet initialized
|
||||
*/
|
||||
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
|
||||
/**
|
||||
* Invoked once the editor root is mounted.
|
||||
*/
|
||||
onMount?: (payload: ExcalidrawMountPayload) => void;
|
||||
/**
|
||||
* Invoked once the initial scene is loaded.
|
||||
*/
|
||||
onInitialize?: (api: ExcalidrawImperativeAPI) => void;
|
||||
isCollaborating?: boolean;
|
||||
onPointerUpdate?: (payload: {
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
@@ -641,6 +666,32 @@ export interface ExcalidrawProps {
|
||||
aiEnabled?: boolean;
|
||||
showDeprecatedFonts?: boolean;
|
||||
renderScrollbars?: boolean;
|
||||
/**
|
||||
* Called before exporting to a file.
|
||||
*
|
||||
* Allows the host app to intercept and delay saving until async operations
|
||||
* (e.g., images are loaded) complete.
|
||||
*
|
||||
* If Promise/AsyncGenerator is returned, a progress toast will be shown
|
||||
* until the operation completes. Generator can yield progress updates.
|
||||
*/
|
||||
onExport?: (
|
||||
/** type of export. Currently we only call for JSON exports or
|
||||
* JSON-embedded PNG (which is also identified as `json` type here)*/
|
||||
type: "json",
|
||||
data: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
},
|
||||
options: {
|
||||
/** signal that gets aborted if user cancels the export (e.g. closes
|
||||
* the native file picker dialog). In that case, you can either
|
||||
* return immediately, or throw AbortError.
|
||||
*/
|
||||
signal: AbortSignal;
|
||||
},
|
||||
) => MaybePromise<void> | AsyncGenerator<OnExportProgress, void>;
|
||||
}
|
||||
|
||||
export type SceneData = {
|
||||
@@ -719,6 +770,7 @@ export type AppProps = Merge<
|
||||
export type AppClassProperties = {
|
||||
props: AppProps;
|
||||
state: AppState;
|
||||
api: App["api"];
|
||||
sessionExportThemeOverride: App["sessionExportThemeOverride"];
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
/** static canvas */
|
||||
@@ -764,9 +816,13 @@ export type AppClassProperties = {
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
updateEditorAtom: App["updateEditorAtom"];
|
||||
onPointerDownEmitter: App["onPointerDownEmitter"];
|
||||
onEvent: App["onEvent"];
|
||||
onStateChange: App["onStateChange"];
|
||||
|
||||
lastPointerMoveCoords: App["lastPointerMoveCoords"];
|
||||
bindModeHandler: App["bindModeHandler"];
|
||||
|
||||
setAppState: App["setAppState"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -839,6 +895,20 @@ export type PointerDownState = Readonly<{
|
||||
|
||||
export type UnsubscribeCallback = () => void;
|
||||
|
||||
export type ExcalidrawMountPayload = {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
/*
|
||||
*Excalidraw container.
|
||||
* should never be null, but just to be safe
|
||||
*/
|
||||
container: HTMLDivElement | null;
|
||||
};
|
||||
|
||||
export type ExcalidrawImperativeAPIEventMap = {
|
||||
"editor:mount": [payload: ExcalidrawMountPayload];
|
||||
"editor:initialize": [api: ExcalidrawImperativeAPI];
|
||||
};
|
||||
|
||||
export interface ExcalidrawImperativeAPI {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||||
@@ -905,6 +975,8 @@ export interface ExcalidrawImperativeAPI {
|
||||
onUserFollow: (
|
||||
callback: (payload: OnUserFollowedPayload) => void,
|
||||
) => UnsubscribeCallback;
|
||||
onStateChange: InstanceType<typeof App>["onStateChange"];
|
||||
onEvent: InstanceType<typeof App>["onEvent"];
|
||||
}
|
||||
|
||||
export type FrameNameBounds = {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"pako": "2.0.3",
|
||||
"perfect-freehand": "1.2.0",
|
||||
"png-chunk-text": "1.0.0",
|
||||
|
||||
@@ -4603,10 +4603,10 @@ braces@^3.0.3, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
browser-fs-access@0.29.1:
|
||||
version "0.29.1"
|
||||
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.29.1.tgz#8a9794c73cf86b9aec74201829999c597128379c"
|
||||
integrity sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==
|
||||
browser-fs-access@0.38.0:
|
||||
version "0.38.0"
|
||||
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.38.0.tgz#9024c5bf3d962287a08d14beebb86cb819cbb838"
|
||||
integrity sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw==
|
||||
|
||||
browserslist@^4.20.3, browserslist@^4.24.0, browserslist@^4.24.4:
|
||||
version "4.24.4"
|
||||
|
||||
Reference in New Issue
Block a user