aboutsummaryrefslogtreecommitdiffstats
path: root/examples/with-script-in-browser/components
diff options
context:
space:
mode:
Diffstat (limited to 'examples/with-script-in-browser/components')
-rw-r--r--examples/with-script-in-browser/components/CustomFooter.tsx73
-rw-r--r--examples/with-script-in-browser/components/ExampleApp.scss92
-rw-r--r--examples/with-script-in-browser/components/ExampleApp.tsx961
-rw-r--r--examples/with-script-in-browser/components/MobileFooter.tsx28
-rw-r--r--examples/with-script-in-browser/components/sidebar/ExampleSidebar.scss66
-rw-r--r--examples/with-script-in-browser/components/sidebar/ExampleSidebar.tsx31
6 files changed, 1251 insertions, 0 deletions
diff --git a/examples/with-script-in-browser/components/CustomFooter.tsx b/examples/with-script-in-browser/components/CustomFooter.tsx
new file mode 100644
index 0000000..72fd199
--- /dev/null
+++ b/examples/with-script-in-browser/components/CustomFooter.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import type * as TExcalidraw from "@excalidraw/excalidraw";
+import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
+
+const COMMENT_SVG = (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="feather feather-message-circle"
+ >
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
+ </svg>
+);
+
+const CustomFooter = ({
+ excalidrawAPI,
+ excalidrawLib,
+}: {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+ excalidrawLib: typeof TExcalidraw;
+}) => {
+ const { Button, MIME_TYPES } = excalidrawLib;
+
+ return (
+ <>
+ <Button
+ onSelect={() => alert("General Kenobi!")}
+ style={{ marginLeft: "1rem", width: "auto" }}
+ title="Hello there!"
+ >
+ Hit me
+ </Button>
+ <Button
+ className="custom-element"
+ onSelect={() => {
+ excalidrawAPI?.setActiveTool({
+ type: "custom",
+ customType: "comment",
+ });
+ const url = `data:${MIME_TYPES.svg},${encodeURIComponent(
+ `<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="feather feather-message-circle"
+ >
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
+ </svg>`,
+ )}`;
+ excalidrawAPI?.setCursor(`url(${url}), auto`);
+ }}
+ title="Comments!"
+ >
+ {COMMENT_SVG}
+ </Button>
+ </>
+ );
+};
+
+export default CustomFooter;
diff --git a/examples/with-script-in-browser/components/ExampleApp.scss b/examples/with-script-in-browser/components/ExampleApp.scss
new file mode 100644
index 0000000..e41a77c
--- /dev/null
+++ b/examples/with-script-in-browser/components/ExampleApp.scss
@@ -0,0 +1,92 @@
+.App {
+ font-family: sans-serif;
+ text-align: center;
+
+ .comment-avatar {
+ background: #faa2c1;
+ border-radius: 66px 67px 67px 0px;
+ width: 2rem;
+ height: 2rem;
+ padding: 4px;
+ margin: 4px;
+ img {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ }
+ }
+ .app-title {
+ margin-block-start: 0.83em;
+ margin-block-end: 0.83em;
+ }
+}
+
+.button-wrapper {
+ input[type="checkbox"] {
+ margin: 5px;
+ }
+ button {
+ z-index: 1;
+ height: 40px;
+ max-width: 200px;
+ margin: 10px;
+ padding: 5px;
+ }
+}
+
+.excalidraw .App-menu_top .buttonList {
+ display: flex;
+}
+
+.excalidraw-wrapper {
+ height: 800px;
+ margin: 50px;
+ position: relative;
+ overflow: hidden;
+}
+
+:root[dir="ltr"]
+ .excalidraw
+ .layer-ui__wrapper
+ .zen-mode-transition.App-menu_bottom--transition-left {
+ transform: none;
+}
+
+.excalidraw .panelColumn {
+ text-align: left;
+}
+
+.export-wrapper {
+ display: flex;
+ flex-direction: column;
+ margin: 50px;
+
+ &__checkbox {
+ display: flex;
+ }
+}
+
+.excalidraw {
+ --color-primary: #faa2c1;
+ --color-primary-darker: #f783ac;
+ --color-primary-darkest: #e64980;
+ --color-primary-light: #fcc2d7;
+
+ button.custom-element {
+ width: 2rem;
+ height: 2rem;
+ }
+
+ .custom-footer,
+ .custom-element {
+ padding: 0.1rem;
+ margin: 0 8px;
+ }
+ .layer-ui__wrapper__footer.App-menu_bottom {
+ align-items: stretch;
+ }
+ // till its merged in OSS
+ .App-toolbar-container .mobile-misc-tools-container {
+ position: absolute;
+ }
+}
diff --git a/examples/with-script-in-browser/components/ExampleApp.tsx b/examples/with-script-in-browser/components/ExampleApp.tsx
new file mode 100644
index 0000000..08c8032
--- /dev/null
+++ b/examples/with-script-in-browser/components/ExampleApp.tsx
@@ -0,0 +1,961 @@
+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 type { ResolvablePromise } from "../utils";
+import {
+ 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/types";
+import type {
+ NonDeletedExcalidrawElement,
+ Theme,
+} from "@excalidraw/excalidraw/element/types";
+import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
+
+import "./ExampleApp.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 ExampleApp({
+ 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 Excalifont";
+ 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 Excalifont";
+ 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>
+ );
+}
diff --git a/examples/with-script-in-browser/components/MobileFooter.tsx b/examples/with-script-in-browser/components/MobileFooter.tsx
new file mode 100644
index 0000000..c8fc0f1
--- /dev/null
+++ b/examples/with-script-in-browser/components/MobileFooter.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
+import CustomFooter from "./CustomFooter";
+import type * as TExcalidraw from "@excalidraw/excalidraw";
+
+const MobileFooter = ({
+ excalidrawAPI,
+ excalidrawLib,
+}: {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+ excalidrawLib: typeof TExcalidraw;
+}) => {
+ const { useDevice, Footer } = excalidrawLib;
+
+ const device = useDevice();
+ if (device.editor.isMobile) {
+ return (
+ <Footer>
+ <CustomFooter
+ excalidrawAPI={excalidrawAPI}
+ excalidrawLib={excalidrawLib}
+ />
+ </Footer>
+ );
+ }
+ return null;
+};
+export default MobileFooter;
diff --git a/examples/with-script-in-browser/components/sidebar/ExampleSidebar.scss b/examples/with-script-in-browser/components/sidebar/ExampleSidebar.scss
new file mode 100644
index 0000000..773a8ff
--- /dev/null
+++ b/examples/with-script-in-browser/components/sidebar/ExampleSidebar.scss
@@ -0,0 +1,66 @@
+.sidebar {
+ height: 100%;
+ width: 0;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ background-color: #111;
+ overflow-x: hidden;
+ transition: 0.5s;
+ padding-top: 60px;
+
+ &.open {
+ width: 300px;
+ }
+
+ &-links {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+
+ button {
+ padding: 10px;
+ margin: 10px;
+ background: #faa2c1;
+ color: #fff;
+ border: none;
+ cursor: pointer;
+ }
+ }
+}
+
+.sidebar a {
+ padding: 8px 8px 8px 32px;
+ text-decoration: none;
+ font-size: 25px;
+ color: #818181;
+ display: block;
+ transition: 0.3s;
+}
+
+.sidebar a:hover {
+ color: #f1f1f1;
+}
+
+.sidebar .closebtn {
+ position: absolute;
+ top: 0;
+ right: 0;
+ font-size: 36px;
+ margin-left: 50px;
+}
+
+.openbtn {
+ font-size: 20px;
+ cursor: pointer;
+ background-color: #111;
+ color: white;
+ padding: 10px 15px;
+ border: none;
+ display: flex;
+ margin-left: 50px;
+}
+.sidebar-open {
+ margin-left: 300px;
+}
diff --git a/examples/with-script-in-browser/components/sidebar/ExampleSidebar.tsx b/examples/with-script-in-browser/components/sidebar/ExampleSidebar.tsx
new file mode 100644
index 0000000..1939134
--- /dev/null
+++ b/examples/with-script-in-browser/components/sidebar/ExampleSidebar.tsx
@@ -0,0 +1,31 @@
+import React, { useState } from "react";
+import "./ExampleSidebar.scss";
+
+export default function Sidebar({ children }: { children: React.ReactNode }) {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+ <div id="mySidebar" className={`sidebar ${open ? "open" : ""}`}>
+ <button className="closebtn" onClick={() => setOpen(false)}>
+ x
+ </button>
+ <div className="sidebar-links">
+ <button>Empty Home</button>
+ <button>Empty About</button>
+ </div>
+ </div>
+ <div className={`${open ? "sidebar-open" : ""}`}>
+ <button
+ className="openbtn"
+ onClick={() => {
+ setOpen(!open);
+ }}
+ >
+ Open Sidebar
+ </button>
+ {children}
+ </div>
+ </>
+ );
+}