mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-31 14:17:06 +02:00

Tests added Fix binding Remove unneeded params Unfinished simple arrow avoidance Fix newly created jumping arrow when gets outside Do not apply the jumping logic to elbow arrows for new elements Existing arrows now jump out Type updates to support fixed binding for simple arrows Fix crash for elbow arrws in mutateElement() Refactored simple arrow creation Updating tests No confirm threshold when inside biding range Fix multi-point arrow grid off Make elbow arrows respect grids Unbind arrow if bound and moved at shaft of arrow key Fix binding test Fix drag unbind when the bound element is in the selection Do not move mid point for simple arrows bound on both ends Add test for mobing mid points for simple arrows when bound on the same element on both ends Fix linear editor bug when both midpoint and endpoint is moved Fix all point multipoint arrow highlight and binding Arrow dragging gets a little drag to avoid accidental unbinding Fixed point binding for simple arrows when the arrow doesn't point to the element Fix binding disabled use-case triggering arrow editor Timed binding mode change for simple arrows Apply fixes Remove code to unbind on drag Update simple arrow fixed point when arrow is dragged or moved by arrow keys Binding highlight fixes Change bind mode timeout logic Fix tests Add Alt bindMode switch No dragging of arrows when bound, similar to elbow Fix timeout not taking effect immediately Bumop z-index for arrows when dragged Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Only transparent bindables allow binding fallthrough Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix lint Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix point click array creation interaction with fixed point binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Restrict new behavior to arrows only Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Allow binding inside images Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix already existing fixed binding retention Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Refactor and implement fixed point binding for unfilled elements Restore drag Removed point binding Binding code refactor Added centered focus point Binding & focus point debug Add invariants to check binding integrity in elements Binding fixes Small refactors Completely rewritten binding Include point updates after binding update Fix point updates when endpoint dragged and opposite endpoint orbits centered focus point only for new arrows Make z-index arrow reorder on bind Turn off inside binding mode after leaving a shape Remove invariants from debug feat: expose `applyTo` options, don't commit empty text element (#9744) * Expose applyTo options, skip re-draw for empty text * Don't commit empty text elements test: added test file for distribute (#9754) z-index update Bind mode on precise binding Fix binding to inside element Fix initial arrow not following cursor (white dot) Fix elbow arrow Fix z-index so it works on hover Fix fixed angle orbiting Move point click arrow creation over to common strategy Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Add binding strategy for drag arrow creation Fix elbow arrow Fix point handles Snap to center Fix transparent shape binding Internal arrow creation fix Fix point binding Fix selection bug Fix new arrow focus point Images now always bind inside Flashing arrow creation on binding band Add watchState debug method to window.h Fix debug canvas crash Remove non-needed bind mode Fix restore No keyboard movement when bound Add actionFinalize when arrow in edit mode Add drag to the Stats panel when bound arrow is moved Further simplify curve tracking Add typing to action register() Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix point at finalize Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix type errors Signed-off-by: Mark Tolmacs <mark@lazycat.hu> New arrow binding rules Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix cyclical dep Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix jiggly arrows Fix jiggly arrow x2 Long inside-other binding Click-click binding Fix arrows Performance [PERF] Replace in-place Jacobian derivation with analytical version Different approach to inside binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fixes Fix inconsistent arrow start jump out Change how images are bound to on new arrow creation Lower timeout Small insurance fix Fix curve test Signed-off-by: Mark Tolmacs <mark@lazycat.hu> No center focus point 90% inside center binding Fixing tests fix: Elbow arrow fixes fix: More arrow fixes Do not trigger arrow binding for linear elements fix: Linear elements fix: Refactor actionFinalize for linear Binding tests updated fix: Jump when cursor not moved fix: history tests Fix history snapshot Fix undo issue fix(eraser): Remove binding from the other element fix(tests): Update tests chore: Attempt filtering new set state Fix excessive history recording Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
1160 lines
35 KiB
TypeScript
1160 lines
35 KiB
TypeScript
import {
|
|
Excalidraw,
|
|
LiveCollaborationTrigger,
|
|
TTDDialogTrigger,
|
|
CaptureUpdateAction,
|
|
reconcileElements,
|
|
} from "@excalidraw/excalidraw";
|
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
|
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
|
import {
|
|
CommandPalette,
|
|
DEFAULT_CATEGORIES,
|
|
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
|
|
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
|
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
|
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
|
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
|
|
import Trans from "@excalidraw/excalidraw/components/Trans";
|
|
import {
|
|
APP_NAME,
|
|
EVENT,
|
|
THEME,
|
|
VERSION_TIMEOUT,
|
|
debounce,
|
|
getVersion,
|
|
getFrame,
|
|
isTestEnv,
|
|
preventUnload,
|
|
resolvablePromise,
|
|
isRunningInIframe,
|
|
isDevEnv,
|
|
} from "@excalidraw/common";
|
|
import polyfill from "@excalidraw/excalidraw/polyfill";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
|
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
|
import { t } from "@excalidraw/excalidraw/i18n";
|
|
|
|
import {
|
|
GithubIcon,
|
|
XBrandIcon,
|
|
DiscordIcon,
|
|
ExcalLogo,
|
|
usersIcon,
|
|
exportToPlus,
|
|
share,
|
|
youtubeIcon,
|
|
} from "@excalidraw/excalidraw/components/icons";
|
|
import { isElementLink } from "@excalidraw/element";
|
|
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
|
import { newElementWith } from "@excalidraw/element";
|
|
import { isInitializedImageElement } from "@excalidraw/element";
|
|
import clsx from "clsx";
|
|
import {
|
|
parseLibraryTokensFromUrl,
|
|
useHandleLibrary,
|
|
} from "@excalidraw/excalidraw/data/library";
|
|
|
|
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
|
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
|
|
import type {
|
|
FileId,
|
|
NonDeletedExcalidrawElement,
|
|
OrderedExcalidrawElement,
|
|
} from "@excalidraw/element/types";
|
|
import type {
|
|
AppState,
|
|
ExcalidrawImperativeAPI,
|
|
BinaryFiles,
|
|
ExcalidrawInitialDataState,
|
|
UIAppState,
|
|
} from "@excalidraw/excalidraw/types";
|
|
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
|
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
|
|
|
import CustomStats from "./CustomStats";
|
|
import {
|
|
Provider,
|
|
useAtom,
|
|
useAtomValue,
|
|
useAtomWithInitialValue,
|
|
appJotaiStore,
|
|
} from "./app-jotai";
|
|
import {
|
|
FIREBASE_STORAGE_PREFIXES,
|
|
isExcalidrawPlusSignedUser,
|
|
STORAGE_KEYS,
|
|
SYNC_BROWSER_TABS_TIMEOUT,
|
|
} from "./app_constants";
|
|
import Collab, {
|
|
collabAPIAtom,
|
|
isCollaboratingAtom,
|
|
isOfflineAtom,
|
|
} from "./collab/Collab";
|
|
import { AppFooter } from "./components/AppFooter";
|
|
import { AppMainMenu } from "./components/AppMainMenu";
|
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
|
import {
|
|
ExportToExcalidrawPlus,
|
|
exportToExcalidrawPlus,
|
|
} from "./components/ExportToExcalidrawPlus";
|
|
import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
|
|
|
import {
|
|
exportToBackend,
|
|
getCollaborationLinkData,
|
|
isCollaborationLink,
|
|
loadScene,
|
|
} from "./data";
|
|
|
|
import { updateStaleImageStatuses } from "./data/FileManager";
|
|
import {
|
|
importFromLocalStorage,
|
|
importUsernameFromLocalStorage,
|
|
} from "./data/localStorage";
|
|
|
|
import { loadFilesFromFirebase } from "./data/firebase";
|
|
import {
|
|
LibraryIndexedDBAdapter,
|
|
LibraryLocalStorageMigrationAdapter,
|
|
LocalData,
|
|
} from "./data/LocalData";
|
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
|
import { useHandleAppTheme } from "./useHandleAppTheme";
|
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
|
import { useAppLangCode } from "./app-language/language-state";
|
|
import DebugCanvas, {
|
|
debugRenderer,
|
|
isVisualDebuggerEnabled,
|
|
loadSavedDebugState,
|
|
} from "./components/DebugCanvas";
|
|
import { AIComponents } from "./components/AI";
|
|
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
|
|
|
import "./index.scss";
|
|
|
|
import type { CollabAPI } from "./collab/Collab";
|
|
|
|
polyfill();
|
|
|
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
|
|
|
declare global {
|
|
interface BeforeInstallPromptEventChoiceResult {
|
|
outcome: "accepted" | "dismissed";
|
|
}
|
|
|
|
interface BeforeInstallPromptEvent extends Event {
|
|
prompt(): Promise<void>;
|
|
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
|
|
}
|
|
|
|
interface WindowEventMap {
|
|
beforeinstallprompt: BeforeInstallPromptEvent;
|
|
}
|
|
}
|
|
|
|
let pwaEvent: BeforeInstallPromptEvent | null = null;
|
|
|
|
// Adding a listener outside of the component as it may (?) need to be
|
|
// subscribed early to catch the event.
|
|
//
|
|
// Also note that it will fire only if certain heuristics are met (user has
|
|
// used the app for some time, etc.)
|
|
window.addEventListener(
|
|
"beforeinstallprompt",
|
|
(event: BeforeInstallPromptEvent) => {
|
|
// prevent Chrome <= 67 from automatically showing the prompt
|
|
event.preventDefault();
|
|
// cache for later use
|
|
pwaEvent = event;
|
|
},
|
|
);
|
|
|
|
let isSelfEmbedding = false;
|
|
|
|
if (window.self !== window.top) {
|
|
try {
|
|
const parentUrl = new URL(document.referrer);
|
|
const currentUrl = new URL(window.location.href);
|
|
if (parentUrl.origin === currentUrl.origin) {
|
|
isSelfEmbedding = true;
|
|
}
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const shareableLinkConfirmDialog = {
|
|
title: t("overwriteConfirm.modal.shareableLink.title"),
|
|
description: (
|
|
<Trans
|
|
i18nKey="overwriteConfirm.modal.shareableLink.description"
|
|
bold={(text) => <strong>{text}</strong>}
|
|
br={() => <br />}
|
|
/>
|
|
),
|
|
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
|
|
color: "danger",
|
|
} as const;
|
|
|
|
const initializeScene = async (opts: {
|
|
collabAPI: CollabAPI | null;
|
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
|
}): Promise<
|
|
{ scene: ExcalidrawInitialDataState | null } & (
|
|
| { isExternalScene: true; id: string; key: string }
|
|
| { isExternalScene: false; id?: null; key?: null }
|
|
)
|
|
> => {
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
const id = searchParams.get("id");
|
|
const jsonBackendMatch = window.location.hash.match(
|
|
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
|
|
);
|
|
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
|
|
|
const localDataState = importFromLocalStorage();
|
|
|
|
let scene: RestoredDataState & {
|
|
scrollToContent?: boolean;
|
|
} = await loadScene(null, null, localDataState);
|
|
|
|
let roomLinkData = getCollaborationLinkData(window.location.href);
|
|
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
|
if (isExternalScene) {
|
|
if (
|
|
// don't prompt if scene is empty
|
|
!scene.elements.length ||
|
|
// don't prompt for collab scenes because we don't override local storage
|
|
roomLinkData ||
|
|
// otherwise, prompt whether user wants to override current scene
|
|
(await openConfirmModal(shareableLinkConfirmDialog))
|
|
) {
|
|
if (jsonBackendMatch) {
|
|
scene = await loadScene(
|
|
jsonBackendMatch[1],
|
|
jsonBackendMatch[2],
|
|
localDataState,
|
|
);
|
|
}
|
|
scene.scrollToContent = true;
|
|
if (!roomLinkData) {
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
|
}
|
|
} else {
|
|
// https://github.com/excalidraw/excalidraw/issues/1919
|
|
if (document.hidden) {
|
|
return new Promise((resolve, reject) => {
|
|
window.addEventListener(
|
|
"focus",
|
|
() => initializeScene(opts).then(resolve).catch(reject),
|
|
{
|
|
once: true,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
roomLinkData = null;
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
|
}
|
|
} else if (externalUrlMatch) {
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
|
|
|
const url = externalUrlMatch[1];
|
|
try {
|
|
const request = await fetch(window.decodeURIComponent(url));
|
|
const data = await loadFromBlob(await request.blob(), null, null);
|
|
if (
|
|
!scene.elements.length ||
|
|
(await openConfirmModal(shareableLinkConfirmDialog))
|
|
) {
|
|
return { scene: data, isExternalScene };
|
|
}
|
|
} catch (error: any) {
|
|
return {
|
|
scene: {
|
|
appState: {
|
|
errorMessage: t("alerts.invalidSceneUrl"),
|
|
},
|
|
},
|
|
isExternalScene,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (roomLinkData && opts.collabAPI) {
|
|
const { excalidrawAPI } = opts;
|
|
|
|
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
|
|
|
return {
|
|
// when collaborating, the state may have already been updated at this
|
|
// point (we may have received updates from other clients), so reconcile
|
|
// elements and appState with existing state
|
|
scene: {
|
|
...scene,
|
|
appState: {
|
|
...restoreAppState(
|
|
{
|
|
...scene?.appState,
|
|
theme: localDataState?.appState?.theme || scene?.appState?.theme,
|
|
},
|
|
excalidrawAPI.getAppState(),
|
|
),
|
|
// necessary if we're invoking from a hashchange handler which doesn't
|
|
// go through App.initializeScene() that resets this flag
|
|
isLoading: false,
|
|
},
|
|
elements: reconcileElements(
|
|
scene?.elements || [],
|
|
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
|
|
excalidrawAPI.getAppState(),
|
|
),
|
|
},
|
|
isExternalScene: true,
|
|
id: roomLinkData.roomId,
|
|
key: roomLinkData.roomKey,
|
|
};
|
|
} else if (scene) {
|
|
return isExternalScene && jsonBackendMatch
|
|
? {
|
|
scene,
|
|
isExternalScene,
|
|
id: jsonBackendMatch[1],
|
|
key: jsonBackendMatch[2],
|
|
}
|
|
: { scene, isExternalScene: false };
|
|
}
|
|
return { scene: null, isExternalScene: false };
|
|
};
|
|
|
|
const ExcalidrawWrapper = () => {
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
const isCollabDisabled = isRunningInIframe();
|
|
|
|
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
|
|
|
|
const [langCode, setLangCode] = useAppLangCode();
|
|
|
|
// initial state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const initialStatePromiseRef = useRef<{
|
|
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
|
}>({ promise: null! });
|
|
if (!initialStatePromiseRef.current.promise) {
|
|
initialStatePromiseRef.current.promise =
|
|
resolvablePromise<ExcalidrawInitialDataState | null>();
|
|
}
|
|
|
|
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
trackEvent("load", "frame", getFrame());
|
|
// Delayed so that the app has a time to load the latest SW
|
|
setTimeout(() => {
|
|
trackEvent("load", "version", getVersion());
|
|
}, VERSION_TIMEOUT);
|
|
}, []);
|
|
|
|
const [excalidrawAPI, excalidrawRefCallback] =
|
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
|
|
|
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
|
const [collabAPI] = useAtom(collabAPIAtom);
|
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
|
return isCollaborationLink(window.location.href);
|
|
});
|
|
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
|
|
|
useHandleLibrary({
|
|
excalidrawAPI,
|
|
adapter: LibraryIndexedDBAdapter,
|
|
// TODO maybe remove this in several months (shipped: 24-03-11)
|
|
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
|
});
|
|
|
|
const [, forceRefresh] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (isDevEnv()) {
|
|
const debugState = loadSavedDebugState();
|
|
|
|
if (debugState.enabled && !window.visualDebug) {
|
|
window.visualDebug = {
|
|
data: [],
|
|
};
|
|
} else {
|
|
delete window.visualDebug;
|
|
}
|
|
forceRefresh((prev) => !prev);
|
|
}
|
|
}, [excalidrawAPI]);
|
|
|
|
useEffect(() => {
|
|
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
|
return;
|
|
}
|
|
|
|
const loadImages = (
|
|
data: ResolutionType<typeof initializeScene>,
|
|
isInitialLoad = false,
|
|
) => {
|
|
if (!data.scene) {
|
|
return;
|
|
}
|
|
if (collabAPI?.isCollaborating()) {
|
|
if (data.scene.elements) {
|
|
collabAPI
|
|
.fetchImageFilesFromFirebase({
|
|
elements: data.scene.elements,
|
|
forceFetchFiles: true,
|
|
})
|
|
.then(({ loadedFiles, erroredFiles }) => {
|
|
excalidrawAPI.addFiles(loadedFiles);
|
|
updateStaleImageStatuses({
|
|
excalidrawAPI,
|
|
erroredFiles,
|
|
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
});
|
|
});
|
|
}
|
|
} else {
|
|
const fileIds =
|
|
data.scene.elements?.reduce((acc, element) => {
|
|
if (isInitializedImageElement(element)) {
|
|
return acc.concat(element.fileId);
|
|
}
|
|
return acc;
|
|
}, [] as FileId[]) || [];
|
|
|
|
if (data.isExternalScene) {
|
|
loadFilesFromFirebase(
|
|
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
|
data.key,
|
|
fileIds,
|
|
).then(({ loadedFiles, erroredFiles }) => {
|
|
excalidrawAPI.addFiles(loadedFiles);
|
|
updateStaleImageStatuses({
|
|
excalidrawAPI,
|
|
erroredFiles,
|
|
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
});
|
|
});
|
|
} else if (isInitialLoad) {
|
|
if (fileIds.length) {
|
|
LocalData.fileStorage
|
|
.getFiles(fileIds)
|
|
.then(({ loadedFiles, erroredFiles }) => {
|
|
if (loadedFiles.length) {
|
|
excalidrawAPI.addFiles(loadedFiles);
|
|
}
|
|
updateStaleImageStatuses({
|
|
excalidrawAPI,
|
|
erroredFiles,
|
|
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
});
|
|
});
|
|
}
|
|
// on fresh load, clear unused files from IDB (from previous
|
|
// session)
|
|
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
|
}
|
|
}
|
|
};
|
|
|
|
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
|
loadImages(data, /* isInitialLoad */ true);
|
|
initialStatePromiseRef.current.promise.resolve(data.scene);
|
|
});
|
|
|
|
const onHashChange = async (event: HashChangeEvent) => {
|
|
event.preventDefault();
|
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
|
if (!libraryUrlTokens) {
|
|
if (
|
|
collabAPI?.isCollaborating() &&
|
|
!isCollaborationLink(window.location.href)
|
|
) {
|
|
collabAPI.stopCollaboration(false);
|
|
}
|
|
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
|
|
|
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
|
|
loadImages(data);
|
|
if (data.scene) {
|
|
excalidrawAPI.updateScene({
|
|
...data.scene,
|
|
...restore(data.scene, null, null, { repairBindings: true }),
|
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const syncData = debounce(() => {
|
|
if (isTestEnv()) {
|
|
return;
|
|
}
|
|
if (
|
|
!document.hidden &&
|
|
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
|
) {
|
|
// don't sync if local state is newer or identical to browser state
|
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
|
const localDataState = importFromLocalStorage();
|
|
const username = importUsernameFromLocalStorage();
|
|
setLangCode(getPreferredLanguage());
|
|
excalidrawAPI.updateScene({
|
|
...localDataState,
|
|
captureUpdate: CaptureUpdateAction.NEVER,
|
|
});
|
|
LibraryIndexedDBAdapter.load().then((data) => {
|
|
if (data) {
|
|
excalidrawAPI.updateLibrary({
|
|
libraryItems: data.libraryItems,
|
|
});
|
|
}
|
|
});
|
|
collabAPI?.setUsername(username || "");
|
|
}
|
|
|
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
|
const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
|
|
const currFiles = excalidrawAPI.getFiles();
|
|
const fileIds =
|
|
elements?.reduce((acc, element) => {
|
|
if (
|
|
isInitializedImageElement(element) &&
|
|
// only load and update images that aren't already loaded
|
|
!currFiles[element.fileId]
|
|
) {
|
|
return acc.concat(element.fileId);
|
|
}
|
|
return acc;
|
|
}, [] as FileId[]) || [];
|
|
if (fileIds.length) {
|
|
LocalData.fileStorage
|
|
.getFiles(fileIds)
|
|
.then(({ loadedFiles, erroredFiles }) => {
|
|
if (loadedFiles.length) {
|
|
excalidrawAPI.addFiles(loadedFiles);
|
|
}
|
|
updateStaleImageStatuses({
|
|
excalidrawAPI,
|
|
erroredFiles,
|
|
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, SYNC_BROWSER_TABS_TIMEOUT);
|
|
|
|
const onUnload = () => {
|
|
LocalData.flushSave();
|
|
};
|
|
|
|
const visibilityChange = (event: FocusEvent | Event) => {
|
|
if (event.type === EVENT.BLUR || document.hidden) {
|
|
LocalData.flushSave();
|
|
}
|
|
if (
|
|
event.type === EVENT.VISIBILITY_CHANGE ||
|
|
event.type === EVENT.FOCUS
|
|
) {
|
|
syncData();
|
|
}
|
|
};
|
|
|
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
|
window.addEventListener(EVENT.UNLOAD, onUnload, false);
|
|
window.addEventListener(EVENT.BLUR, visibilityChange, false);
|
|
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
|
|
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
|
|
return () => {
|
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
|
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
|
|
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
|
|
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
|
|
document.removeEventListener(
|
|
EVENT.VISIBILITY_CHANGE,
|
|
visibilityChange,
|
|
false,
|
|
);
|
|
};
|
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
|
|
|
useEffect(() => {
|
|
const unloadHandler = (event: BeforeUnloadEvent) => {
|
|
LocalData.flushSave();
|
|
|
|
if (
|
|
excalidrawAPI &&
|
|
LocalData.fileStorage.shouldPreventUnload(
|
|
excalidrawAPI.getSceneElements(),
|
|
)
|
|
) {
|
|
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
|
|
preventUnload(event);
|
|
} else {
|
|
console.warn(
|
|
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
|
|
);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
|
return () => {
|
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
|
};
|
|
}, [excalidrawAPI]);
|
|
|
|
const onChange = (
|
|
elements: readonly OrderedExcalidrawElement[],
|
|
appState: AppState,
|
|
files: BinaryFiles,
|
|
) => {
|
|
if (collabAPI?.isCollaborating()) {
|
|
collabAPI.syncElements(elements);
|
|
}
|
|
|
|
// this check is redundant, but since this is a hot path, it's best
|
|
// not to evaludate the nested expression every time
|
|
if (!LocalData.isSavePaused()) {
|
|
LocalData.save(elements, appState, files, () => {
|
|
if (excalidrawAPI) {
|
|
let didChange = false;
|
|
|
|
const elements = excalidrawAPI
|
|
.getSceneElementsIncludingDeleted()
|
|
.map((element) => {
|
|
if (
|
|
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
|
) {
|
|
const newElement = newElementWith(element, { status: "saved" });
|
|
if (newElement !== element) {
|
|
didChange = true;
|
|
}
|
|
return newElement;
|
|
}
|
|
return element;
|
|
});
|
|
|
|
if (didChange) {
|
|
excalidrawAPI.updateScene({
|
|
elements,
|
|
captureUpdate: CaptureUpdateAction.NEVER,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Render the debug scene if the debug canvas is available
|
|
if (debugCanvasRef.current && excalidrawAPI) {
|
|
debugRenderer(
|
|
debugCanvasRef.current,
|
|
appState,
|
|
elements,
|
|
window.devicePixelRatio,
|
|
);
|
|
}
|
|
};
|
|
|
|
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
|
null,
|
|
);
|
|
|
|
const onExportToBackend = async (
|
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
|
appState: Partial<AppState>,
|
|
files: BinaryFiles,
|
|
) => {
|
|
if (exportedElements.length === 0) {
|
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
|
}
|
|
try {
|
|
const { url, errorMessage } = await exportToBackend(
|
|
exportedElements,
|
|
{
|
|
...appState,
|
|
viewBackgroundColor: appState.exportBackground
|
|
? appState.viewBackgroundColor
|
|
: getDefaultAppState().viewBackgroundColor,
|
|
},
|
|
files,
|
|
);
|
|
|
|
if (errorMessage) {
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
if (url) {
|
|
setLatestShareableLink(url);
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name !== "AbortError") {
|
|
const { width, height } = appState;
|
|
console.error(error, {
|
|
width,
|
|
height,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
});
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
};
|
|
|
|
const renderCustomStats = (
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
|
appState: UIAppState,
|
|
) => {
|
|
return (
|
|
<CustomStats
|
|
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
|
appState={appState}
|
|
elements={elements}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const isOffline = useAtomValue(isOfflineAtom);
|
|
|
|
const onCollabDialogOpen = useCallback(
|
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
|
[setShareDialogState],
|
|
);
|
|
|
|
// browsers generally prevent infinite self-embedding, there are
|
|
// cases where it still happens, and while we disallow self-embedding
|
|
// by not whitelisting our own origin, this serves as an additional guard
|
|
if (isSelfEmbedding) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
textAlign: "center",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
<h1>I'm not a pretzel!</h1>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ExcalidrawPlusCommand = {
|
|
label: "Excalidraw+",
|
|
category: DEFAULT_CATEGORIES.links,
|
|
predicate: true,
|
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
|
keywords: ["plus", "cloud", "server"],
|
|
perform: () => {
|
|
window.open(
|
|
`${
|
|
import.meta.env.VITE_APP_PLUS_LP
|
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
|
"_blank",
|
|
);
|
|
},
|
|
};
|
|
const ExcalidrawPlusAppCommand = {
|
|
label: "Sign up",
|
|
category: DEFAULT_CATEGORIES.links,
|
|
predicate: true,
|
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
|
keywords: [
|
|
"excalidraw",
|
|
"plus",
|
|
"cloud",
|
|
"server",
|
|
"signin",
|
|
"login",
|
|
"signup",
|
|
],
|
|
perform: () => {
|
|
window.open(
|
|
`${
|
|
import.meta.env.VITE_APP_PLUS_APP
|
|
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
|
"_blank",
|
|
);
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{ height: "100%" }}
|
|
className={clsx("excalidraw-app", {
|
|
"is-collaborating": isCollaborating,
|
|
})}
|
|
>
|
|
<Excalidraw
|
|
excalidrawAPI={excalidrawRefCallback}
|
|
onChange={onChange}
|
|
initialData={initialStatePromiseRef.current.promise}
|
|
isCollaborating={isCollaborating}
|
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
|
UIOptions={{
|
|
canvasActions: {
|
|
toggleTheme: true,
|
|
export: {
|
|
onExportToBackend,
|
|
renderCustomUI: excalidrawAPI
|
|
? (elements, appState, files) => {
|
|
return (
|
|
<ExportToExcalidrawPlus
|
|
elements={elements}
|
|
appState={appState}
|
|
files={files}
|
|
name={excalidrawAPI.getName()}
|
|
onError={(error) => {
|
|
excalidrawAPI?.updateScene({
|
|
appState: {
|
|
errorMessage: error.message,
|
|
},
|
|
});
|
|
}}
|
|
onSuccess={() => {
|
|
excalidrawAPI.updateScene({
|
|
appState: { openDialog: null },
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
: undefined,
|
|
},
|
|
},
|
|
}}
|
|
langCode={langCode}
|
|
renderCustomStats={renderCustomStats}
|
|
detectScroll={false}
|
|
handleKeyboardGlobally={true}
|
|
autoFocus={true}
|
|
theme={editorTheme}
|
|
renderTopRightUI={(isMobile) => {
|
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="top-right-ui">
|
|
{collabError.message && <CollabError collabError={collabError} />}
|
|
<LiveCollaborationTrigger
|
|
isCollaborating={isCollaborating}
|
|
onSelect={() =>
|
|
setShareDialogState({ isOpen: true, type: "share" })
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}}
|
|
onLinkOpen={(element, event) => {
|
|
if (element.link && isElementLink(element.link)) {
|
|
event.preventDefault();
|
|
excalidrawAPI?.scrollToContent(element.link, { animate: true });
|
|
}
|
|
}}
|
|
>
|
|
<AppMainMenu
|
|
onCollabDialogOpen={onCollabDialogOpen}
|
|
isCollaborating={isCollaborating}
|
|
isCollabEnabled={!isCollabDisabled}
|
|
theme={appTheme}
|
|
setTheme={(theme) => setAppTheme(theme)}
|
|
refresh={() => forceRefresh((prev) => !prev)}
|
|
/>
|
|
<AppWelcomeScreen
|
|
onCollabDialogOpen={onCollabDialogOpen}
|
|
isCollabEnabled={!isCollabDisabled}
|
|
/>
|
|
<OverwriteConfirmDialog>
|
|
<OverwriteConfirmDialog.Actions.ExportToImage />
|
|
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
|
{excalidrawAPI && (
|
|
<OverwriteConfirmDialog.Action
|
|
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
|
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
|
onClick={() => {
|
|
exportToExcalidrawPlus(
|
|
excalidrawAPI.getSceneElements(),
|
|
excalidrawAPI.getAppState(),
|
|
excalidrawAPI.getFiles(),
|
|
excalidrawAPI.getName(),
|
|
);
|
|
}}
|
|
>
|
|
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
|
</OverwriteConfirmDialog.Action>
|
|
)}
|
|
</OverwriteConfirmDialog>
|
|
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
|
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
|
|
|
<TTDDialogTrigger />
|
|
{isCollaborating && isOffline && (
|
|
<div className="collab-offline-warning">
|
|
{t("alerts.collabOfflineWarning")}
|
|
</div>
|
|
)}
|
|
{latestShareableLink && (
|
|
<ShareableLinkDialog
|
|
link={latestShareableLink}
|
|
onCloseRequest={() => setLatestShareableLink(null)}
|
|
setErrorMessage={setErrorMessage}
|
|
/>
|
|
)}
|
|
{excalidrawAPI && !isCollabDisabled && (
|
|
<Collab excalidrawAPI={excalidrawAPI} />
|
|
)}
|
|
|
|
<ShareDialog
|
|
collabAPI={collabAPI}
|
|
onExportToBackend={async () => {
|
|
if (excalidrawAPI) {
|
|
try {
|
|
await onExportToBackend(
|
|
excalidrawAPI.getSceneElements(),
|
|
excalidrawAPI.getAppState(),
|
|
excalidrawAPI.getFiles(),
|
|
);
|
|
} catch (error: any) {
|
|
setErrorMessage(error.message);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{errorMessage && (
|
|
<ErrorDialog onClose={() => setErrorMessage("")}>
|
|
{errorMessage}
|
|
</ErrorDialog>
|
|
)}
|
|
|
|
<CommandPalette
|
|
customCommandPaletteItems={[
|
|
{
|
|
label: t("labels.liveCollaboration"),
|
|
category: DEFAULT_CATEGORIES.app,
|
|
keywords: [
|
|
"team",
|
|
"multiplayer",
|
|
"share",
|
|
"public",
|
|
"session",
|
|
"invite",
|
|
],
|
|
icon: usersIcon,
|
|
perform: () => {
|
|
setShareDialogState({
|
|
isOpen: true,
|
|
type: "collaborationOnly",
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: t("roomDialog.button_stopSession"),
|
|
category: DEFAULT_CATEGORIES.app,
|
|
predicate: () => !!collabAPI?.isCollaborating(),
|
|
keywords: [
|
|
"stop",
|
|
"session",
|
|
"end",
|
|
"leave",
|
|
"close",
|
|
"exit",
|
|
"collaboration",
|
|
],
|
|
perform: () => {
|
|
if (collabAPI) {
|
|
collabAPI.stopCollaboration();
|
|
if (!collabAPI.isCollaborating()) {
|
|
setShareDialogState({ isOpen: false });
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: t("labels.share"),
|
|
category: DEFAULT_CATEGORIES.app,
|
|
predicate: true,
|
|
icon: share,
|
|
keywords: [
|
|
"link",
|
|
"shareable",
|
|
"readonly",
|
|
"export",
|
|
"publish",
|
|
"snapshot",
|
|
"url",
|
|
"collaborate",
|
|
"invite",
|
|
],
|
|
perform: async () => {
|
|
setShareDialogState({ isOpen: true, type: "share" });
|
|
},
|
|
},
|
|
{
|
|
label: "GitHub",
|
|
icon: GithubIcon,
|
|
category: DEFAULT_CATEGORIES.links,
|
|
predicate: true,
|
|
keywords: [
|
|
"issues",
|
|
"bugs",
|
|
"requests",
|
|
"report",
|
|
"features",
|
|
"social",
|
|
"community",
|
|
],
|
|
perform: () => {
|
|
window.open(
|
|
"https://github.com/excalidraw/excalidraw",
|
|
"_blank",
|
|
"noopener noreferrer",
|
|
);
|
|
},
|
|
},
|
|
{
|
|
label: t("labels.followUs"),
|
|
icon: XBrandIcon,
|
|
category: DEFAULT_CATEGORIES.links,
|
|
predicate: true,
|
|
keywords: ["twitter", "contact", "social", "community"],
|
|
perform: () => {
|
|
window.open(
|
|
"https://x.com/excalidraw",
|
|
"_blank",
|
|
"noopener noreferrer",
|
|
);
|
|
},
|
|
},
|
|
{
|
|
label: t("labels.discordChat"),
|
|
category: DEFAULT_CATEGORIES.links,
|
|
predicate: true,
|
|
icon: DiscordIcon,
|
|
keywords: [
|
|
"chat",
|
|
"talk",
|
|
"contact",
|
|
"bugs",
|
|
"requests",
|
|
"report",
|
|
"feedback",
|
|
"suggestions",
|
|
"social",
|
|
"community",
|
|
],
|
|
perform: () => {
|
|
window.open(
|
|
"https://discord.gg/UexuTaE",
|
|
"_blank",
|
|
"noopener noreferrer",
|
|
);
|
|
},
|
|
},
|
|
{
|
|
label: "YouTube",
|
|
icon: youtubeIcon,
|
|
category: DEFAULT_CATEGORIES.links,
|
|
predicate: true,
|
|
keywords: ["features", "tutorials", "howto", "help", "community"],
|
|
perform: () => {
|
|
window.open(
|
|
"https://youtube.com/@excalidraw",
|
|
"_blank",
|
|
"noopener noreferrer",
|
|
);
|
|
},
|
|
},
|
|
...(isExcalidrawPlusSignedUser
|
|
? [
|
|
{
|
|
...ExcalidrawPlusAppCommand,
|
|
label: "Sign in / Go to Excalidraw+",
|
|
},
|
|
]
|
|
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
|
|
|
|
{
|
|
label: t("overwriteConfirm.action.excalidrawPlus.button"),
|
|
category: DEFAULT_CATEGORIES.export,
|
|
icon: exportToPlus,
|
|
predicate: true,
|
|
keywords: ["plus", "export", "save", "backup"],
|
|
perform: () => {
|
|
if (excalidrawAPI) {
|
|
exportToExcalidrawPlus(
|
|
excalidrawAPI.getSceneElements(),
|
|
excalidrawAPI.getAppState(),
|
|
excalidrawAPI.getFiles(),
|
|
excalidrawAPI.getName(),
|
|
);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
...CommandPalette.defaultItems.toggleTheme,
|
|
perform: () => {
|
|
setAppTheme(
|
|
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
|
);
|
|
},
|
|
},
|
|
{
|
|
label: t("labels.installPWA"),
|
|
category: DEFAULT_CATEGORIES.app,
|
|
predicate: () => !!pwaEvent,
|
|
perform: () => {
|
|
if (pwaEvent) {
|
|
pwaEvent.prompt();
|
|
pwaEvent.userChoice.then(() => {
|
|
// event cannot be reused, but we'll hopefully
|
|
// grab new one as the event should be fired again
|
|
pwaEvent = null;
|
|
});
|
|
}
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
{isVisualDebuggerEnabled() && excalidrawAPI && (
|
|
<DebugCanvas
|
|
appState={excalidrawAPI.getAppState()}
|
|
scale={window.devicePixelRatio}
|
|
ref={debugCanvasRef}
|
|
/>
|
|
)}
|
|
</Excalidraw>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ExcalidrawApp = () => {
|
|
const isCloudExportWindow =
|
|
window.location.pathname === "/excalidraw-plus-export";
|
|
if (isCloudExportWindow) {
|
|
return <ExcalidrawPlusIframeExport />;
|
|
}
|
|
|
|
return (
|
|
<TopErrorBoundary>
|
|
<Provider store={appJotaiStore}>
|
|
<ExcalidrawWrapper />
|
|
</Provider>
|
|
</TopErrorBoundary>
|
|
);
|
|
};
|
|
|
|
export default ExcalidrawApp;
|