From 225db4a7805befe009fe055fc2ef5daedd6c04f9 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: examples/ --- .../with-script-in-browser/.codesandbox/Dockerfile | 5 + .../with-script-in-browser/.codesandbox/tasks.json | 35 + examples/with-script-in-browser/.gitignore | 2 + .../components/CustomFooter.tsx | 73 ++ .../components/ExampleApp.scss | 92 ++ .../components/ExampleApp.tsx | 961 ++++++++++++++++++++ .../components/MobileFooter.tsx | 28 + .../components/sidebar/ExampleSidebar.scss | 66 ++ .../components/sidebar/ExampleSidebar.tsx | 31 + examples/with-script-in-browser/index.html | 31 + examples/with-script-in-browser/index.tsx | 28 + examples/with-script-in-browser/initialData.tsx | 994 +++++++++++++++++++++ examples/with-script-in-browser/package.json | 21 + .../public/images/doremon.png | Bin 0 -> 201946 bytes .../public/images/excalibot.png | Bin 0 -> 30330 bytes .../with-script-in-browser/public/images/pika.jpeg | Bin 0 -> 6250 bytes .../public/images/rocket.jpeg | Bin 0 -> 40368 bytes examples/with-script-in-browser/tsconfig.json | 9 + examples/with-script-in-browser/utils.ts | 145 +++ examples/with-script-in-browser/vercel.json | 5 + examples/with-script-in-browser/vite.config.mts | 19 + 21 files changed, 2545 insertions(+) create mode 100644 examples/with-script-in-browser/.codesandbox/Dockerfile create mode 100644 examples/with-script-in-browser/.codesandbox/tasks.json create mode 100644 examples/with-script-in-browser/.gitignore create mode 100644 examples/with-script-in-browser/components/CustomFooter.tsx create mode 100644 examples/with-script-in-browser/components/ExampleApp.scss create mode 100644 examples/with-script-in-browser/components/ExampleApp.tsx create mode 100644 examples/with-script-in-browser/components/MobileFooter.tsx create mode 100644 examples/with-script-in-browser/components/sidebar/ExampleSidebar.scss create mode 100644 examples/with-script-in-browser/components/sidebar/ExampleSidebar.tsx create mode 100644 examples/with-script-in-browser/index.html create mode 100644 examples/with-script-in-browser/index.tsx create mode 100644 examples/with-script-in-browser/initialData.tsx create mode 100644 examples/with-script-in-browser/package.json create mode 100644 examples/with-script-in-browser/public/images/doremon.png create mode 100644 examples/with-script-in-browser/public/images/excalibot.png create mode 100644 examples/with-script-in-browser/public/images/pika.jpeg create mode 100644 examples/with-script-in-browser/public/images/rocket.jpeg create mode 100644 examples/with-script-in-browser/tsconfig.json create mode 100644 examples/with-script-in-browser/utils.ts create mode 100644 examples/with-script-in-browser/vercel.json create mode 100644 examples/with-script-in-browser/vite.config.mts (limited to 'examples/with-script-in-browser') diff --git a/examples/with-script-in-browser/.codesandbox/Dockerfile b/examples/with-script-in-browser/.codesandbox/Dockerfile new file mode 100644 index 0000000..fd5b38d --- /dev/null +++ b/examples/with-script-in-browser/.codesandbox/Dockerfile @@ -0,0 +1,5 @@ +FROM node:18-bullseye + +# Vite wants to open the browser using `open`, so we +# need to install those utils. +RUN apt update -y && apt install -y xdg-utils diff --git a/examples/with-script-in-browser/.codesandbox/tasks.json b/examples/with-script-in-browser/.codesandbox/tasks.json new file mode 100644 index 0000000..990c21a --- /dev/null +++ b/examples/with-script-in-browser/.codesandbox/tasks.json @@ -0,0 +1,35 @@ +{ + // These tasks will run in order when initializing your CodeSandbox project. + "setupTasks": [ + { + "name": "Install Dependencies", + "command": "yarn install" + } + ], + + // These tasks can be run from CodeSandbox. Running one will open a log in the app. + "tasks": { + "build": { + "name": "Build", + "command": "yarn build", + "runAtStart": false + }, + "start": { + "name": "Start Example", + "command": "yarn start", + "runAtStart": true, + "preview": { + "port": 3001 + } + }, + "install-deps": { + "name": "Install Dependencies", + "command": "yarn install", + "restartOn": { + "files": ["yarn.lock"], + "branch": false, + "resume": false + } + } + } +} diff --git a/examples/with-script-in-browser/.gitignore b/examples/with-script-in-browser/.gitignore new file mode 100644 index 0000000..f1b2f5e --- /dev/null +++ b/examples/with-script-in-browser/.gitignore @@ -0,0 +1,2 @@ +# copied assets +public/**/*.woff2 \ No newline at end of file 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 = ( + + + +); + +const CustomFooter = ({ + excalidrawAPI, + excalidrawLib, +}: { + excalidrawAPI: ExcalidrawImperativeAPI; + excalidrawLib: typeof TExcalidraw; +}) => { + const { Button, MIME_TYPES } = excalidrawLib; + + return ( + <> + + + + ); +}; + +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(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 [disableImageTool, setDisableImageTool] = useState(false); + 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, + 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 && ( +
+ +
+ )} + + + + + Tab one! + Tab two! + + One + Two + + + + + Toggle Custom Sidebar + + {renderMenu()} + {excalidrawAPI && ( + 😀}> + Text to diagram + + )} + { + 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 && ( + { + 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( + 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; + }>, + ) => { + 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 ( +
{ + 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", + }); + }} + > +
+ 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 ( +