2021-01-05 20:06:14 +02:00
|
|
|
import LanguageDetector from "i18next-browser-languagedetector";
|
2021-01-04 02:21:52 +05:30
|
|
|
import React, {
|
2021-01-05 20:06:14 +02:00
|
|
|
useCallback,
|
2021-01-04 02:21:52 +05:30
|
|
|
useEffect,
|
2021-01-05 20:06:14 +02:00
|
|
|
useLayoutEffect,
|
2021-01-04 02:21:52 +05:30
|
|
|
useRef,
|
2021-01-05 20:06:14 +02:00
|
|
|
useState,
|
2021-01-04 02:21:52 +05:30
|
|
|
} from "react";
|
2021-01-05 20:06:14 +02:00
|
|
|
import { getDefaultAppState } from "../appState";
|
|
|
|
import { ExcalidrawImperativeAPI } from "../components/App";
|
|
|
|
import { ErrorDialog } from "../components/ErrorDialog";
|
|
|
|
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
|
|
|
import { APP_NAME, EVENT, TITLE_TIMEOUT } from "../constants";
|
|
|
|
import { ImportedDataState } from "../data/types";
|
|
|
|
import {
|
|
|
|
ExcalidrawElement,
|
|
|
|
NonDeletedExcalidrawElement,
|
|
|
|
} from "../element/types";
|
|
|
|
import { Language, t } from "../i18n";
|
2021-01-04 02:21:52 +05:30
|
|
|
import Excalidraw, {
|
|
|
|
defaultLang,
|
2021-01-05 20:06:14 +02:00
|
|
|
languages,
|
2021-01-04 02:21:52 +05:30
|
|
|
} from "../packages/excalidraw/index";
|
2021-01-05 20:06:14 +02:00
|
|
|
import { AppState, ExcalidrawAPIRefValue } from "../types";
|
|
|
|
import { debounce, ResolvablePromise, resolvablePromise } from "../utils";
|
|
|
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
|
|
|
|
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
|
|
|
|
import { LanguageList } from "./components/LanguageList";
|
|
|
|
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
|
|
|
import { loadFromFirebase } from "./data/firebase";
|
2020-10-25 19:39:57 +05:30
|
|
|
import {
|
|
|
|
importFromLocalStorage,
|
|
|
|
saveToLocalStorage,
|
2020-12-05 20:00:53 +05:30
|
|
|
STORAGE_KEYS,
|
|
|
|
} from "./data/localStorage";
|
|
|
|
|
2021-01-04 02:21:52 +05:30
|
|
|
const languageDetector = new LanguageDetector();
|
|
|
|
languageDetector.init({
|
|
|
|
languageUtils: {
|
|
|
|
formatLanguageCode: (langCode: Language["code"]) => langCode,
|
|
|
|
isWhitelisted: () => true,
|
|
|
|
},
|
|
|
|
checkWhitelist: false,
|
|
|
|
});
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2020-12-09 01:35:08 +05:30
|
|
|
const excalidrawRef: React.MutableRefObject<
|
|
|
|
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
|
|
|
|
> = {
|
2020-12-05 20:00:53 +05:30
|
|
|
current: {
|
|
|
|
readyPromise: resolvablePromise(),
|
|
|
|
ready: false,
|
|
|
|
},
|
|
|
|
};
|
2020-10-25 19:39:57 +05:30
|
|
|
|
|
|
|
const saveDebounced = debounce(
|
|
|
|
(elements: readonly ExcalidrawElement[], state: AppState) => {
|
|
|
|
saveToLocalStorage(elements, state);
|
|
|
|
},
|
|
|
|
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
|
|
|
);
|
|
|
|
|
|
|
|
const onBlur = () => {
|
|
|
|
saveDebounced.flush();
|
|
|
|
};
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
const shouldForceLoadScene = (
|
|
|
|
scene: ResolutionType<typeof loadScene>,
|
|
|
|
): boolean => {
|
|
|
|
if (!scene.elements.length) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
const roomMatch = getCollaborationLinkData(window.location.href);
|
|
|
|
|
|
|
|
if (!roomMatch) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const roomId = roomMatch[1];
|
|
|
|
|
|
|
|
let collabForceLoadFlag;
|
|
|
|
try {
|
|
|
|
collabForceLoadFlag = localStorage?.getItem(
|
|
|
|
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
|
|
);
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
if (collabForceLoadFlag) {
|
|
|
|
try {
|
|
|
|
const {
|
|
|
|
room: previousRoom,
|
|
|
|
timestamp,
|
|
|
|
}: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
|
|
|
|
// if loading same room as the one previously unloaded within 15sec
|
|
|
|
// force reload without prompting
|
|
|
|
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
};
|
|
|
|
|
|
|
|
type Scene = ImportedDataState & { commitToHistory: boolean };
|
|
|
|
|
|
|
|
const initializeScene = async (opts: {
|
|
|
|
resetScene: ExcalidrawImperativeAPI["resetScene"];
|
|
|
|
initializeSocketClient: CollabAPI["initializeSocketClient"];
|
|
|
|
}): Promise<Scene | null> => {
|
|
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
|
|
const id = searchParams.get("id");
|
|
|
|
const jsonMatch = window.location.hash.match(
|
|
|
|
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
|
|
|
|
);
|
|
|
|
|
|
|
|
const initialData = importFromLocalStorage();
|
|
|
|
|
|
|
|
let scene = await loadScene(null, null, initialData);
|
|
|
|
|
|
|
|
let isCollabScene = !!getCollaborationLinkData(window.location.href);
|
|
|
|
const isExternalScene = !!(id || jsonMatch || isCollabScene);
|
|
|
|
if (isExternalScene) {
|
|
|
|
if (
|
|
|
|
shouldForceLoadScene(scene) ||
|
|
|
|
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
|
|
|
) {
|
|
|
|
// Backwards compatibility with legacy url format
|
|
|
|
if (id) {
|
|
|
|
scene = await loadScene(id, null, initialData);
|
|
|
|
} else if (jsonMatch) {
|
|
|
|
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
|
|
|
}
|
|
|
|
if (!isCollabScene) {
|
2020-12-22 11:34:06 +02:00
|
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// https://github.com/excalidraw/excalidraw/issues/1919
|
|
|
|
if (document.hidden) {
|
2020-12-29 21:03:34 +01:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
window.addEventListener(
|
|
|
|
"focus",
|
|
|
|
() => initializeScene(opts).then(resolve).catch(reject),
|
|
|
|
{
|
|
|
|
once: true,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
});
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
isCollabScene = false;
|
2020-12-22 11:34:06 +02:00
|
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
if (isCollabScene) {
|
|
|
|
// when joining a room we don't want user's local scene data to be merged
|
|
|
|
// into the remote scene
|
|
|
|
opts.resetScene();
|
|
|
|
const scenePromise = opts.initializeSocketClient();
|
|
|
|
|
|
|
|
try {
|
|
|
|
const [, roomId, roomKey] = getCollaborationLinkData(
|
|
|
|
window.location.href,
|
|
|
|
)!;
|
|
|
|
const elements = await loadFromFirebase(roomId, roomKey);
|
|
|
|
if (elements) {
|
|
|
|
return {
|
|
|
|
elements,
|
|
|
|
commitToHistory: true,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
...(await scenePromise),
|
|
|
|
commitToHistory: true,
|
|
|
|
};
|
|
|
|
} catch (error) {
|
|
|
|
// log the error and move on. other peers will sync us the scene.
|
|
|
|
console.error(error);
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
} else if (scene) {
|
|
|
|
return scene;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
};
|
|
|
|
|
|
|
|
function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|
|
|
// dimensions
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2020-10-25 19:39:57 +05:30
|
|
|
const [dimensions, setDimensions] = useState({
|
|
|
|
width: window.innerWidth,
|
|
|
|
height: window.innerHeight,
|
|
|
|
});
|
2020-12-20 19:44:04 +05:30
|
|
|
const [errorMessage, setErrorMessage] = useState("");
|
2021-01-04 02:21:52 +05:30
|
|
|
const currentLangCode = languageDetector.detect() || defaultLang.code;
|
|
|
|
const [langCode, setLangCode] = useState(currentLangCode);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2020-10-25 19:39:57 +05:30
|
|
|
useLayoutEffect(() => {
|
|
|
|
const onResize = () => {
|
|
|
|
setDimensions({
|
|
|
|
width: window.innerWidth,
|
|
|
|
height: window.innerHeight,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
window.addEventListener("resize", onResize);
|
|
|
|
|
|
|
|
return () => window.removeEventListener("resize", onResize);
|
|
|
|
}, []);
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
// initial state
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
const initialStatePromiseRef = useRef<{
|
|
|
|
promise: ResolvablePromise<ImportedDataState | null>;
|
|
|
|
}>({ promise: null! });
|
|
|
|
if (!initialStatePromiseRef.current.promise) {
|
|
|
|
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
|
|
|
|
}
|
|
|
|
|
|
|
|
const { collab } = props;
|
2020-10-25 19:39:57 +05:30
|
|
|
|
|
|
|
useEffect(() => {
|
2020-12-05 20:00:53 +05:30
|
|
|
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
|
|
|
|
initializeScene({
|
|
|
|
resetScene: excalidrawApi.resetScene,
|
|
|
|
initializeSocketClient: collab.initializeSocketClient,
|
|
|
|
}).then((scene) => {
|
|
|
|
initialStatePromiseRef.current.promise.resolve(scene);
|
|
|
|
});
|
2020-10-25 19:39:57 +05:30
|
|
|
});
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
const onHashChange = (_: HashChangeEvent) => {
|
|
|
|
const api = excalidrawRef.current!;
|
|
|
|
if (!api.ready) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (window.location.hash.length > 1) {
|
|
|
|
initializeScene({
|
|
|
|
resetScene: api.resetScene,
|
|
|
|
initializeSocketClient: collab.initializeSocketClient,
|
|
|
|
}).then((scene) => {
|
|
|
|
if (scene) {
|
|
|
|
api.updateScene(scene);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-12-22 11:34:06 +02:00
|
|
|
const titleTimeout = setTimeout(
|
|
|
|
() => (document.title = APP_NAME),
|
|
|
|
TITLE_TIMEOUT,
|
|
|
|
);
|
2020-12-05 20:00:53 +05:30
|
|
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
2020-10-25 19:39:57 +05:30
|
|
|
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
|
|
|
window.addEventListener(EVENT.BLUR, onBlur, false);
|
|
|
|
return () => {
|
2020-12-05 20:00:53 +05:30
|
|
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
2020-10-25 19:39:57 +05:30
|
|
|
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
|
|
|
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
2020-12-22 11:34:06 +02:00
|
|
|
clearTimeout(titleTimeout);
|
2020-10-25 19:39:57 +05:30
|
|
|
};
|
2020-12-05 20:00:53 +05:30
|
|
|
}, [collab.initializeSocketClient]);
|
2020-10-25 19:39:57 +05:30
|
|
|
|
2021-01-04 02:21:52 +05:30
|
|
|
useEffect(() => {
|
|
|
|
languageDetector.cacheUserLanguage(langCode);
|
|
|
|
}, [langCode]);
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
const onChange = (
|
|
|
|
elements: readonly ExcalidrawElement[],
|
|
|
|
appState: AppState,
|
|
|
|
) => {
|
|
|
|
saveDebounced(elements, appState);
|
|
|
|
if (collab.isCollaborating) {
|
|
|
|
collab.broadcastElements(elements, appState);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-12-20 19:44:04 +05:30
|
|
|
const onExportToBackend = async (
|
|
|
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
|
|
|
appState: AppState,
|
|
|
|
canvas: HTMLCanvasElement | null,
|
|
|
|
) => {
|
|
|
|
if (exportedElements.length === 0) {
|
|
|
|
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
|
|
|
}
|
|
|
|
if (canvas) {
|
|
|
|
try {
|
|
|
|
await exportToBackend(exportedElements, {
|
|
|
|
...appState,
|
|
|
|
viewBackgroundColor: appState.exportBackground
|
|
|
|
? appState.viewBackgroundColor
|
|
|
|
: getDefaultAppState().viewBackgroundColor,
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
if (error.name !== "AbortError") {
|
|
|
|
const { width, height } = canvas;
|
|
|
|
console.error(error, { width, height });
|
|
|
|
setErrorMessage(error.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
2021-01-04 02:21:52 +05:30
|
|
|
|
|
|
|
const renderFooter = useCallback(
|
|
|
|
(isMobile: boolean) => {
|
|
|
|
const renderLanguageList = () => (
|
|
|
|
<LanguageList
|
|
|
|
onChange={(langCode) => {
|
|
|
|
setLangCode(langCode);
|
|
|
|
}}
|
|
|
|
languages={languages}
|
|
|
|
floating={!isMobile}
|
|
|
|
currentLangCode={langCode}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
if (isMobile) {
|
|
|
|
return (
|
|
|
|
<fieldset>
|
|
|
|
<legend>{t("labels.language")}</legend>
|
|
|
|
{renderLanguageList()}
|
|
|
|
</fieldset>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return renderLanguageList();
|
|
|
|
},
|
|
|
|
[langCode],
|
|
|
|
);
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
return (
|
2020-12-20 19:44:04 +05:30
|
|
|
<>
|
|
|
|
<Excalidraw
|
|
|
|
ref={excalidrawRef}
|
|
|
|
onChange={onChange}
|
|
|
|
width={dimensions.width}
|
|
|
|
height={dimensions.height}
|
|
|
|
initialData={initialStatePromiseRef.current.promise}
|
|
|
|
user={{ name: collab.username }}
|
|
|
|
onCollabButtonClick={collab.onCollabButtonClick}
|
|
|
|
isCollaborating={collab.isCollaborating}
|
|
|
|
onPointerUpdate={collab.onPointerUpdate}
|
|
|
|
onExportToBackend={onExportToBackend}
|
2021-01-04 02:21:52 +05:30
|
|
|
renderFooter={renderFooter}
|
|
|
|
langCode={langCode}
|
2020-12-20 19:44:04 +05:30
|
|
|
/>
|
|
|
|
{errorMessage && (
|
|
|
|
<ErrorDialog
|
|
|
|
message={errorMessage}
|
|
|
|
onClose={() => setErrorMessage("")}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</>
|
2020-12-05 20:00:53 +05:30
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export default function ExcalidrawApp() {
|
|
|
|
return (
|
2020-10-25 19:39:57 +05:30
|
|
|
<TopErrorBoundary>
|
2020-12-05 20:00:53 +05:30
|
|
|
<CollabWrapper
|
|
|
|
excalidrawRef={
|
|
|
|
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
|
|
|
|
}
|
|
|
|
>
|
|
|
|
{(collab) => <ExcalidrawWrapper collab={collab} />}
|
|
|
|
</CollabWrapper>
|
2020-10-25 19:39:57 +05:30
|
|
|
</TopErrorBoundary>
|
|
|
|
);
|
|
|
|
}
|