You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
336 lines
9.6 KiB
336 lines
9.6 KiB
import React, { useState, useLayoutEffect, useEffect, useRef } from "react"; |
|
|
|
import Excalidraw from "../packages/excalidraw/index"; |
|
|
|
import { |
|
getTotalStorageSize, |
|
importFromLocalStorage, |
|
saveToLocalStorage, |
|
STORAGE_KEYS, |
|
} from "./data/localStorage"; |
|
|
|
import { ImportedDataState } from "../data/types"; |
|
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper"; |
|
import { TopErrorBoundary } from "../components/TopErrorBoundary"; |
|
import { t } from "../i18n"; |
|
import { exportToBackend, loadScene } from "./data"; |
|
import { getCollaborationLinkData } from "./data"; |
|
import { EVENT } from "../constants"; |
|
import { loadFromFirebase } from "./data/firebase"; |
|
import { ExcalidrawImperativeAPI } from "../components/App"; |
|
import { debounce, ResolvablePromise, resolvablePromise } from "../utils"; |
|
import { AppState, ExcalidrawAPIRefValue } from "../types"; |
|
import { |
|
ExcalidrawElement, |
|
NonDeletedExcalidrawElement, |
|
} from "../element/types"; |
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants"; |
|
import { EVENT_LOAD, EVENT_SHARE, trackEvent } from "../analytics"; |
|
import { ErrorDialog } from "../components/ErrorDialog"; |
|
import { getDefaultAppState } from "../appState"; |
|
import { APP_NAME, TITLE_TIMEOUT } from "../constants"; |
|
|
|
const excalidrawRef: React.MutableRefObject< |
|
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise"> |
|
> = { |
|
current: { |
|
readyPromise: resolvablePromise(), |
|
ready: false, |
|
}, |
|
}; |
|
|
|
const saveDebounced = debounce( |
|
(elements: readonly ExcalidrawElement[], state: AppState) => { |
|
saveToLocalStorage(elements, state); |
|
}, |
|
SAVE_TO_LOCAL_STORAGE_TIMEOUT, |
|
); |
|
|
|
const onBlur = () => { |
|
saveDebounced.flush(); |
|
}; |
|
|
|
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"]; |
|
onLateInitialization?: (scene: Scene) => void; |
|
}): 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) { |
|
window.history.replaceState({}, APP_NAME, window.location.origin); |
|
} |
|
} else { |
|
// https://github.com/excalidraw/excalidraw/issues/1919 |
|
if (document.hidden) { |
|
window.addEventListener( |
|
"focus", |
|
() => |
|
initializeScene(opts).then((_scene) => { |
|
opts?.onLateInitialization?.(_scene || scene); |
|
}), |
|
{ |
|
once: true, |
|
}, |
|
); |
|
return null; |
|
} |
|
|
|
isCollabScene = false; |
|
window.history.replaceState({}, APP_NAME, window.location.origin); |
|
} |
|
} |
|
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(); |
|
trackEvent(EVENT_SHARE, "session join"); |
|
|
|
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 |
|
// --------------------------------------------------------------------------- |
|
|
|
const [dimensions, setDimensions] = useState({ |
|
width: window.innerWidth, |
|
height: window.innerHeight, |
|
}); |
|
const [errorMessage, setErrorMessage] = useState(""); |
|
|
|
useLayoutEffect(() => { |
|
const onResize = () => { |
|
setDimensions({ |
|
width: window.innerWidth, |
|
height: window.innerHeight, |
|
}); |
|
}; |
|
|
|
window.addEventListener("resize", onResize); |
|
|
|
return () => window.removeEventListener("resize", onResize); |
|
}, []); |
|
|
|
// initial state |
|
// --------------------------------------------------------------------------- |
|
|
|
const initialStatePromiseRef = useRef<{ |
|
promise: ResolvablePromise<ImportedDataState | null>; |
|
}>({ promise: null! }); |
|
if (!initialStatePromiseRef.current.promise) { |
|
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>(); |
|
} |
|
|
|
const { collab } = props; |
|
|
|
useEffect(() => { |
|
const storageSize = getTotalStorageSize(); |
|
if (storageSize) { |
|
trackEvent(EVENT_LOAD, "storage", "size", storageSize); |
|
} else { |
|
trackEvent(EVENT_LOAD, "first time"); |
|
} |
|
excalidrawRef.current!.readyPromise.then((excalidrawApi) => { |
|
initializeScene({ |
|
resetScene: excalidrawApi.resetScene, |
|
initializeSocketClient: collab.initializeSocketClient, |
|
onLateInitialization: (scene) => { |
|
initialStatePromiseRef.current.promise.resolve(scene); |
|
}, |
|
}).then((scene) => { |
|
initialStatePromiseRef.current.promise.resolve(scene); |
|
}); |
|
}); |
|
|
|
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); |
|
} |
|
}); |
|
} |
|
}; |
|
|
|
const titleTimeout = setTimeout( |
|
() => (document.title = APP_NAME), |
|
TITLE_TIMEOUT, |
|
); |
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false); |
|
window.addEventListener(EVENT.UNLOAD, onBlur, false); |
|
window.addEventListener(EVENT.BLUR, onBlur, false); |
|
return () => { |
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false); |
|
window.removeEventListener(EVENT.UNLOAD, onBlur, false); |
|
window.removeEventListener(EVENT.BLUR, onBlur, false); |
|
clearTimeout(titleTimeout); |
|
}; |
|
}, [collab.initializeSocketClient]); |
|
|
|
const onChange = ( |
|
elements: readonly ExcalidrawElement[], |
|
appState: AppState, |
|
) => { |
|
saveDebounced(elements, appState); |
|
if (collab.isCollaborating) { |
|
collab.broadcastElements(elements, appState); |
|
} |
|
}; |
|
|
|
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); |
|
} |
|
} |
|
} |
|
}; |
|
return ( |
|
<> |
|
<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} |
|
/> |
|
{errorMessage && ( |
|
<ErrorDialog |
|
message={errorMessage} |
|
onClose={() => setErrorMessage("")} |
|
/> |
|
)} |
|
</> |
|
); |
|
} |
|
|
|
export default function ExcalidrawApp() { |
|
return ( |
|
<TopErrorBoundary> |
|
<CollabWrapper |
|
excalidrawRef={ |
|
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI> |
|
} |
|
> |
|
{(collab) => <ExcalidrawWrapper collab={collab} />} |
|
</CollabWrapper> |
|
</TopErrorBoundary> |
|
); |
|
}
|
|
|