excalidraw/examples/excalidraw/components/App.tsx

962 lines
28 KiB
TypeScript

import React, {
useEffect,
useState,
useRef,
useCallback,
Children,
cloneElement,
} from "react";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import { nanoid } from "nanoid";
import {
resolvablePromise,
ResolvablePromise,
distance2d,
fileOpen,
withBatchedUpdates,
withBatchedUpdatesThrottled,
} from "../utils";
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
import initialData from "../initialData";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/dist/excalidraw/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
import "./App.scss";
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;
};
};
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[];
children: React.ReactNode;
excalidrawLib: typeof TExcalidraw;
}
export default function App({
appTitle,
useCustom,
customArgs,
children,
excalidrawLib,
}: AppProps) {
const {
exportToCanvas,
exportToSvg,
exportToBlob,
exportToClipboard,
useHandleLibrary,
MIME_TYPES,
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
restoreElements,
Sidebar,
Footer,
WelcomeScreen,
MainMenu,
LiveCollaborationTrigger,
convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
ROUNDNESS,
loadSceneOrLibraryFromBlob,
} = excalidrawLib;
const appRef = useRef<any>(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
const [exportEmbedScene, setExportEmbedScene] = useState(false);
const [theme, setTheme] = useState<Theme>("light");
const [disableImageTool, setDisableImageTool] = useState(false);
const [isCollaborating, setIsCollaborating] = useState(false);
const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>(
{},
);
const [comment, setComment] = useState<Comment | null>(null);
const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise =
resolvablePromise<ExcalidrawInitialDataState | null>();
}
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI | null>(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,
elements: convertToExcalidrawElements(initialData.elements),
});
excalidrawAPI.addFiles(imagesArray);
};
};
fetchData();
}, [excalidrawAPI, convertToExcalidrawElements, MIME_TYPES]);
const renderExcalidraw = (children: React.ReactNode) => {
const Excalidraw: any = Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child.type.displayName === "Excalidraw",
);
if (!Excalidraw) {
return;
}
const newElement = cloneElement(
Excalidraw,
{
excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api),
initialData: initialStatePromiseRef.current.promise,
onChange: (
elements: NonDeletedExcalidrawElement[],
state: AppState,
) => {
console.info("Elements :", elements, "State : ", state);
},
onPointerUpdate: (payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => setPointerData(payload),
viewModeEnabled,
zenModeEnabled,
gridModeEnabled,
theme,
name: "Custom name of drawing",
UIOptions: {
canvasActions: {
loadScene: false,
},
tools: { image: !disableImageTool },
},
renderTopRightUI,
onLinkOpen,
onPointerDown,
onScrollChange: rerenderCommentIcons,
validateEmbeddable: true,
},
<>
{excalidrawAPI && (
<Footer>
<CustomFooter
excalidrawAPI={excalidrawAPI}
excalidrawLib={excalidrawLib}
/>
</Footer>
)}
<WelcomeScreen />
<Sidebar name="custom">
<Sidebar.Tabs>
<Sidebar.Header />
<Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
<Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
</Sidebar.TabTriggers>
</Sidebar.Tabs>
</Sidebar>
<Sidebar.Trigger
name="custom"
tab="one"
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "20px",
zIndex: 9999999999999999,
}}
>
Toggle Custom Sidebar
</Sidebar.Trigger>
{renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</>,
);
return newElement;
};
const renderTopRightUI = (isMobile: boolean) => {
return (
<>
{!isMobile && (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {
window.alert("Collab dialog clicked");
}}
/>
)}
<button
onClick={() => alert("This is an empty top right UI")}
style={{ height: "2.5rem" }}
>
Click me
</button>
</>
);
};
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(
convertToExcalidrawElements([
{
type: "rectangle",
id: "rect-1",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
angle: 0,
x: 100.50390625,
y: 93.67578125,
strokeColor: "#c92a2a",
width: 186.47265625,
height: 141.9765625,
seed: 1968410350,
roundness: {
type: ROUNDNESS.ADAPTIVE_RADIUS,
value: 32,
},
},
{
type: "arrow",
x: 300,
y: 150,
start: { id: "rect-1" },
end: { type: "ellipse" },
},
{
type: "text",
x: 300,
y: 100,
text: "HELLO WORLD!",
},
]),
null,
),
appState: {
viewBackgroundColor: "#edf2ff",
},
};
excalidrawAPI?.updateScene(sceneData);
};
const onLinkOpen = useCallback(
(
element: NonDeletedExcalidrawElement,
event: CustomEvent<{
nativeEvent: MouseEvent | React.PointerEvent<HTMLCanvasElement>;
}>,
) => {
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("pointermove", pointerDownState.onMove);
window.removeEventListener("pointerup", 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 (
<div
id={commentIcon.id}
key={commentIcon.id}
style={{
top: `${y - COMMENT_ICON_DIMENSION / 2 - appState!.offsetTop}px`,
left: `${x - COMMENT_ICON_DIMENSION / 2 - appState!.offsetLeft}px`,
position: "absolute",
zIndex: 1,
width: `${COMMENT_ICON_DIMENSION}px`,
height: `${COMMENT_ICON_DIMENSION}px`,
cursor: "pointer",
touchAction: "none",
}}
className="comment-icon"
onPointerDown={(event) => {
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("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
pointerDownState.onMove = onPointerMove;
pointerDownState.onUp = onPointerUp;
excalidrawAPI?.setActiveTool({
type: "custom",
customType: "comment",
});
}}
>
<div className="comment-avatar">
<img src="images/doremon.png" alt="doremon" />
</div>
</div>
);
});
};
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 (
<textarea
className="comment"
style={{
top: `${top}px`,
left: `${left}px`,
position: "absolute",
zIndex: 1,
height: `${COMMENT_INPUT_HEIGHT}px`,
width: `${COMMENT_INPUT_WIDTH}px`,
}}
ref={(ref) => {
setTimeout(() => ref?.focus());
}}
placeholder={comment.value ? "Reply" : "Comment"}
value={comment.value}
onChange={(event) => {
setComment({ ...comment, value: event.target.value });
}}
onBlur={saveComment}
onKeyDown={(event) => {
if (!event.shiftKey && event.key === "Enter") {
event.preventDefault();
saveComment();
}
}}
/>
);
};
const renderMenu = () => {
return (
<MainMenu>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export />
<MainMenu.Separator />
<MainMenu.DefaultItems.LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => window.alert("You clicked on collab button")}
/>
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.ItemCustom>
<button
style={{ height: "2rem" }}
onClick={() => window.alert("custom menu item")}
>
custom item
</button>
</MainMenu.ItemCustom>
<MainMenu.DefaultItems.Help />
{excalidrawAPI && (
<MobileFooter
excalidrawLib={excalidrawLib}
excalidrawAPI={excalidrawAPI}
/>
)}
</MainMenu>
);
};
return (
<div className="App" ref={appRef}>
<h1>{appTitle}</h1>
{/* TODO fix type */}
<ExampleSidebar>
<div className="button-wrapper">
<button onClick={loadSceneOrLibrary}>Load Scene or Library</button>
<button className="update-scene" onClick={updateScene}>
Update Scene
</button>
<button
className="reset-scene"
onClick={() => {
excalidrawAPI?.resetScene();
}}
>
Reset Scene
</button>
<button
onClick={() => {
const libraryItems: LibraryItems = [
{
status: "published",
id: "1",
created: 1,
elements: initialData.libraryItems[1] as any,
},
{
status: "unpublished",
id: "2",
created: 2,
elements: initialData.libraryItems[1] as any,
},
];
excalidrawAPI?.updateLibrary({
libraryItems,
});
}}
>
Update Library
</button>
<label>
<input
type="checkbox"
checked={viewModeEnabled}
onChange={() => setViewModeEnabled(!viewModeEnabled)}
/>
View mode
</label>
<label>
<input
type="checkbox"
checked={zenModeEnabled}
onChange={() => setZenModeEnabled(!zenModeEnabled)}
/>
Zen mode
</label>
<label>
<input
type="checkbox"
checked={gridModeEnabled}
onChange={() => setGridModeEnabled(!gridModeEnabled)}
/>
Grid mode
</label>
<label>
<input
type="checkbox"
checked={theme === "dark"}
onChange={() => {
setTheme(theme === "light" ? "dark" : "light");
}}
/>
Switch to Dark Theme
</label>
<label>
<input
type="checkbox"
checked={disableImageTool === true}
onChange={() => {
setDisableImageTool(!disableImageTool);
}}
/>
Disable Image Tool
</label>
<label>
<input
type="checkbox"
checked={isCollaborating}
onChange={() => {
if (!isCollaborating) {
const collaborators = new Map();
collaborators.set("id1", {
username: "Doremon",
avatarUrl: "images/doremon.png",
});
collaborators.set("id2", {
username: "Excalibot",
avatarUrl: "images/excalibot.png",
});
collaborators.set("id3", {
username: "Pika",
avatarUrl: "images/pika.jpeg",
});
collaborators.set("id4", {
username: "fallback",
avatarUrl: "https://example.com",
});
excalidrawAPI?.updateScene({ collaborators });
} else {
excalidrawAPI?.updateScene({
collaborators: new Map(),
});
}
setIsCollaborating(!isCollaborating);
}}
/>
Show collaborators
</label>
<div>
<button onClick={onCopy.bind(null, "png")}>
Copy to Clipboard as PNG
</button>
<button onClick={onCopy.bind(null, "svg")}>
Copy to Clipboard as SVG
</button>
<button onClick={onCopy.bind(null, "json")}>
Copy to Clipboard as JSON
</button>
</div>
<div
style={{
display: "flex",
gap: "1em",
justifyContent: "center",
marginTop: "1em",
}}
>
<div>x: {pointerData?.pointer.x ?? 0}</div>
<div>y: {pointerData?.pointer.y ?? 0}</div>
</div>
</div>
<div className="excalidraw-wrapper">
{renderExcalidraw(children)}
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()}
</div>
<div className="export-wrapper button-wrapper">
<label className="export-wrapper__checkbox">
<input
type="checkbox"
checked={exportWithDarkMode}
onChange={() => setExportWithDarkMode(!exportWithDarkMode)}
/>
Export with dark mode
</label>
<label className="export-wrapper__checkbox">
<input
type="checkbox"
checked={exportEmbedScene}
onChange={() => setExportEmbedScene(!exportEmbedScene)}
/>
Export with embed scene
</label>
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
});
appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML;
}}
>
Export to SVG
</button>
<div className="export export-svg"></div>
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const blob = await exportToBlob({
elements: excalidrawAPI?.getSceneElements(),
mimeType: "image/png",
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
});
setBlobUrl(window.URL.createObjectURL(blob));
}}
>
Export to Blob
</button>
<div className="export export-blob">
<img src={blobUrl} alt="" />
</div>
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
>
Export to Canvas
</button>
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
>
Export to Canvas
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToViewport: true,
});
}}
>
Fit to viewport, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
}}
>
Fit to content, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0]);
}}
>
Scroll to first element, no fitToContent, no fitToViewport
</button>
<div className="export export-canvas">
<img src={canvasUrl} alt="" />
</div>
</div>
</ExampleSidebar>
</div>
);
}