import { useEffect, useState, useRef, useCallback } from "react"; import ExampleSidebar from "./sidebar/ExampleSidebar"; import type * as TExcalidraw from "../index"; import "./App.scss"; import initialData from "./initialData"; import { nanoid } from "nanoid"; import { resolvablePromise, ResolvablePromise, withBatchedUpdates, withBatchedUpdatesThrottled, } from "../../../utils"; import { EVENT, ROUNDNESS } from "../../../constants"; import { distance2d } from "../../../math"; import { fileOpen } from "../../../data/filesystem"; import { loadSceneOrLibraryFromBlob } from "../../utils"; import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawInitialDataState, Gesture, LibraryItems, PointerDownState as ExcalidrawPointerDownState, } from "../../../types"; import { NonDeletedExcalidrawElement } from "../../../element/types"; import { ImportedLibraryData } from "../../../data/types"; import CustomFooter from "./CustomFooter"; import MobileFooter from "./MobileFooter"; import { KEYS } from "../../../keys"; declare global { interface Window { ExcalidrawLib: typeof TExcalidraw; } } type Comment = { x: number; y: number; value: string; id?: string; }; type PointerDownState = { x: number; y: number; hitElement: Comment; onMove: any; onUp: any; hitElementOffsets: { x: number; y: number; }; }; // This is so that we use the bundled excalidraw.development.js file instead // of the actual source code const { exportToCanvas, exportToSvg, exportToBlob, exportToClipboard, Excalidraw, useHandleLibrary, MIME_TYPES, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, restoreElements, Sidebar, Footer, WelcomeScreen, MainMenu, LiveCollaborationTrigger, } = window.ExcalidrawLib; const COMMENT_ICON_DIMENSION = 32; const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_WIDTH = 150; export interface AppProps { appTitle: string; useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; customArgs?: any[]; } export default function App({ appTitle, useCustom, customArgs }: AppProps) { const appRef = useRef(null); const [viewModeEnabled, setViewModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false); const [gridModeEnabled, setGridModeEnabled] = useState(false); const [blobUrl, setBlobUrl] = useState(""); const [canvasUrl, setCanvasUrl] = useState(""); const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportEmbedScene, setExportEmbedScene] = useState(false); const [theme, setTheme] = useState("light"); const [isCollaborating, setIsCollaborating] = useState(false); const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>( {}, ); const [comment, setComment] = useState(null); const initialStatePromiseRef = useRef<{ promise: ResolvablePromise; }>({ promise: null! }); if (!initialStatePromiseRef.current.promise) { initialStatePromiseRef.current.promise = resolvablePromise(); } const [excalidrawAPI, setExcalidrawAPI] = useState(null); useCustom(excalidrawAPI, customArgs); useHandleLibrary({ excalidrawAPI }); useEffect(() => { if (!excalidrawAPI) { return; } const fetchData = async () => { const res = await fetch("/images/rocket.jpeg"); const imageData = await res.blob(); const reader = new FileReader(); reader.readAsDataURL(imageData); reader.onload = function () { const imagesArray: BinaryFileData[] = [ { id: "rocket" as BinaryFileData["id"], dataURL: reader.result as BinaryFileData["dataURL"], mimeType: MIME_TYPES.jpg, created: 1644915140367, lastRetrieved: 1644915140367, }, ]; //@ts-ignore initialStatePromiseRef.current.promise.resolve(initialData); excalidrawAPI.addFiles(imagesArray); }; }; fetchData(); }, [excalidrawAPI]); const renderTopRightUI = (isMobile: boolean) => { return ( <> {!isMobile && ( { window.alert("Collab dialog clicked"); }} /> )} ); }; const loadSceneOrLibrary = async () => { const file = await fileOpen({ description: "Excalidraw or library file" }); const contents = await loadSceneOrLibraryFromBlob(file, null, null); if (contents.type === MIME_TYPES.excalidraw) { excalidrawAPI?.updateScene(contents.data as any); } else if (contents.type === MIME_TYPES.excalidrawlib) { excalidrawAPI?.updateLibrary({ libraryItems: (contents.data as ImportedLibraryData).libraryItems!, openLibraryMenu: true, }); } }; const updateScene = () => { const sceneData = { elements: restoreElements( [ { type: "rectangle", version: 141, versionNonce: 361174001, isDeleted: false, id: "oDVXy8D6rom3H1-LLH2-f", fillStyle: "hachure", strokeWidth: 1, strokeStyle: "solid", roughness: 1, opacity: 100, angle: 0, x: 100.50390625, y: 93.67578125, strokeColor: "#c92a2a", backgroundColor: "transparent", width: 186.47265625, height: 141.9765625, seed: 1968410350, groupIds: [], boundElements: null, locked: false, link: null, updated: 1, roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS, value: 32, }, }, ], null, ), appState: { viewBackgroundColor: "#edf2ff", }, }; excalidrawAPI?.updateScene(sceneData); }; const onLinkOpen = useCallback( ( element: NonDeletedExcalidrawElement, event: CustomEvent<{ nativeEvent: MouseEvent | React.PointerEvent; }>, ) => { const link = element.link!; const { nativeEvent } = event.detail; const isNewTab = nativeEvent.ctrlKey || nativeEvent.metaKey; const isNewWindow = nativeEvent.shiftKey; const isInternalLink = link.startsWith("/") || link.includes(window.location.origin); if (isInternalLink && !isNewTab && !isNewWindow) { // signal that we're handling the redirect ourselves event.preventDefault(); // do a custom redirect, such as passing to react-router // ... } }, [], ); const onCopy = async (type: "png" | "svg" | "json") => { if (!excalidrawAPI) { return false; } await exportToClipboard({ elements: excalidrawAPI.getSceneElements(), appState: excalidrawAPI.getAppState(), files: excalidrawAPI.getFiles(), type, }); window.alert(`Copied to clipboard as ${type} successfully`); }; const [pointerData, setPointerData] = useState<{ pointer: { x: number; y: number }; button: "down" | "up"; pointersMap: Gesture["pointers"]; } | null>(null); const onPointerDown = ( activeTool: AppState["activeTool"], pointerDownState: ExcalidrawPointerDownState, ) => { if (activeTool.type === "custom" && activeTool.customType === "comment") { const { x, y } = pointerDownState.origin; setComment({ x, y, value: "" }); } }; const rerenderCommentIcons = () => { if (!excalidrawAPI) { return false; } const commentIconsElements = appRef.current.querySelectorAll( ".comment-icon", ) as HTMLElement[]; commentIconsElements.forEach((ele) => { const id = ele.id; const appstate = excalidrawAPI.getAppState(); const { x, y } = sceneCoordsToViewportCoords( { sceneX: commentIcons[id].x, sceneY: commentIcons[id].y }, appstate, ); ele.style.left = `${ x - COMMENT_ICON_DIMENSION / 2 - appstate!.offsetLeft }px`; ele.style.top = `${ y - COMMENT_ICON_DIMENSION / 2 - appstate!.offsetTop }px`; }); }; const onPointerMoveFromPointerDownHandler = ( pointerDownState: PointerDownState, ) => { return withBatchedUpdatesThrottled((event) => { if (!excalidrawAPI) { return false; } const { x, y } = viewportCoordsToSceneCoords( { clientX: event.clientX - pointerDownState.hitElementOffsets.x, clientY: event.clientY - pointerDownState.hitElementOffsets.y, }, excalidrawAPI.getAppState(), ); setCommentIcons({ ...commentIcons, [pointerDownState.hitElement.id!]: { ...commentIcons[pointerDownState.hitElement.id!], x, y, }, }); }); }; const onPointerUpFromPointerDownHandler = ( pointerDownState: PointerDownState, ) => { return withBatchedUpdates((event) => { window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove); window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp); excalidrawAPI?.setActiveTool({ type: "selection" }); const distance = distance2d( pointerDownState.x, pointerDownState.y, event.clientX, event.clientY, ); if (distance === 0) { if (!comment) { setComment({ x: pointerDownState.hitElement.x + 60, y: pointerDownState.hitElement.y, value: pointerDownState.hitElement.value, id: pointerDownState.hitElement.id, }); } else { setComment(null); } } }); }; const renderCommentIcons = () => { return Object.values(commentIcons).map((commentIcon) => { if (!excalidrawAPI) { return false; } const appState = excalidrawAPI.getAppState(); const { x, y } = sceneCoordsToViewportCoords( { sceneX: commentIcon.x, sceneY: commentIcon.y }, excalidrawAPI.getAppState(), ); return (
{ event.preventDefault(); if (comment) { commentIcon.value = comment.value; saveComment(); } const pointerDownState: any = { x: event.clientX, y: event.clientY, hitElement: commentIcon, hitElementOffsets: { x: event.clientX - x, y: event.clientY - y }, }; const onPointerMove = onPointerMoveFromPointerDownHandler(pointerDownState); const onPointerUp = onPointerUpFromPointerDownHandler(pointerDownState); window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); window.addEventListener(EVENT.POINTER_UP, onPointerUp); pointerDownState.onMove = onPointerMove; pointerDownState.onUp = onPointerUp; excalidrawAPI?.setActiveTool({ type: "custom", customType: "comment", }); }} >
doremon
); }); }; const saveComment = () => { if (!comment) { return; } if (!comment.id && !comment.value) { setComment(null); return; } const id = comment.id || nanoid(); setCommentIcons({ ...commentIcons, [id]: { x: comment.id ? comment.x - 60 : comment.x, y: comment.y, id, value: comment.value, }, }); setComment(null); }; const renderComment = () => { if (!comment) { return null; } const appState = excalidrawAPI?.getAppState()!; const { x, y } = sceneCoordsToViewportCoords( { sceneX: comment.x, sceneY: comment.y }, appState, ); let top = y - COMMENT_ICON_DIMENSION / 2 - appState.offsetTop; let left = x - COMMENT_ICON_DIMENSION / 2 - appState.offsetLeft; if ( top + COMMENT_INPUT_HEIGHT < appState.offsetTop + COMMENT_INPUT_HEIGHT ) { top = COMMENT_ICON_DIMENSION / 2; } if (top + COMMENT_INPUT_HEIGHT > appState.height) { top = appState.height - COMMENT_INPUT_HEIGHT - COMMENT_ICON_DIMENSION / 2; } if ( left + COMMENT_INPUT_WIDTH < appState.offsetLeft + COMMENT_INPUT_WIDTH ) { left = COMMENT_ICON_DIMENSION / 2; } if (left + COMMENT_INPUT_WIDTH > appState.width) { left = appState.width - COMMENT_INPUT_WIDTH - COMMENT_ICON_DIMENSION / 2; } return (