12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481 |
- import polyfill from "../packages/excalidraw/polyfill";
- import { useCallback, useEffect, useRef, useState } from "react";
- import { trackEvent } from "../packages/excalidraw/analytics";
- import { getDefaultAppState } from "../packages/excalidraw/appState";
- import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
- import { TopErrorBoundary } from "./components/TopErrorBoundary";
- import {
- APP_NAME,
- EVENT,
- THEME,
- TITLE_TIMEOUT,
- VERSION_TIMEOUT,
- } from "../packages/excalidraw/constants";
- import { loadFromBlob } from "../packages/excalidraw/data/blob";
- import type {
- FileId,
- NonDeletedExcalidrawElement,
- OrderedExcalidrawElement,
- } from "../packages/excalidraw/element/types";
- import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
- import { t } from "../packages/excalidraw/i18n";
- import {
- Excalidraw,
- LiveCollaborationTrigger,
- TTDDialogTrigger,
- StoreAction,
- reconcileElements,
- exportToCanvas,
- } from "../packages/excalidraw";
- import {
- exportToBlob,
- getNonDeletedElements,
- } from "../packages/excalidraw/index";
- import type {
- AppState,
- ExcalidrawImperativeAPI,
- BinaryFiles,
- ExcalidrawInitialDataState,
- UIAppState,
- } from "../packages/excalidraw/types";
- import type { ResolvablePromise } from "../packages/excalidraw/utils";
- import {
- debounce,
- getVersion,
- getFrame,
- isTestEnv,
- preventUnload,
- resolvablePromise,
- isRunningInIframe,
- } from "../packages/excalidraw/utils";
- import {
- FIREBASE_STORAGE_PREFIXES,
- isExcalidrawPlusSignedUser,
- STORAGE_KEYS,
- SYNC_BROWSER_TABS_TIMEOUT,
- } from "./app_constants";
- import type { CollabAPI } from "./collab/Collab";
- import Collab, {
- collabAPIAtom,
- isCollaboratingAtom,
- isOfflineAtom,
- } from "./collab/Collab";
- import {
- exportToBackend,
- getCollaborationLinkData,
- isCollaborationLink,
- loadScene,
- } from "./data";
- import {
- importFromLocalStorage,
- importUsernameFromLocalStorage,
- } from "./data/localStorage";
- import CustomStats from "./CustomStats";
- import type { RestoredDataState } from "../packages/excalidraw/data/restore";
- import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
- import {
- ExportToExcalidrawPlus,
- exportToExcalidrawPlus,
- } from "./components/ExportToExcalidrawPlus";
- import { updateStaleImageStatuses } from "./data/FileManager";
- import { newElementWith } from "../packages/excalidraw/element/mutateElement";
- import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
- import { loadFilesFromFirebase } from "./data/firebase";
- import {
- LibraryIndexedDBAdapter,
- LibraryLocalStorageMigrationAdapter,
- LocalData,
- } from "./data/LocalData";
- import { isBrowserStorageStateNewer } from "./data/tabSync";
- import clsx from "clsx";
- import {
- parseLibraryTokensFromUrl,
- useHandleLibrary,
- } from "../packages/excalidraw/data/library";
- import { AppMainMenu } from "./components/AppMainMenu";
- import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
- import { AppFooter } from "./components/AppFooter";
- import { Provider, useAtom, useAtomValue } from "jotai";
- import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
- import { appJotaiStore } from "./app-jotai";
- import "./index.scss";
- import type { ResolutionType } from "../packages/excalidraw/utility-types";
- import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
- import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
- import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
- import Trans from "../packages/excalidraw/components/Trans";
- import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
- import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
- import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
- import {
- CommandPalette,
- DEFAULT_CATEGORIES,
- } from "../packages/excalidraw/components/CommandPalette/CommandPalette";
- import {
- GithubIcon,
- XBrandIcon,
- DiscordIcon,
- ExcalLogo,
- usersIcon,
- exportToPlus,
- share,
- youtubeIcon,
- } from "../packages/excalidraw/components/icons";
- import { appThemeAtom, 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 { fileSave } from "../packages/excalidraw/data/filesystem";
- import type { ExportToCanvasConfig } from "../packages/excalidraw/scene/export";
- 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 [appTheme, setAppTheme] = useAtom(appThemeAtom);
- const { editorTheme } = 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 (import.meta.env.DEV) {
- 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 }),
- storeAction: StoreAction.CAPTURE,
- });
- }
- });
- }
- };
- const titleTimeout = setTimeout(
- () => (document.title = APP_NAME),
- TITLE_TIMEOUT,
- );
- 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,
- storeAction: StoreAction.UPDATE,
- });
- 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,
- );
- clearTimeout(titleTimeout);
- };
- }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
- useEffect(() => {
- const unloadHandler = (event: BeforeUnloadEvent) => {
- LocalData.flushSave();
- if (
- excalidrawAPI &&
- LocalData.fileStorage.shouldPreventUnload(
- excalidrawAPI.getSceneElements(),
- )
- ) {
- preventUnload(event);
- }
- };
- window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
- return () => {
- window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
- };
- }, [excalidrawAPI]);
- const canvasPreviewContainerRef = useRef<HTMLDivElement>(null);
- const [config, setConfig] = useState<ExportToCanvasConfig>(
- JSON.parse(localStorage.getItem("_exportConfig") || "null") || {
- width: 300,
- height: 100,
- padding: 2,
- scale: 1,
- position: "none",
- fit: "contain",
- canvasBackgroundColor: "yellow",
- },
- );
- useEffect(() => {
- localStorage.setItem("_exportConfig", JSON.stringify(config));
- }, [config]);
- const onChange = (
- elements: readonly OrderedExcalidrawElement[],
- appState: AppState,
- files: BinaryFiles,
- ) => {
- if (collabAPI?.isCollaborating()) {
- collabAPI.syncElements(elements);
- }
- {
- const frame = elements.find(
- (el) => el.strokeStyle === "dashed" && !el.isDeleted,
- );
- exportToCanvas({
- data: {
- elements: getNonDeletedElements(elements).filter(
- (x) => x.id !== frame?.id,
- ),
- // .concat(
- // restoreElements(
- // [
- // // @ts-ignore
- // {
- // type: "rectangle",
- // width: appState.width / zoom,
- // height: appState.height / zoom,
- // x: -appState.scrollX,
- // y: -appState.scrollY,
- // fillStyle: "solid",
- // strokeColor: "transparent",
- // backgroundColor: "rgba(0,0,0,0.05)",
- // roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS, value: 40 },
- // },
- // ],
- // null,
- // ),
- // ),
- appState,
- files,
- },
- config: {
- // // light yellow
- // // canvasBackgroundColor: "#fff9c4",
- // // width,
- // // maxWidthOrHeight: 120,
- // // scale: 0.01,
- // // scale: 2,
- // // origin: "content",
- // // fit: "cover",
- // // scale: 2,
- // // x: 0,
- // // y: 0,
- // padding: 20,
- // ...config,
- // width: config.width,
- // height: config.height,
- // maxWidthOrHeight: config.maxWidthOrHeight,
- // widthOrHeight: config.widthOrHeight,
- // padding: config.padding,
- ...(frame
- ? {
- ...config,
- width: frame.width,
- height: frame.height,
- x: frame.x,
- y: frame.y,
- }
- : config),
- // // height: 140,
- // // x: -appState.scrollX,
- // // y: -appState.scrollY,
- // // height: 150,
- // // height: appState.height,
- // // scale,
- // // zoom: { value: appState.zoom.value },
- // // getDimensions(width,height) {
- // // setCanvasSize({ width, height })
- // // return {width: 300, height: 150}
- // // }
- },
- }).then((canvas) => {
- if (canvasPreviewContainerRef.current) {
- canvasPreviewContainerRef.current.replaceChildren(canvas);
- document.querySelector(
- ".dims",
- )!.innerHTML = `${canvas.width}x${canvas.height}`;
- // canvas.style.width = "100%";
- }
- });
- }
- // 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,
- storeAction: StoreAction.UPDATE,
- });
- }
- }
- });
- }
- // Render the debug scene if the debug canvas is available
- if (debugCanvasRef.current && excalidrawAPI) {
- debugRenderer(
- debugCanvasRef.current,
- appState,
- window.devicePixelRatio,
- () => forceRefresh((prev) => !prev),
- );
- }
- };
- 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>
- );
- }}
- >
- <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
- style={{
- display: "flex",
- flexDirection: "column",
- position: "fixed",
- bottom: 60,
- right: 60,
- zIndex: 9999999999,
- color: "black",
- }}
- >
- <div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
- <div style={{ display: "flex", gap: "1rem" }}>
- <label>
- center{" "}
- <input
- type="checkbox"
- checked={config.position === "center"}
- onChange={() =>
- setConfig((s) => ({
- ...s,
- position: s.position === "center" ? "topLeft" : "center",
- }))
- }
- />
- </label>
- <label>
- fit{" "}
- <select
- value={config.fit}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- fit: event.target.value as any,
- }))
- }
- >
- <option value="none">none</option>
- <option value="contain">contain</option>
- <option value="cover">cover</option>
- </select>
- </label>
- <label>
- padding{" "}
- <input
- type="number"
- max={600}
- style={{ width: "3rem" }}
- value={config.padding}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- padding: !event.target.value.trim()
- ? undefined
- : Math.min(parseInt(event.target.value as any), 600),
- }))
- }
- />
- </label>
- <label>
- scale{" "}
- <input
- type="number"
- max={4}
- style={{ width: "3rem" }}
- value={config.scale}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- scale: !event.target.value.trim()
- ? undefined
- : Math.min(parseFloat(event.target.value as any), 4),
- }))
- }
- />
- </label>
- </div>
- <div style={{ display: "flex", gap: "1rem" }}>
- <label
- style={{
- opacity:
- config.maxWidthOrHeight != null ||
- config.widthOrHeight != null
- ? 0.5
- : undefined,
- }}
- >
- width{" "}
- <input
- type="number"
- max={600}
- style={{ width: "3rem" }}
- value={config.width}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- width: !event.target.value.trim()
- ? undefined
- : Math.min(parseInt(event.target.value as any), 600),
- }))
- }
- />
- </label>
- <label
- style={{
- opacity:
- config.maxWidthOrHeight != null ||
- config.widthOrHeight != null
- ? 0.5
- : undefined,
- }}
- >
- height{" "}
- <input
- type="number"
- max={600}
- style={{ width: "3rem" }}
- value={config.height}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- height: !event.target.value.trim()
- ? undefined
- : Math.min(parseInt(event.target.value as any), 600),
- }))
- }
- />
- </label>
- <label>
- x{" "}
- <input
- type="number"
- style={{ width: "3rem" }}
- value={config.x}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- x: !event.target.value.trim()
- ? undefined
- : parseFloat(event.target.value as any) ?? undefined,
- }))
- }
- />
- </label>
- <label>
- y{" "}
- <input
- type="number"
- style={{ width: "3rem" }}
- value={config.y}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- y: !event.target.value.trim()
- ? undefined
- : parseFloat(event.target.value as any) ?? undefined,
- }))
- }
- />
- </label>
- <label
- style={{
- opacity: config.widthOrHeight != null ? 0.5 : undefined,
- }}
- >
- maxWH{" "}
- <input
- type="number"
- // max={600}
- style={{ width: "3rem" }}
- value={config.maxWidthOrHeight}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- maxWidthOrHeight: !event.target.value.trim()
- ? undefined
- : parseInt(event.target.value as any),
- }))
- }
- />
- </label>
- <label>
- widthOrHeight{" "}
- <input
- type="number"
- max={600}
- style={{ width: "3rem" }}
- value={config.widthOrHeight}
- onChange={(event) =>
- setConfig((s) => ({
- ...s,
- widthOrHeight: !event.target.value.trim()
- ? undefined
- : Math.min(parseInt(event.target.value as any), 600),
- }))
- }
- />
- </label>
- </div>
- </div>
- <div className="dims">0x0</div>
- <div
- ref={canvasPreviewContainerRef}
- onClick={() => {
- exportToBlob({
- data: {
- elements: excalidrawAPI!.getSceneElements(),
- files: excalidrawAPI?.getFiles() || null,
- },
- config,
- }).then((blob) => {
- fileSave(blob, {
- name: "xx",
- extension: "png",
- description: "xxx",
- });
- });
- }}
- style={{
- borderRadius: 12,
- border: "1px solid #777",
- overflow: "hidden",
- padding: 10,
- backgroundColor: "pink",
- }}
- />
- </div>
- </div>
- );
- };
- const ExcalidrawApp = () => {
- const isCloudExportWindow =
- window.location.pathname === "/excalidraw-plus-export";
- if (isCloudExportWindow) {
- return <ExcalidrawPlusIframeExport />;
- }
- return (
- <TopErrorBoundary>
- <Provider unstable_createStore={() => appJotaiStore}>
- <ExcalidrawWrapper />
- </Provider>
- </TopErrorBoundary>
- );
- };
- export default ExcalidrawApp;
|