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
|
"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",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.0.0",
|
|
||||||
"react-dom": "19.0.0",
|
|
||||||
"@excalidraw/excalidraw": "*",
|
"@excalidraw/excalidraw": "*",
|
||||||
"browser-fs-access": "0.29.1"
|
"browser-fs-access": "0.38.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "5.0.12",
|
"typescript": "^5",
|
||||||
"typescript": "^5"
|
"vite": "5.0.12"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
|
|||||||
|
|
||||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
|
|
||||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
|
||||||
|
|
||||||
export type ResolvablePromise<T> = Promise<T> & {
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
extensions,
|
extensions,
|
||||||
mimeTypes,
|
mimeTypes,
|
||||||
multiple: opts.multiple ?? false,
|
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>;
|
}) as Promise<RetType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+92
-20
@@ -5,6 +5,8 @@ import {
|
|||||||
CaptureUpdateAction,
|
CaptureUpdateAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
useEditorInterface,
|
useEditorInterface,
|
||||||
|
ExcalidrawAPIProvider,
|
||||||
|
useExcalidrawAPI,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||||
@@ -34,7 +36,6 @@ import {
|
|||||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||||
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +75,7 @@ import type {
|
|||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
ExcalidrawInitialDataState,
|
ExcalidrawInitialDataState,
|
||||||
UIAppState,
|
UIAppState,
|
||||||
|
ExcalidrawProps,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||||
@@ -114,6 +116,7 @@ import {
|
|||||||
} from "./data";
|
} from "./data";
|
||||||
|
|
||||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||||
|
import { FileStatusStore } from "./data/fileStatusStore";
|
||||||
import {
|
import {
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ExcalidrawWrapper = () => {
|
const ExcalidrawWrapper = () => {
|
||||||
|
const excalidrawAPI = useExcalidrawAPI();
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}, VERSION_TIMEOUT);
|
}, VERSION_TIMEOUT);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [excalidrawAPI, excalidrawRefCallback] =
|
|
||||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
|
||||||
|
|
||||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
const [collabAPI] = useAtom(collabAPIAtom);
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
@@ -433,18 +435,15 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
useEffect(() => {
|
// ---------------------------------------------------------------------------
|
||||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
// Hoisted loadImages
|
||||||
return;
|
// ---------------------------------------------------------------------------
|
||||||
}
|
const loadImages = useCallback(
|
||||||
|
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||||
const loadImages = (
|
if (!data.scene || !excalidrawAPI) {
|
||||||
data: ResolutionType<typeof initializeScene>,
|
|
||||||
isInitialLoad = false,
|
|
||||||
) => {
|
|
||||||
if (!data.scene) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collabAPI?.isCollaborating()) {
|
if (collabAPI?.isCollaborating()) {
|
||||||
if (data.scene.elements) {
|
if (data.scene.elements) {
|
||||||
collabAPI
|
collabAPI
|
||||||
@@ -471,6 +470,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
}, [] as FileId[]) || [];
|
}, [] as FileId[]) || [];
|
||||||
|
|
||||||
if (data.isExternalScene) {
|
if (data.isExternalScene) {
|
||||||
|
if (fileIds.length) {
|
||||||
|
// Direct Firebase call (not through FileManager), so track manually
|
||||||
|
FileStatusStore.updateStatuses(
|
||||||
|
fileIds.map((id) => [id, "loading"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
loadFilesFromFirebase(
|
loadFilesFromFirebase(
|
||||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||||
data.key,
|
data.key,
|
||||||
@@ -482,12 +487,18 @@ const ExcalidrawWrapper = () => {
|
|||||||
erroredFiles,
|
erroredFiles,
|
||||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
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) {
|
} else if (isInitialLoad) {
|
||||||
if (fileIds.length) {
|
if (fileIds.length) {
|
||||||
LocalData.fileStorage
|
LocalData.fileStorage
|
||||||
.getFiles(fileIds)
|
.getFiles(fileIds)
|
||||||
.then(({ loadedFiles, erroredFiles }) => {
|
.then(async ({ loadedFiles, erroredFiles }) => {
|
||||||
if (loadedFiles.length) {
|
if (loadedFiles.length) {
|
||||||
excalidrawAPI.addFiles(loadedFiles);
|
excalidrawAPI.addFiles(loadedFiles);
|
||||||
}
|
}
|
||||||
@@ -500,10 +511,19 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
// on fresh load, clear unused files from IDB (from previous
|
// on fresh load, clear unused files from IDB (from previous
|
||||||
// session)
|
// 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) => {
|
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||||
loadImages(data, /* isInitialLoad */ true);
|
loadImages(data, /* isInitialLoad */ true);
|
||||||
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||||
@@ -773,6 +793,56 @@ const ExcalidrawWrapper = () => {
|
|||||||
[setShareDialogState],
|
[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
|
// browsers generally prevent infinite self-embedding, there are
|
||||||
// cases where it still happens, and while we disallow self-embedding
|
// cases where it still happens, and while we disallow self-embedding
|
||||||
// by not whitelisting our own origin, this serves as an additional guard
|
// by not whitelisting our own origin, this serves as an additional guard
|
||||||
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={excalidrawRefCallback}
|
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onExport={onExport}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
|
|||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<Provider store={appJotaiStore}>
|
<Provider store={appJotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawAPIProvider>
|
||||||
|
<ExcalidrawWrapper />
|
||||||
|
</ExcalidrawAPIProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
FileManager,
|
FileManager,
|
||||||
updateStaleImageStatuses,
|
updateStaleImageStatuses,
|
||||||
} from "../data/FileManager";
|
} from "../data/FileManager";
|
||||||
|
import { FileStatusStore } from "../data/fileStatusStore";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
this.portal = new Portal(this);
|
||||||
this.fileManager = new FileManager({
|
this.fileManager = new FileManager({
|
||||||
|
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||||
getFiles: async (fileIds) => {
|
getFiles: async (fileIds) => {
|
||||||
const { roomId, roomKey } = this.portal;
|
const { roomId, roomKey } = this.portal;
|
||||||
if (!roomId || !roomKey) {
|
if (!roomId || !roomKey) {
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ export class FileManager {
|
|||||||
|
|
||||||
private _getFiles;
|
private _getFiles;
|
||||||
private _saveFiles;
|
private _saveFiles;
|
||||||
|
private _onFileStatusChange;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
getFiles,
|
getFiles,
|
||||||
saveFiles,
|
saveFiles,
|
||||||
|
onFileStatusChange,
|
||||||
}: {
|
}: {
|
||||||
getFiles: (fileIds: FileId[]) => Promise<{
|
getFiles: (fileIds: FileId[]) => Promise<{
|
||||||
loadedFiles: BinaryFileData[];
|
loadedFiles: BinaryFileData[];
|
||||||
@@ -53,9 +55,13 @@ export class FileManager {
|
|||||||
savedFiles: Map<FileId, BinaryFileData>;
|
savedFiles: Map<FileId, BinaryFileData>;
|
||||||
erroredFiles: Map<FileId, BinaryFileData>;
|
erroredFiles: Map<FileId, BinaryFileData>;
|
||||||
}>;
|
}>;
|
||||||
|
onFileStatusChange?: (
|
||||||
|
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
|
||||||
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
this._getFiles = getFiles;
|
this._getFiles = getFiles;
|
||||||
this._saveFiles = saveFiles;
|
this._saveFiles = saveFiles;
|
||||||
|
this._onFileStatusChange = onFileStatusChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +152,8 @@ export class FileManager {
|
|||||||
this.fetchingFiles.set(id, true);
|
this.fetchingFiles.set(id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||||
|
|
||||||
@@ -156,6 +164,13 @@ export class FileManager {
|
|||||||
this.erroredFiles_fetch.set(fileId, true);
|
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 };
|
return { loadedFiles, erroredFiles };
|
||||||
} finally {
|
} finally {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
@@ -195,6 +210,13 @@ export class FileManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
if (this._onFileStatusChange && this.fetchingFiles.size) {
|
||||||
|
this._onFileStatusChange(
|
||||||
|
[...this.fetchingFiles.keys()].map(
|
||||||
|
(id) => [id, "error"] as [FileId, "error"],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
this.fetchingFiles.clear();
|
this.fetchingFiles.clear();
|
||||||
this.savingFiles.clear();
|
this.savingFiles.clear();
|
||||||
this.savedFiles.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 { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
import { FileManager } from "./FileManager";
|
import { FileManager } from "./FileManager";
|
||||||
|
import { FileStatusStore } from "./fileStatusStore";
|
||||||
import { Locker } from "./Locker";
|
import { Locker } from "./Locker";
|
||||||
import { updateBrowserStateVersion } from "./tabSync";
|
import { updateBrowserStateVersion } from "./tabSync";
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ export class LocalData {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static fileStorage = new LocalFileManager({
|
static fileStorage = new LocalFileManager({
|
||||||
|
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||||
getFiles(ids) {
|
getFiles(ids) {
|
||||||
return getMany(ids, filesStore).then(
|
return getMany(ids, filesStore).then(
|
||||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
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 "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./emitter";
|
export * from "./emitter";
|
||||||
|
export * from "./appEventBus";
|
||||||
export * from "./editorInterface";
|
export * from "./editorInterface";
|
||||||
|
export * from "./versionedSnapshotStore";
|
||||||
export { Debug } from "../debug";
|
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.
|
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
|
## Excalidraw Library
|
||||||
|
|
||||||
## 0.18.0 (2025-03-11)
|
## 0.18.0 (2025-03-11)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
|
||||||
import { useStylesPanelMode } from "..";
|
import { useStylesPanelMode } from "../components/App";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { t } from "../i18n";
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { getShortcutKey } from "../shortcut";
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { useStylesPanelMode } from "..";
|
import { useStylesPanelMode } from "../components/App";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } 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 { useEditorInterface } from "../components/App";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
|
import { Toast } from "../components/Toast";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
|
|
||||||
import { resaveAsImageWithScene } from "../data/resave";
|
import { resaveAsImageWithScene } from "../data/resave";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
|
|||||||
|
|
||||||
import { register } from "./register";
|
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"]>({
|
export const actionChangeProjectName = register<AppState["name"]>({
|
||||||
name: "changeProjectName",
|
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({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
label: "buttons.save",
|
label: "buttons.save",
|
||||||
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
perform: async (elements, appState, value, app) => {
|
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 {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = isImageFileHandle(previousFileHandle)
|
||||||
? await resaveAsImageWithScene(
|
? await resaveAsImageWithScene(
|
||||||
elements,
|
exportedDataPromise,
|
||||||
appState,
|
previousFileHandle,
|
||||||
app.files,
|
filename,
|
||||||
app.getName(),
|
|
||||||
)
|
)
|
||||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
: await saveAsJSON({
|
||||||
|
data: exportedDataPromise,
|
||||||
|
filename,
|
||||||
|
fileHandle: previousFileHandle,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toast: fileHandleExists
|
toast: {
|
||||||
? {
|
message:
|
||||||
message: fileHandle?.name
|
previousFileHandle && fileHandle?.name
|
||||||
? t("toast.fileSavedToFilename").replace(
|
? t("toast.fileSavedToFilename").replace(
|
||||||
"{filename}",
|
"{filename}",
|
||||||
`"${fileHandle.name}"`,
|
`"${fileHandle.name}"`,
|
||||||
)
|
)
|
||||||
: t("toast.fileSaved"),
|
: t("toast.fileSaved"),
|
||||||
}
|
duration: 1500,
|
||||||
: null,
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
return {
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
appState: {
|
||||||
|
toast: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
onExportInProgress = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
|
if (onExportInProgress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onExportInProgress = true;
|
||||||
|
|
||||||
|
const { abortController, data: exportedDataPromise } =
|
||||||
|
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(
|
const { fileHandle: savedFileHandle } = await saveAsJSON({
|
||||||
elements,
|
data: exportedDataPromise,
|
||||||
{
|
filename: app.getName(),
|
||||||
...appState,
|
fileHandle: null,
|
||||||
fileHandle: null,
|
});
|
||||||
},
|
|
||||||
app.files,
|
|
||||||
app.getName(),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
|
||||||
openDialog: null,
|
openDialog: null,
|
||||||
fileHandle,
|
fileHandle: savedFileHandle,
|
||||||
toast: { message: t("toast.fileSaved") },
|
toast: { message: t("toast.fileSaved") },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
abortController.abort();
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
return {
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
appState: {
|
||||||
|
toast: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
onExportInProgress = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
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 }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
|
|||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { useStylesPanelMode } from "..";
|
import { useStylesPanelMode } from "../components/App";
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import {
|
|||||||
normalizeFile,
|
normalizeFile,
|
||||||
} from "./data/blob";
|
} from "./data/blob";
|
||||||
|
|
||||||
import type { FileSystemHandle } from "./data/filesystem";
|
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
@@ -369,7 +367,7 @@ type AllowedParsedDataTransferItem =
|
|||||||
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
kind: "file";
|
kind: "file";
|
||||||
file: File;
|
file: File;
|
||||||
fileHandle: FileSystemHandle | null;
|
fileHandle: FileSystemFileHandle | null;
|
||||||
}
|
}
|
||||||
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||||
|
|
||||||
@@ -378,7 +376,7 @@ type ParsedDataTransferItem =
|
|||||||
type: string;
|
type: string;
|
||||||
kind: "file";
|
kind: "file";
|
||||||
file: File;
|
file: File;
|
||||||
fileHandle: FileSystemHandle | null;
|
fileHandle: FileSystemFileHandle | null;
|
||||||
}
|
}
|
||||||
| { type: string; kind: "string"; value: string };
|
| { type: string; kind: "string"; value: string };
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import {
|
|||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
applyDarkModeFilter,
|
applyDarkModeFilter,
|
||||||
|
AppEventBus,
|
||||||
type EXPORT_IMAGE_TYPES,
|
type EXPORT_IMAGE_TYPES,
|
||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
@@ -448,7 +449,7 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
|||||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||||
import { isPointHittingLink } from "./hyperlink/helpers";
|
import { isPointHittingLink } from "./hyperlink/helpers";
|
||||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||||
import { Toast } from "./Toast";
|
import { AppStateObserver, type OnStateChange } from "./AppStateObserver";
|
||||||
|
|
||||||
import { findShapeByKey } from "./shapes";
|
import { findShapeByKey } from "./shapes";
|
||||||
|
|
||||||
@@ -464,7 +465,6 @@ import type {
|
|||||||
import type { ClipboardData, PastedMixedContent } from "../clipboard";
|
import type { ClipboardData, PastedMixedContent } from "../clipboard";
|
||||||
import type { ExportedElements } from "../data";
|
import type { ExportedElements } from "../data";
|
||||||
import type { ContextMenuItems } from "./ContextMenu";
|
import type { ContextMenuItems } from "./ContextMenu";
|
||||||
import type { FileSystemHandle } from "../data/filesystem";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
@@ -488,6 +488,7 @@ import type {
|
|||||||
UnsubscribeCallback,
|
UnsubscribeCallback,
|
||||||
EmbedsValidationStatus,
|
EmbedsValidationStatus,
|
||||||
ElementsPendingErasure,
|
ElementsPendingErasure,
|
||||||
|
ExcalidrawImperativeAPIEventMap,
|
||||||
GenerateDiagramToCode,
|
GenerateDiagramToCode,
|
||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
Offsets,
|
Offsets,
|
||||||
@@ -513,6 +514,11 @@ const EditorInterfaceContext = React.createContext<EditorInterface>(
|
|||||||
);
|
);
|
||||||
EditorInterfaceContext.displayName = "EditorInterfaceContext";
|
EditorInterfaceContext.displayName = "EditorInterfaceContext";
|
||||||
|
|
||||||
|
const editorLifecycleEventBehavior = {
|
||||||
|
"editor:mount": { cardinality: "once", replay: "last" },
|
||||||
|
"editor:initialize": { cardinality: "once", replay: "last" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const ExcalidrawContainerContext = React.createContext<{
|
export const ExcalidrawContainerContext = React.createContext<{
|
||||||
container: HTMLDivElement | null;
|
container: HTMLDivElement | null;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -545,6 +551,15 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
|||||||
);
|
);
|
||||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
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 useApp = () => useContext(AppContext);
|
||||||
export const useAppProps = () => useContext(AppPropsContext);
|
export const useAppProps = () => useContext(AppPropsContext);
|
||||||
export const useEditorInterface = () =>
|
export const useEditorInterface = () =>
|
||||||
@@ -561,6 +576,10 @@ export const useExcalidrawSetAppState = () =>
|
|||||||
useContext(ExcalidrawSetAppStateContext);
|
useContext(ExcalidrawSetAppStateContext);
|
||||||
export const useExcalidrawActionManager = () =>
|
export const useExcalidrawActionManager = () =>
|
||||||
useContext(ExcalidrawActionManagerContext);
|
useContext(ExcalidrawActionManagerContext);
|
||||||
|
/**
|
||||||
|
* Requires wrapping your component in <ExcalidrawAPIContext.Provider>
|
||||||
|
*/
|
||||||
|
export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext);
|
||||||
|
|
||||||
let didTapTwice: boolean = false;
|
let didTapTwice: boolean = false;
|
||||||
let tappedTwiceTimer = 0;
|
let tappedTwiceTimer = 0;
|
||||||
@@ -635,12 +654,26 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
* insert to DOM before user initially scrolls to them) */
|
* insert to DOM before user initially scrolls to them) */
|
||||||
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
|
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
|
||||||
|
|
||||||
private handleToastClose = () => {
|
|
||||||
this.setToast(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
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();
|
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||||
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
||||||
|
|
||||||
@@ -696,11 +729,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
>();
|
>();
|
||||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||||
|
|
||||||
|
api: ExcalidrawImperativeAPI;
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
const {
|
const {
|
||||||
excalidrawAPI,
|
|
||||||
viewModeEnabled = false,
|
viewModeEnabled = false,
|
||||||
zenModeEnabled = false,
|
zenModeEnabled = false,
|
||||||
gridModeEnabled = false,
|
gridModeEnabled = false,
|
||||||
@@ -708,6 +742,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
theme = defaultAppState.theme,
|
theme = defaultAppState.theme,
|
||||||
name = `${t("labels.untitled")}-${getDateTime()}`,
|
name = `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
...defaultAppState,
|
...defaultAppState,
|
||||||
theme,
|
theme,
|
||||||
@@ -744,51 +779,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.store = new Store(this);
|
this.store = new Store(this);
|
||||||
this.history = new History(this.store);
|
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 = {
|
this.excalidrawContainerValue = {
|
||||||
container: this.excalidrawContainerRef.current,
|
container: this.excalidrawContainerRef.current,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
@@ -800,6 +790,48 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
this.actionManager.registerAction(createUndoAction(this.history));
|
this.actionManager.registerAction(createUndoAction(this.history));
|
||||||
this.actionManager.registerAction(createRedoAction(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>(
|
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
||||||
@@ -2042,282 +2074,279 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
onPointerEnter={this.toggleOverscrollBehavior}
|
onPointerEnter={this.toggleOverscrollBehavior}
|
||||||
onPointerLeave={this.toggleOverscrollBehavior}
|
onPointerLeave={this.toggleOverscrollBehavior}
|
||||||
>
|
>
|
||||||
<AppContext.Provider value={this}>
|
<ExcalidrawAPIContext.Provider value={this.api}>
|
||||||
<AppPropsContext.Provider value={this.props}>
|
<AppContext.Provider value={this}>
|
||||||
<ExcalidrawContainerContext.Provider
|
<AppPropsContext.Provider value={this.props}>
|
||||||
value={this.excalidrawContainerValue}
|
<ExcalidrawContainerContext.Provider
|
||||||
>
|
value={this.excalidrawContainerValue}
|
||||||
<EditorInterfaceContext.Provider value={this.editorInterface}>
|
>
|
||||||
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
|
<EditorInterfaceContext.Provider value={this.editorInterface}>
|
||||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
<ExcalidrawSetAppStateContext.Provider
|
||||||
<ExcalidrawElementsContext.Provider
|
value={this.setAppState}
|
||||||
value={this.scene.getNonDeletedElements()}
|
>
|
||||||
>
|
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||||
<ExcalidrawActionManagerContext.Provider
|
<ExcalidrawElementsContext.Provider
|
||||||
value={this.actionManager}
|
value={this.scene.getNonDeletedElements()}
|
||||||
>
|
>
|
||||||
<LayerUI
|
<ExcalidrawActionManagerContext.Provider
|
||||||
canvas={this.canvas}
|
value={this.actionManager}
|
||||||
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
|
||||||
</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-textEditorContainer" />
|
||||||
<div className="excalidraw-contextMenuContainer" />
|
<div className="excalidraw-contextMenuContainer" />
|
||||||
<div className="excalidraw-eye-dropper-container" />
|
<div className="excalidraw-eye-dropper-container" />
|
||||||
<SVGLayer
|
<SVGLayer
|
||||||
trails={[
|
trails={[
|
||||||
this.laserTrails,
|
this.laserTrails,
|
||||||
this.lassoTrail,
|
this.lassoTrail,
|
||||||
this.eraserTrail,
|
this.eraserTrail,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
this.state.openDialog?.name !==
|
this.state.openDialog?.name !==
|
||||||
"elementLinkSelector" &&
|
"elementLinkSelector" &&
|
||||||
this.state.showHyperlinkPopup && (
|
this.state.showHyperlinkPopup && (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
key={firstSelectedElement.id}
|
key={firstSelectedElement.id}
|
||||||
element={firstSelectedElement}
|
element={firstSelectedElement}
|
||||||
scene={this.scene}
|
scene={this.scene}
|
||||||
setAppState={this.setAppState}
|
setAppState={this.setAppState}
|
||||||
onLinkOpen={this.props.onLinkOpen}
|
onLinkOpen={this.props.onLinkOpen}
|
||||||
setToast={this.setToast}
|
setToast={this.setToast}
|
||||||
updateEmbedValidationStatus={
|
updateEmbedValidationStatus={
|
||||||
this.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 &&
|
<StaticCanvas
|
||||||
selectedElements.length === 1 &&
|
canvas={this.canvas}
|
||||||
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}
|
|
||||||
rc={this.rc}
|
rc={this.rc}
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
allElementsMap={allElementsMap}
|
allElementsMap={allElementsMap}
|
||||||
|
visibleElements={visibleElements}
|
||||||
|
sceneNonce={sceneNonce}
|
||||||
|
selectionNonce={
|
||||||
|
this.state.selectionElement?.versionNonce
|
||||||
|
}
|
||||||
|
scale={window.devicePixelRatio}
|
||||||
|
appState={this.state}
|
||||||
renderConfig={{
|
renderConfig={{
|
||||||
imageCache: this.imageCache,
|
imageCache: this.imageCache,
|
||||||
isExporting: false,
|
isExporting: false,
|
||||||
renderGrid: false,
|
renderGrid: isGridModeEnabled(this),
|
||||||
canvasBackgroundColor:
|
canvasBackgroundColor:
|
||||||
this.state.viewBackgroundColor,
|
this.state.viewBackgroundColor,
|
||||||
embedsValidationStatus:
|
embedsValidationStatus:
|
||||||
this.embedsValidationStatus,
|
this.embedsValidationStatus,
|
||||||
elementsPendingErasure:
|
elementsPendingErasure:
|
||||||
this.elementsPendingErasure,
|
this.elementsPendingErasure,
|
||||||
pendingFlowchartNodes: null,
|
pendingFlowchartNodes:
|
||||||
|
this.flowChartCreator.pendingNodes,
|
||||||
theme: this.state.theme,
|
theme: this.state.theme,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{this.state.newElement && (
|
||||||
<InteractiveCanvas
|
<NewElementCanvas
|
||||||
app={this}
|
appState={this.state}
|
||||||
containerRef={this.excalidrawContainerRef}
|
scale={window.devicePixelRatio}
|
||||||
canvas={this.interactiveCanvas}
|
rc={this.rc}
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
visibleElements={visibleElements}
|
allElementsMap={allElementsMap}
|
||||||
allElementsMap={allElementsMap}
|
renderConfig={{
|
||||||
selectedElements={selectedElements}
|
imageCache: this.imageCache,
|
||||||
sceneNonce={sceneNonce}
|
isExporting: false,
|
||||||
selectionNonce={
|
renderGrid: false,
|
||||||
this.state.selectionElement?.versionNonce
|
canvasBackgroundColor:
|
||||||
}
|
this.state.viewBackgroundColor,
|
||||||
scale={window.devicePixelRatio}
|
embedsValidationStatus:
|
||||||
appState={this.state}
|
this.embedsValidationStatus,
|
||||||
renderScrollbars={
|
elementsPendingErasure:
|
||||||
this.props.renderScrollbars === true
|
this.elementsPendingErasure,
|
||||||
}
|
pendingFlowchartNodes: null,
|
||||||
editorInterface={this.editorInterface}
|
theme: this.state.theme,
|
||||||
renderInteractiveSceneCallback={
|
}}
|
||||||
this.renderInteractiveSceneCallback
|
/>
|
||||||
}
|
)}
|
||||||
handleCanvasRef={this.handleInteractiveCanvasRef}
|
<InteractiveCanvas
|
||||||
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
|
|
||||||
app={this}
|
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}
|
||||||
/>
|
/>
|
||||||
)}
|
{this.state.userToFollow && (
|
||||||
{showShapeSwitchPanel && (
|
<FollowMode
|
||||||
<ConvertElementTypePopup app={this} />
|
width={this.state.width}
|
||||||
)}
|
height={this.state.height}
|
||||||
</ExcalidrawActionManagerContext.Provider>
|
userToFollow={this.state.userToFollow}
|
||||||
{this.renderEmbeddables()}
|
onDisconnect={this.maybeUnfollowRemoteUser}
|
||||||
</ExcalidrawElementsContext.Provider>
|
/>
|
||||||
</ExcalidrawAppStateContext.Provider>
|
)}
|
||||||
</ExcalidrawSetAppStateContext.Provider>
|
{this.renderFrameNames()}
|
||||||
</EditorInterfaceContext.Provider>
|
{this.state.activeLockedId && (
|
||||||
</ExcalidrawContainerContext.Provider>
|
<UnlockPopup
|
||||||
</AppPropsContext.Provider>
|
app={this}
|
||||||
</AppContext.Provider>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3015,12 +3044,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.history.record(increment.delta);
|
this.history.record(increment.delta);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { onIncrement } = this.props;
|
|
||||||
|
|
||||||
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
|
// 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) => {
|
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||||
onIncrement(increment);
|
this.props.onIncrement?.(increment);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3054,6 +3081,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
errorMessage: <BraveMeasureTextError />,
|
errorMessage: <BraveMeasureTextError />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mountPayload = {
|
||||||
|
excalidrawAPI: this.api,
|
||||||
|
container: this.excalidrawContainerRef.current,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.editorLifecycleEvents.emit("editor:mount", mountPayload);
|
||||||
|
this.props.onMount?.(mountPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
@@ -3074,6 +3109,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.onChangeEmitter.clear();
|
this.onChangeEmitter.clear();
|
||||||
this.store.onStoreIncrementEmitter.clear();
|
this.store.onStoreIncrementEmitter.clear();
|
||||||
this.store.onDurableIncrementEmitter.clear();
|
this.store.onDurableIncrementEmitter.clear();
|
||||||
|
this.appStateObserver.clear();
|
||||||
|
this.editorLifecycleEvents.clear();
|
||||||
ShapeCache.destroy();
|
ShapeCache.destroy();
|
||||||
SnapCache.destroy();
|
SnapCache.destroy();
|
||||||
clearTimeout(touchTimeout);
|
clearTimeout(touchTimeout);
|
||||||
@@ -3239,6 +3276,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: AppProps, prevState: 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();
|
this.updateEmbeddables();
|
||||||
const elements = this.scene.getElementsIncludingDeleted();
|
const elements = this.scene.getElementsIncludingDeleted();
|
||||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||||
@@ -4324,13 +4370,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState(state);
|
this.setState(state);
|
||||||
};
|
};
|
||||||
|
|
||||||
setToast = (
|
setToast = (toast: AppState["toast"]) => {
|
||||||
toast: {
|
|
||||||
message: string;
|
|
||||||
closable?: boolean;
|
|
||||||
duration?: number;
|
|
||||||
} | null,
|
|
||||||
) => {
|
|
||||||
this.setState({ toast });
|
this.setState({ toast });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5155,7 +5195,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// eye dropper
|
// eye dropper
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
const lowerCased = event.key.toLocaleLowerCase();
|
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 =
|
const isPickingBackground =
|
||||||
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
|
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
|
||||||
|
|
||||||
@@ -11602,7 +11643,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
loadFileToCanvas = async (
|
loadFileToCanvas = async (
|
||||||
file: File,
|
file: File,
|
||||||
fileHandle: FileSystemHandle | null,
|
fileHandle: FileSystemFileHandle | null,
|
||||||
) => {
|
) => {
|
||||||
file = await normalizeFile(file);
|
file = await normalizeFile(file);
|
||||||
try {
|
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,
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} 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 type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
|
||||||
|
import { getShortcutKey } from "../../shortcut";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
actionClearCanvas,
|
actionClearCanvas,
|
||||||
actionLink,
|
actionLink,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
bumpVersion,
|
||||||
getLinearElementSubType,
|
getLinearElementSubType,
|
||||||
|
mutateElement,
|
||||||
updateElbowArrowPoints,
|
updateElbowArrowPoints,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ import {
|
|||||||
isProdEnv,
|
isProdEnv,
|
||||||
mapFind,
|
mapFind,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
|
ROUNDNESS,
|
||||||
|
sceneCoordsToViewportCoords,
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
@@ -71,12 +75,6 @@ import type {
|
|||||||
|
|
||||||
import type { Scene } from "@excalidraw/element";
|
import type { Scene } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
|
||||||
bumpVersion,
|
|
||||||
mutateElement,
|
|
||||||
ROUNDNESS,
|
|
||||||
sceneCoordsToViewportCoords,
|
|
||||||
} from "..";
|
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { atom } from "../editor-jotai";
|
import { atom } from "../editor-jotai";
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { ImageExportDialog } from "./ImageExportDialog";
|
|||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LaserPointerButton } from "./LaserPointerButton";
|
import { LaserPointerButton } from "./LaserPointerButton";
|
||||||
|
import { Toast } from "./Toast";
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import "./Toolbar.scss";
|
import "./Toolbar.scss";
|
||||||
@@ -605,18 +606,30 @@ const LayerUI = ({
|
|||||||
showExitZenModeBtn={showExitZenModeBtn}
|
showExitZenModeBtn={showExitZenModeBtn}
|
||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
/>
|
/>
|
||||||
{appState.scrolledOutside && (
|
{(appState.toast || appState.scrolledOutside) && (
|
||||||
<button
|
<div className="floating-status-stack">
|
||||||
type="button"
|
{appState.toast && (
|
||||||
className="scroll-back-to-content"
|
<Toast
|
||||||
onClick={() => {
|
message={appState.toast.message}
|
||||||
setAppState((appState) => ({
|
onClose={() => setAppState({ toast: null })}
|
||||||
...calculateScrollCenter(elements, appState),
|
duration={appState.toast.duration}
|
||||||
}));
|
closable={appState.toast.closable}
|
||||||
}}
|
/>
|
||||||
>
|
)}
|
||||||
{t("buttons.scrollBackToContent")}
|
{!appState.toast && appState.scrolledOutside && (
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scroll-back-to-content"
|
||||||
|
onClick={() => {
|
||||||
|
setAppState((appState) => ({
|
||||||
|
...calculateScrollCenter(elements, appState),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
|
|||||||
|
|
||||||
import { ChatHistoryMenu } from "./ChatHistoryMenu";
|
import { ChatHistoryMenu } from "./ChatHistoryMenu";
|
||||||
|
|
||||||
import { ChatInterface } from ".";
|
import { ChatInterface } from "./ChatInterface";
|
||||||
|
|
||||||
import type { TTDPanelAction } from "../TTDDialogPanel";
|
import type { TTDPanelAction } from "../TTDDialogPanel";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
import { getShortcutKey } from "../../shortcut";
|
||||||
|
|
||||||
export const TTDDialogSubmitShortcut = () => {
|
export const TTDDialogSubmitShortcut = () => {
|
||||||
return (
|
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 {
|
import type {
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
@@ -6,11 +14,6 @@ import type {
|
|||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
||||||
import {
|
|
||||||
convertToExcalidrawElements,
|
|
||||||
exportToCanvas,
|
|
||||||
THEME,
|
|
||||||
} from "../../index";
|
|
||||||
|
|
||||||
import type { MermaidToExcalidrawLibProps } from "./types";
|
import type { MermaidToExcalidrawLibProps } from "./types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { RequestError } from "@excalidraw/excalidraw/errors";
|
import { RequestError } from "../../../errors";
|
||||||
|
|
||||||
import type {
|
import type { LLMMessage, TTTDDialog } from "../types";
|
||||||
LLMMessage,
|
|
||||||
TTTDDialog,
|
|
||||||
} from "@excalidraw/excalidraw/components/TTDDialog/types";
|
|
||||||
|
|
||||||
interface RateLimitInfo {
|
interface RateLimitInfo {
|
||||||
rateLimit?: number;
|
rateLimit?: number;
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
@use "../css/variables.module" as *;
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Toast {
|
.Toast {
|
||||||
$closeButtonSize: 1.2rem;
|
$closeButtonSize: 1.2rem;
|
||||||
$closeButtonPadding: 0.4rem;
|
$closeButtonPadding: 0.4rem;
|
||||||
|
|
||||||
animation: fade-in 0.5s;
|
animation: Toast-fade-in 0.5s;
|
||||||
background-color: var(--button-gray-1);
|
min-width: 220px;
|
||||||
border-radius: 4px;
|
max-width: min(360px, calc(100vw - 32px));
|
||||||
bottom: 10px;
|
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;
|
box-sizing: border-box;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
left: 50%;
|
pointer-events: none;
|
||||||
margin-left: -150px;
|
|
||||||
padding: 4px 0;
|
|
||||||
position: absolute;
|
|
||||||
text-align: center;
|
|
||||||
width: 300px;
|
|
||||||
z-index: 999999;
|
|
||||||
|
|
||||||
.Toast__message {
|
.Toast__message {
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||||
color: var(--popup-text-color);
|
|
||||||
white-space: pre-wrap;
|
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 {
|
.close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: $closeButtonPadding;
|
padding: $closeButtonPadding;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
width: $closeButtonSize;
|
width: $closeButtonSize;
|
||||||
@@ -38,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes Toast-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,22 @@ import { ToolButton } from "./ToolButton";
|
|||||||
|
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
const DEFAULT_TOAST_TIMEOUT = 5000;
|
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,
|
message,
|
||||||
onClose,
|
onClose,
|
||||||
closable = false,
|
closable = false,
|
||||||
@@ -17,7 +28,7 @@ export const Toast = ({
|
|||||||
duration = DEFAULT_TOAST_TIMEOUT,
|
duration = DEFAULT_TOAST_TIMEOUT,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
@@ -47,11 +58,12 @@ export const Toast = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Toast"
|
className="Toast"
|
||||||
|
role="status"
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<p className="Toast__message">{message}</p>
|
<div className="Toast__message">{message}</div>
|
||||||
{closable && (
|
{closable && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
icon={CloseIcon}
|
icon={CloseIcon}
|
||||||
@@ -64,3 +76,5 @@ export const Toast = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Toast = Object.assign(ToastComponent, { ProgressBar });
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
type EditorInterface,
|
type EditorInterface,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
@@ -24,6 +23,8 @@ import type {
|
|||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||||
|
|
||||||
|
import { AnimationController } from "../../renderer/animation";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
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 {
|
.help-icon {
|
||||||
@include outlineButtonStyles;
|
@include outlineButtonStyles;
|
||||||
@include filledButtonOnCanvas;
|
@include filledButtonOnCanvas;
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
|
|
||||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||||
|
|
||||||
import type { FileSystemHandle } from "browser-fs-access";
|
|
||||||
import type { ImportedLibraryData } from "./types";
|
import type { ImportedLibraryData } from "./types";
|
||||||
|
|
||||||
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||||
@@ -104,7 +103,7 @@ export const getMimeType = (blob: Blob | string): string => {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileHandleType = (handle: FileSystemHandle | null) => {
|
export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
|
||||||
if (!handle) {
|
if (!handle) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -118,7 +117,9 @@ export const isImageFileHandleType = (
|
|||||||
return type === "png" || type === "svg";
|
return type === "png" || type === "svg";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
export const isImageFileHandle = (
|
||||||
|
handle: FileSystemFileHandle | null,
|
||||||
|
): handle is FileSystemFileHandle => {
|
||||||
const type = getFileHandleType(handle);
|
const type = getFileHandleType(handle);
|
||||||
return type === "png" || type === "svg";
|
return type === "png" || type === "svg";
|
||||||
};
|
};
|
||||||
@@ -139,8 +140,8 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
/** @see restore.localAppState */
|
/** @see restore.localAppState */
|
||||||
localAppState: AppState | null,
|
localAppState: AppState | null,
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
localElements: readonly ExcalidrawElement[] | null,
|
||||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||||
fileHandle?: FileSystemHandle | null,
|
fileHandle?: FileSystemFileHandle | null,
|
||||||
) => {
|
) => {
|
||||||
const contents = await parseFileContents(blob);
|
const contents = await parseFileContents(blob);
|
||||||
let data;
|
let data;
|
||||||
@@ -198,8 +199,8 @@ export const loadFromBlob = async (
|
|||||||
/** @see restore.localAppState */
|
/** @see restore.localAppState */
|
||||||
localAppState: AppState | null,
|
localAppState: AppState | null,
|
||||||
localElements: readonly ExcalidrawElement[] | null,
|
localElements: readonly ExcalidrawElement[] | null,
|
||||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||||
fileHandle?: FileSystemHandle | null,
|
fileHandle?: FileSystemFileHandle | null,
|
||||||
) => {
|
) => {
|
||||||
const ret = await loadSceneOrLibraryFromBlob(
|
const ret = await loadSceneOrLibraryFromBlob(
|
||||||
blob,
|
blob,
|
||||||
@@ -392,7 +393,7 @@ export const ImageURLToFile = async (
|
|||||||
|
|
||||||
export const getFileHandle = async (
|
export const getFileHandle = async (
|
||||||
event: DragEvent | React.DragEvent | DataTransferItem,
|
event: DragEvent | React.DragEvent | DataTransferItem,
|
||||||
): Promise<FileSystemHandle | null> => {
|
): Promise<FileSystemFileHandle | null> => {
|
||||||
if (nativeFileSystemSupported) {
|
if (nativeFileSystemSupported) {
|
||||||
try {
|
try {
|
||||||
const dataTransferItem =
|
const dataTransferItem =
|
||||||
@@ -400,7 +401,7 @@ export const getFileHandle = async (
|
|||||||
? event
|
? event
|
||||||
: (event as DragEvent).dataTransfer?.items?.[0];
|
: (event as DragEvent).dataTransfer?.items?.[0];
|
||||||
|
|
||||||
const handle: FileSystemHandle | null =
|
const handle: FileSystemFileHandle | null =
|
||||||
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
|
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
|
||||||
|
|
||||||
return handle;
|
return handle;
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import {
|
|||||||
supported as nativeFileSystemSupported,
|
supported as nativeFileSystemSupported,
|
||||||
} from "browser-fs-access";
|
} from "browser-fs-access";
|
||||||
|
|
||||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
import { MIME_TYPES } from "@excalidraw/common";
|
||||||
|
|
||||||
import { AbortError } from "../errors";
|
|
||||||
|
|
||||||
import { normalizeFile } from "./blob";
|
import { normalizeFile } from "./blob";
|
||||||
|
|
||||||
import type { FileSystemHandle } from "browser-fs-access";
|
|
||||||
|
|
||||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
|
|
||||||
const INPUT_CHANGE_INTERVAL_MS = 5000;
|
|
||||||
|
|
||||||
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
||||||
extensions?: FILE_EXTENSION[];
|
extensions?: FILE_EXTENSION[];
|
||||||
description: string;
|
description: string;
|
||||||
@@ -42,40 +36,6 @@ export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
|||||||
extensions,
|
extensions,
|
||||||
mimeTypes,
|
mimeTypes,
|
||||||
multiple: opts.multiple ?? false,
|
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)) {
|
if (Array.isArray(files)) {
|
||||||
@@ -95,8 +55,8 @@ export const fileSave = (
|
|||||||
extension: FILE_EXTENSION;
|
extension: FILE_EXTENSION;
|
||||||
mimeTypes?: string[];
|
mimeTypes?: string[];
|
||||||
description: string;
|
description: string;
|
||||||
/** existing FileSystemHandle */
|
/** existing FileSystemFileHandle */
|
||||||
fileHandle?: FileSystemHandle | null;
|
fileHandle?: FileSystemFileHandle | null;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
return _fileSave(
|
return _fileSave(
|
||||||
@@ -108,8 +68,8 @@ export const fileSave = (
|
|||||||
mimeTypes: opts.mimeTypes,
|
mimeTypes: opts.mimeTypes,
|
||||||
},
|
},
|
||||||
opts.fileHandle,
|
opts.fileHandle,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { nativeFileSystemSupported };
|
export { nativeFileSystemSupported };
|
||||||
export type { FileSystemHandle };
|
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import { canvasToBlob } from "./blob";
|
|||||||
import { fileSave } from "./filesystem";
|
import { fileSave } from "./filesystem";
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
|
|
||||||
import type { FileSystemHandle } from "./filesystem";
|
|
||||||
|
|
||||||
import type { ExportType } from "../scene/types";
|
import type { ExportType } from "../scene/types";
|
||||||
import type { AppState, BinaryFiles } from "../types";
|
import type { AppState, BinaryFiles } from "../types";
|
||||||
|
|
||||||
@@ -110,7 +108,7 @@ export const exportCanvas = async (
|
|||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
/** filename, if applicable */
|
/** filename, if applicable */
|
||||||
name?: string;
|
name?: string;
|
||||||
fileHandle?: FileSystemHandle | null;
|
fileHandle?: FileSystemFileHandle | null;
|
||||||
exportingFrame: ExcalidrawFrameLikeElement | null;
|
exportingFrame: ExcalidrawFrameLikeElement | null;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FILENAME,
|
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
getExportSource,
|
getExportSource,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
VERSIONS,
|
VERSIONS,
|
||||||
} from "@excalidraw/common";
|
} 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";
|
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||||
|
|
||||||
@@ -21,6 +22,12 @@ import type {
|
|||||||
ImportedLibraryData,
|
ImportedLibraryData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
export type JSONExportData = {
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips out files which are only referenced by deleted elements
|
* Strips out files which are only referenced by deleted elements
|
||||||
*/
|
*/
|
||||||
@@ -67,27 +74,29 @@ export const serializeAsJSON = (
|
|||||||
return JSON.stringify(data, null, 2);
|
return JSON.stringify(data, null, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveAsJSON = async (
|
export const saveAsJSON = async ({
|
||||||
elements: readonly ExcalidrawElement[],
|
data,
|
||||||
appState: AppState,
|
filename,
|
||||||
files: BinaryFiles,
|
fileHandle,
|
||||||
/** filename */
|
}: {
|
||||||
name: string = appState.name || DEFAULT_FILENAME,
|
data: MaybePromise<JSONExportData>;
|
||||||
) => {
|
filename: string;
|
||||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
fileHandle: AppState["fileHandle"];
|
||||||
const blob = new Blob([serialized], {
|
}) => {
|
||||||
type: MIME_TYPES.excalidraw,
|
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, {
|
const savedFileHandle = await fileSave(blob, {
|
||||||
name,
|
name: filename,
|
||||||
extension: "excalidraw",
|
extension: "excalidraw",
|
||||||
description: "Excalidraw file",
|
description: "Excalidraw file",
|
||||||
fileHandle: isImageFileHandle(appState.fileHandle)
|
fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
|
||||||
? null
|
|
||||||
: appState.fileHandle,
|
|
||||||
});
|
});
|
||||||
return { fileHandle };
|
return { fileHandle: savedFileHandle };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromJSON = async (
|
export const loadFromJSON = async (
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
|
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||||
import { exportCanvas, prepareElementsForExport } from ".";
|
import { exportCanvas, prepareElementsForExport } from ".";
|
||||||
|
|
||||||
import type { AppState, BinaryFiles } from "../types";
|
import type { AppState, BinaryFiles } from "../types";
|
||||||
|
|
||||||
export const resaveAsImageWithScene = async (
|
export const resaveAsImageWithScene = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
data: MaybePromise<{
|
||||||
appState: AppState,
|
elements: readonly ExcalidrawElement[];
|
||||||
files: BinaryFiles,
|
appState: AppState;
|
||||||
name: string,
|
files: BinaryFiles;
|
||||||
|
}>,
|
||||||
|
fileHandle: FileSystemFileHandle,
|
||||||
|
filename: string,
|
||||||
) => {
|
) => {
|
||||||
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
|
|
||||||
|
|
||||||
const fileHandleType = getFileHandleType(fileHandle);
|
const fileHandleType = getFileHandleType(fileHandle);
|
||||||
|
|
||||||
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
|
if (Math.random() < 1) {
|
||||||
|
throw new Error("OLALALALA");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImageFileHandleType(fileHandleType)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"fileHandle should exist and should be of type svg or png when resaving",
|
"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 = {
|
||||||
...appState,
|
...appState,
|
||||||
exportEmbedScene: true,
|
exportEmbedScene: true,
|
||||||
@@ -35,7 +48,7 @@ export const resaveAsImageWithScene = async (
|
|||||||
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
name,
|
name: filename,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+1
-1
@@ -52,7 +52,7 @@ declare module "png-chunks-extract" {
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Blob {
|
interface Blob {
|
||||||
handle?: import("browser-fs-acces").FileSystemHandle;
|
handle?: FileSystemFileHandle;
|
||||||
name?: string;
|
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 { 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 { InitializeApp } from "./components/InitializeApp";
|
||||||
import Footer from "./components/footer/FooterCenter";
|
import Footer from "./components/footer/FooterCenter";
|
||||||
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
|
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
|
||||||
import MainMenu from "./components/main-menu/MainMenu";
|
import MainMenu from "./components/main-menu/MainMenu";
|
||||||
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
||||||
import { defaultLang } from "./i18n";
|
import { defaultLang } from "./i18n";
|
||||||
|
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
|
||||||
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
||||||
import polyfill from "./polyfill";
|
import polyfill from "./polyfill";
|
||||||
|
|
||||||
@@ -16,16 +20,44 @@ import "./css/app.scss";
|
|||||||
import "./css/styles.scss";
|
import "./css/styles.scss";
|
||||||
import "./fonts/fonts.css";
|
import "./fonts/fonts.css";
|
||||||
|
|
||||||
import type { AppProps, ExcalidrawProps } from "./types";
|
import type {
|
||||||
|
AppProps,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
ExcalidrawProps,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
polyfill();
|
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 ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||||
const {
|
const {
|
||||||
|
onExport,
|
||||||
onChange,
|
onChange,
|
||||||
onIncrement,
|
onIncrement,
|
||||||
initialData,
|
initialData,
|
||||||
excalidrawAPI,
|
onExcalidrawAPI,
|
||||||
|
onMount,
|
||||||
|
onInitialize,
|
||||||
isCollaborating = false,
|
isCollaborating = false,
|
||||||
onPointerUpdate,
|
onPointerUpdate,
|
||||||
renderTopLeftUI,
|
renderTopLeftUI,
|
||||||
@@ -86,6 +118,15 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
UIOptions.canvasActions.toggleTheme = true;
|
UIOptions.canvasActions.toggleTheme = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
|
||||||
|
const handleExcalidrawAPI = useCallback(
|
||||||
|
(api: ExcalidrawImperativeAPI) => {
|
||||||
|
setExcalidrawAPI?.(api);
|
||||||
|
onExcalidrawAPI?.(api);
|
||||||
|
},
|
||||||
|
[onExcalidrawAPI, setExcalidrawAPI],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const importPolyfill = async () => {
|
const importPolyfill = async () => {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
@@ -115,10 +156,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
<EditorJotaiProvider store={editorJotaiStore}>
|
<EditorJotaiProvider store={editorJotaiStore}>
|
||||||
<InitializeApp langCode={langCode} theme={theme}>
|
<InitializeApp langCode={langCode} theme={theme}>
|
||||||
<App
|
<App
|
||||||
|
onExport={onExport}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onIncrement={onIncrement}
|
onIncrement={onIncrement}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
excalidrawAPI={excalidrawAPI}
|
onExcalidrawAPI={handleExcalidrawAPI}
|
||||||
|
onMount={onMount}
|
||||||
|
onInitialize={onInitialize}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={onPointerUpdate}
|
onPointerUpdate={onPointerUpdate}
|
||||||
renderTopLeftUI={renderTopLeftUI}
|
renderTopLeftUI={renderTopLeftUI}
|
||||||
@@ -285,7 +329,13 @@ export { Button } from "./components/Button";
|
|||||||
export { Footer };
|
export { Footer };
|
||||||
export { MainMenu };
|
export { MainMenu };
|
||||||
export { Ellipsify } from "./components/Ellipsify";
|
export { Ellipsify } from "./components/Ellipsify";
|
||||||
export { useEditorInterface, useStylesPanelMode } from "./components/App";
|
export {
|
||||||
|
useEditorInterface,
|
||||||
|
useStylesPanelMode,
|
||||||
|
useExcalidrawAPI,
|
||||||
|
ExcalidrawAPIContext,
|
||||||
|
} from "./components/App";
|
||||||
|
|
||||||
export { WelcomeScreen };
|
export { WelcomeScreen };
|
||||||
export { LiveCollaborationTrigger };
|
export { LiveCollaborationTrigger };
|
||||||
export { Stats } from "./components/Stats";
|
export { Stats } from "./components/Stats";
|
||||||
@@ -326,3 +376,37 @@ export {
|
|||||||
tryParseSpreadsheet,
|
tryParseSpreadsheet,
|
||||||
isSpreadsheetValidForChartType,
|
isSpreadsheetValidForChartType,
|
||||||
} from "./charts";
|
} 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": {
|
"errorDialog": {
|
||||||
"title": "Error"
|
"title": "Error"
|
||||||
},
|
},
|
||||||
|
"progressDialog": {
|
||||||
|
"title": "Saving",
|
||||||
|
"defaultMessage": "Preparing to save..."
|
||||||
|
},
|
||||||
"exportDialog": {
|
"exportDialog": {
|
||||||
"disk_title": "Save to disk",
|
"disk_title": "Save to disk",
|
||||||
"disk_details": "Export the scene data to a file from which you can import later.",
|
"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/math": "0.18.0",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
|
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
|
||||||
"@excalidraw/random-username": "1.1.0",
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"radix-ui": "1.4.3",
|
"browser-fs-access": "0.38.0",
|
||||||
"browser-fs-access": "0.29.1",
|
|
||||||
"canvas-roundrect-polyfill": "0.0.1",
|
"canvas-roundrect-polyfill": "0.0.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
@@ -112,6 +111,7 @@
|
|||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
"points-on-curve": "1.0.1",
|
"points-on-curve": "1.0.1",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.17",
|
||||||
|
"radix-ui": "1.4.3",
|
||||||
"roughjs": "4.6.4",
|
"roughjs": "4.6.4",
|
||||||
"sass": "1.51.0",
|
"sass": "1.51.0",
|
||||||
"tunnel-rat": "0.1.2"
|
"tunnel-rat": "0.1.2"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { resolvablePromise } from "@excalidraw/common";
|
|||||||
import { Excalidraw, CaptureUpdateAction } from "../../index";
|
import { Excalidraw, CaptureUpdateAction } from "../../index";
|
||||||
import { API } from "../helpers/api";
|
import { API } from "../helpers/api";
|
||||||
import { Pointer } from "../helpers/ui";
|
import { Pointer } from "../helpers/ui";
|
||||||
import { render } from "../test-utils";
|
import { render, unmountComponent } from "../test-utils";
|
||||||
|
|
||||||
import type { ExcalidrawImperativeAPI } from "../../types";
|
import type { ExcalidrawImperativeAPI } from "../../types";
|
||||||
|
|
||||||
@@ -21,12 +21,91 @@ describe("event callbacks", () => {
|
|||||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||||
await render(
|
await render(
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
excalidrawAPI = await excalidrawAPIPromise;
|
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 () => {
|
it("should trigger onChange on render", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe("setActiveTool()", () => {
|
|||||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||||
await render(
|
await render(
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
excalidrawAPI = await excalidrawAPIPromise;
|
excalidrawAPI = await excalidrawAPIPromise;
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import type { Spreadsheet } from "./charts";
|
|||||||
import type { ClipboardData } from "./clipboard";
|
import type { ClipboardData } from "./clipboard";
|
||||||
import type App from "./components/App";
|
import type App from "./components/App";
|
||||||
import type Library from "./data/library";
|
import type Library from "./data/library";
|
||||||
import type { FileSystemHandle } from "./data/filesystem";
|
|
||||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import type { SnapLine } from "./snapping";
|
import type { SnapLine } from "./snapping";
|
||||||
import type { ImportedDataState } from "./data/types";
|
import type { ImportedDataState } from "./data/types";
|
||||||
@@ -408,7 +407,11 @@ export interface AppState {
|
|||||||
previousSelectedElementIds: { [id: string]: true };
|
previousSelectedElementIds: { [id: string]: true };
|
||||||
selectedElementsAreBeingDragged: boolean;
|
selectedElementsAreBeingDragged: boolean;
|
||||||
shouldCacheIgnoreZoom: boolean;
|
shouldCacheIgnoreZoom: boolean;
|
||||||
toast: { message: string; closable?: boolean; duration?: number } | null;
|
toast: {
|
||||||
|
message: React.ReactNode;
|
||||||
|
closable?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
} | null;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
/** grid cell px size */
|
/** grid cell px size */
|
||||||
@@ -427,7 +430,7 @@ export interface AppState {
|
|||||||
offsetTop: number;
|
offsetTop: number;
|
||||||
offsetLeft: number;
|
offsetLeft: number;
|
||||||
|
|
||||||
fileHandle: FileSystemHandle | null;
|
fileHandle: FileSystemFileHandle | null;
|
||||||
collaborators: Map<SocketId, Collaborator>;
|
collaborators: Map<SocketId, Collaborator>;
|
||||||
stats: {
|
stats: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -546,17 +549,39 @@ export type OnUserFollowedPayload = {
|
|||||||
action: "FOLLOW" | "UNFOLLOW";
|
action: "FOLLOW" | "UNFOLLOW";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OnExportProgress = {
|
||||||
|
type: "progress";
|
||||||
|
message?: React.ReactNode;
|
||||||
|
/** 0-1 range */
|
||||||
|
progress?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawProps {
|
export interface ExcalidrawProps {
|
||||||
onChange?: (
|
onChange?: (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
|
/**
|
||||||
|
* note: only subscribes if the props.onIncrement is defined on initial render
|
||||||
|
*/
|
||||||
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
|
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
|
||||||
initialData?:
|
initialData?:
|
||||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||||
| 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;
|
isCollaborating?: boolean;
|
||||||
onPointerUpdate?: (payload: {
|
onPointerUpdate?: (payload: {
|
||||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||||
@@ -641,6 +666,32 @@ export interface ExcalidrawProps {
|
|||||||
aiEnabled?: boolean;
|
aiEnabled?: boolean;
|
||||||
showDeprecatedFonts?: boolean;
|
showDeprecatedFonts?: boolean;
|
||||||
renderScrollbars?: 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 = {
|
export type SceneData = {
|
||||||
@@ -719,6 +770,7 @@ export type AppProps = Merge<
|
|||||||
export type AppClassProperties = {
|
export type AppClassProperties = {
|
||||||
props: AppProps;
|
props: AppProps;
|
||||||
state: AppState;
|
state: AppState;
|
||||||
|
api: App["api"];
|
||||||
sessionExportThemeOverride: App["sessionExportThemeOverride"];
|
sessionExportThemeOverride: App["sessionExportThemeOverride"];
|
||||||
interactiveCanvas: HTMLCanvasElement | null;
|
interactiveCanvas: HTMLCanvasElement | null;
|
||||||
/** static canvas */
|
/** static canvas */
|
||||||
@@ -764,9 +816,13 @@ export type AppClassProperties = {
|
|||||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||||
updateEditorAtom: App["updateEditorAtom"];
|
updateEditorAtom: App["updateEditorAtom"];
|
||||||
onPointerDownEmitter: App["onPointerDownEmitter"];
|
onPointerDownEmitter: App["onPointerDownEmitter"];
|
||||||
|
onEvent: App["onEvent"];
|
||||||
|
onStateChange: App["onStateChange"];
|
||||||
|
|
||||||
lastPointerMoveCoords: App["lastPointerMoveCoords"];
|
lastPointerMoveCoords: App["lastPointerMoveCoords"];
|
||||||
bindModeHandler: App["bindModeHandler"];
|
bindModeHandler: App["bindModeHandler"];
|
||||||
|
|
||||||
|
setAppState: App["setAppState"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
@@ -839,6 +895,20 @@ export type PointerDownState = Readonly<{
|
|||||||
|
|
||||||
export type UnsubscribeCallback = () => void;
|
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 {
|
export interface ExcalidrawImperativeAPI {
|
||||||
updateScene: InstanceType<typeof App>["updateScene"];
|
updateScene: InstanceType<typeof App>["updateScene"];
|
||||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||||||
@@ -905,6 +975,8 @@ export interface ExcalidrawImperativeAPI {
|
|||||||
onUserFollow: (
|
onUserFollow: (
|
||||||
callback: (payload: OnUserFollowedPayload) => void,
|
callback: (payload: OnUserFollowedPayload) => void,
|
||||||
) => UnsubscribeCallback;
|
) => UnsubscribeCallback;
|
||||||
|
onStateChange: InstanceType<typeof App>["onStateChange"];
|
||||||
|
onEvent: InstanceType<typeof App>["onEvent"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FrameNameBounds = {
|
export type FrameNameBounds = {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/laser-pointer": "1.3.1",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
"browser-fs-access": "0.29.1",
|
"browser-fs-access": "0.38.0",
|
||||||
"pako": "2.0.3",
|
"pako": "2.0.3",
|
||||||
"perfect-freehand": "1.2.0",
|
"perfect-freehand": "1.2.0",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
|
|||||||
@@ -4603,10 +4603,10 @@ braces@^3.0.3, braces@~3.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range "^7.1.1"
|
fill-range "^7.1.1"
|
||||||
|
|
||||||
browser-fs-access@0.29.1:
|
browser-fs-access@0.38.0:
|
||||||
version "0.29.1"
|
version "0.38.0"
|
||||||
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.29.1.tgz#8a9794c73cf86b9aec74201829999c597128379c"
|
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.38.0.tgz#9024c5bf3d962287a08d14beebb86cb819cbb838"
|
||||||
integrity sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==
|
integrity sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw==
|
||||||
|
|
||||||
browserslist@^4.20.3, browserslist@^4.24.0, browserslist@^4.24.4:
|
browserslist@^4.20.3, browserslist@^4.24.0, browserslist@^4.24.4:
|
||||||
version "4.24.4"
|
version "4.24.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user