Files
excalidraw/examples/with-script-in-browser/components/ExampleApp.tsx
T
David Luzar e1b7aacb35 feat(packages/excalidraw): replace the export functions with exportScene (#12347)
* [export] rewrite - init

* restore elements + filter deleted in export API (restores #11470 behavior)

* fix snaps

* port export callers added on master to the new API + snaps

- renderOverrides (#12100), sticky notes (#12064) and arrow-label hole
  (#10947) tests called the old positional exportToSvg/exportToCanvas
- embedded-scene snapshots gain master's new element fields (created,
  baseFontSize, labelPosition); decoded payloads match master's

Claude Opus 5.5 (xhigh)

* restore elements in embedded scene + json clipboard export (restores #12060 behavior)

- master's #12060 made every public export entry point restore its input
  (legacy elements reach lower-level code with `created: null`), incl. the
  PNG metadata embed and the json clipboard copy in the removed
  @excalidraw/utils wrappers
- SVG embed now serializes the restored, non-deleted elements (same as
  master's utils path), PNG embed serializes restoreElements(data.elements)
  and json clipboard copies the non-deleted restored elements
- port #12060's utils tests to the new API, observing renderers/serializer
  instead of the (now identical) scene export functions

Claude Opus 5.5 (xhigh)

* make config.canvasBackgroundColor the single export background knob

- background resolved once in configExportDimension; exportToSvg no longer
  reads appState.exportBackground (now omitted from ExportSceneData's
  appState type), and honors `canvasBackgroundColor: false`
- outline arrowheads (circle/triangle/diamond_outline, cardinality_zero_*)
  are filled with the resolved background color, also when the background
  isn't painted (as on master) — the canvas path filled them transparent,
  showing the arrow's shaft through them; SVG ignored the config color
- JPEG: `canvasBackgroundColor: false` falls back to the default background
  (+ warning) instead of rendering black; no more warning for the default
- callers (exportCanvas, ImageExportDialog, library/chart/publish previews,
  AI) pass the background via config only

Claude Opus 5.5 (xhigh)

* resolve export theme before creating frame labels

- frame labels were colored by appState.exportWithDarkMode, ignoring
  config.theme (export dialog's dark toggle), and in dark exports the
  dark-theme label color got dark-filtered again by the renderer
  (#7a7a7a -> #848484)
- theme is now resolved once in configExportDimension; dark labels are
  pre-reverted so they render with the editor's frame name color

Claude Opus 5.5 (xhigh)

* drop appState.exportWithDarkMode theme fallback in export

- `config.theme` is the only theme input (defaults to light); the type
  already omitted `exportWithDarkMode` from `data.appState`
- callers that relied on the fallback (PublishLibrary item preview, AI
  frame export) pass `theme` explicitly, keeping their behavior

Claude Opus 5.5 (xhigh)

* exportToClipboard: don't restore appState before exportToSvg (already restored there)

Claude Opus 5.5 (xhigh)

* remove unused getExportSize, type getCanvasSize's [x, y, width, height] (not Bounds)

Claude Opus 5.5 (xhigh)

* fix typos in export comments/JSDoc

Claude Opus 5.5 (xhigh)

* port with-script-in-browser example to the new export API

- exportToSvg/Blob/Canvas/Clipboard take `{ data, config }`; theme via
  `config.theme`, padding 10 kept explicit (new default is 0)
- dropped the no-op `width`/`height` appState overrides from the SVG export

Claude Opus 5.5 (xhigh)

* call getDimensions once, ignore it when widthOrHeight is set

- with an explicit `fit: "contain"`, getDimensions ran twice (the second
  time on its own output); it now supplies width/height (+ scale) once,
  and `contain` scales the content into that frame
- getDimensions + widthOrHeight both applied; widthOrHeight now wins (with
  a warning), like width/height/maxWidthOrHeight already did
- `fit: "none"` (the E+ export server path) renders identically

Claude Opus 5.5 (xhigh)

* type the export size options as mutually exclusive

- `width`/`height`, `maxWidthOrHeight`, `widthOrHeight` and `getDimensions`
  are now a union where the unused options are `?: never`, so combining
  them is a type error (runtime still ignores/warns for JS callers)
- the `never` keys are remapped over `keyof`, so every option keeps its
  JSDoc in completions & hovers (verified via the TS language service)

Claude Opus 5.5 (xhigh)

* replace getDimensions with a `scale` function, round the canvas size

- `config.scale` also accepts `(width, height) => number`, called with the
  export dimensions at scale 1 (padding included) — covers content-dependent
  scaling (e.g. E+'s pixel budgets) without the getDimensions round trip
- remove `getDimensions`: its returned `scale` replaced `config.scale` and
  multiplied the returned size, so callers thinking in output pixels had
  to divide it back out
- exportToCanvas rounds the canvas size instead of letting the canvas
  truncate it (float noise like 28.999999999999996 lost a pixel); half
  pixels now round up (frame label area 20.5px, mermaid preview 89.5px)

Claude Opus 5.5 (xhigh)

* align fit/padding/position export docs with the code

- `fit` defaults to "contain" when `width`/`height` is set, otherwise
  "none" (the doc said the opposite); dropped the unimplemented
  "x/y force none" claim
- padding is always subtracted for `maxWidthOrHeight`/`widthOrHeight`
  (they ignore `fit`), not added
- `position` values are "center" | "topLeft" (doc listed "none")

Claude Opus 5.5 (xhigh)

* derive a missing export width/height from the aspect ratio

- with `fit: "contain"` (the default when a size is given), passing only
  `width` or `height` now derives the other from the content's aspect
  ratio, as documented — it defaulted to the content's unscaled size,
  e.g. `{ height: 50 }` of 200x100 content gave a 200x50 canvas with
  60x30 content in wide empty bands (now 80x50 with padding 10)
- with `fit: "none"` the missing side stays the content extent

Claude Opus 5.5 (xhigh)

* default export `fit` to "none" when x/y is set

- `{ x, y, width, height }` was scaled to fit with `x`/`y` silently
  ignored (`fit` defaulted to "contain" whenever a size was given); an
  explicit region now crops by default, `fit: "contain"` still opts in
  (and ignores `x`/`y`, as now documented)

Claude Opus 5.5 (xhigh)

* ignore export x/y when the content is scaled to fit

- with `fit: "contain"`, `maxWidthOrHeight` or `widthOrHeight`, `x`/`y`
  shifted the fitted content by a mix of scene units and pixels; they're
  now dropped with a warning, which the previous commit already
  documented (its "ignored" claim was based on a coincidental probe)

Claude Opus 5.5 (xhigh)

* pad an export x/y region on all sides

- with `position: "center"` (the default), the centering adjustment also
  ran on axes pinned by `x`/`y`, shifting the region by the padding: the
  canvas started exactly at `x`/`y` with all padding on the right/bottom
  (`position: "topLeft"` padded all sides); `position` now only applies to
  unpinned axes, as documented

Claude Opus 5.5 (xhigh)

* consolidate exportToCanvas/Blob/Svg into exportScene({ type, data, config })

- `type: "canvas" | "png" | "jpeg" | "svg"`, return type per `type`
  (overloads, plus a union overload for dynamic callers); per-type options
  are type-checked: `quality` only for jpeg, `embedScene` for png/svg,
  `renderEmbeddables`/`skipInliningFonts`/`reuseImages` for svg
- the format replaces `mimeType` (and its "image/jpg" typo handling)
- `appState.exportEmbedScene` -> `config.embedScene` (no longer read
  from `data.appState`); `data.files` is optional
- `config.createCanvas` -> `config.environment.createCanvas` (where
  #11997's render environment and richtext's text metrics will go)
- exportToCanvas/Blob/Svg are no longer exported (no aliases);
  exportToClipboard stays, routed through exportScene
- callers, the with-script-in-browser example and tests ported

Claude Opus 5.5 (xhigh)

* export: take only scene settings from data.appState, frameRendering -> config.frames

- `data.appState` is typed as (and stripped at runtime to) the scene
  settings a .excalidraw file stores; the renderer state is built from
  those + defaults, so editor state passed in (a live AppState: open
  dialog, cropping, frame highlight…) can't affect the output. It only
  supplies the default background and what `embedScene` embeds
- `appState.frameRendering` -> `config.frames: { name, outline, clip }`
- editor callers translate their export settings via one internal
  `getExportConfigFromAppState`; `exportCanvas` drops its redundant
  exportBackground/viewBackgroundColor options
- fixes the editor's SVG export ignoring `exportScale` (the branch's
  exportCanvas SVG path had dropped it; master passed it)

Claude Opus 5.5 (xhigh)

* type export positioning options as width/height-only

- `fit`, `position`, `x`, `y`, `origin` move into the size-option union's
  `width`/`height` member, so combining them with `maxWidthOrHeight` /
  `widthOrHeight` (which ignore them) is a type error; runtime unchanged
  (still ignores/warns for JS callers)
- docs stay visible in completions (same `keyof` remap as the size options)

Claude Opus 5.5 (xhigh)

* move export `loadFonts` into `config.environment`

- font loading is a host capability, next to `createCanvas` (svg gets
  `environment: { loadFonts }` only, as it creates no canvas)

Claude Opus 5.5 (xhigh)

* changelog: export API (exportScene, config, migration table)

Claude Opus 5.5 (xhigh)

* export: default padding back to 10, canvas at least 1px

- export bounds are geometric (no stroke width), so padding 0 cut half
  of the strokes on the content's edge and made flat content (a line)
  zero-height — the library/chart/publish previews, which inherited
  master's default of 10, rendered blank thumbnails for lines
- canvas dimensions never go below 1px (a 0-sized canvas can't be
  encoded); SVG snapshot sizes are back to master's

Claude Opus 5.5 (xhigh)

* export: clamp padding against the fitted size, before deriving from it

- the padding clamp used the content's unpadded size, so flat content (a
  horizontal line) lost its padding with maxWidthOrHeight/widthOrHeight
  (`{ padding: 10, maxWidthOrHeight: 400 }` → 200x0); it now only
  considers the target being fitted into
- a missing width/height was derived before clamping
  (`{ height: 10, padding: 10 }` → 0x10, SVG viewBox NaN/Infinity)
- a zero-extent content axis no longer yields a NaN scale under contain

Claude Opus 5.5 (xhigh)

* export: clip frame exports to the frame

- frame clipping was off for exportingFrame (to avoid the rounded
  per-element clip), relying on the canvas/SVG edge — which only crops
  when the export is exactly the frame; a larger export (size options)
  painted children and overlapping elements into the margins, and SVG
  frame exports had stopped clipping (master clipped them)
- everything drawn is now clipped to the frame with square corners: a
  canvas clip after the background (`renderConfig.clipToFrame`), and an
  SVG group with the frame's clip path; the background isn't clipped

Claude Opus 5.5 (xhigh)

* changelog: fill in PR numbers for the placeholder links

Claude Opus 5.5 (xhigh)
2026-10-18 20:24:17 +02:00

1018 lines
30 KiB
TypeScript

import { nanoid } from "nanoid";
import React, {
useEffect,
useState,
useRef,
useCallback,
Children,
cloneElement,
} from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import initialData from "../initialData";
import {
resolvablePromise,
distance2d,
fileOpen,
withBatchedUpdates,
withBatchedUpdatesThrottled,
} from "../utils";
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import "./ExampleApp.scss";
import type { ResolvablePromise } from "../utils";
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 {
exportScene,
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 [interactive, setInteractive] = useState(true);
const [interactiveLinks, setInteractiveLinks] = useState(false);
const [interactiveEmbeds, setInteractiveEmbeds] = useState(false);
const [interactiveNavigation, setInteractiveNavigation] = useState(false);
const [interactiveBrowserZoom, setInteractiveBrowserZoom] = useState(false);
const [ui, setUi] = useState(true);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
const [exportEmbedScene, setExportEmbedScene] = useState(false);
const exportConfig = {
theme: exportWithDarkMode ? "dark" : "light",
padding: 10,
} as const;
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,
interaction: interactive
? true
: {
enabled: {
links: interactiveLinks,
embeds: interactiveEmbeds,
navigation: interactiveNavigation,
browserZoom: interactiveBrowserZoom,
},
},
ui,
zenModeEnabled,
renderScrollbars,
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({
type,
data: {
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
},
});
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={!interactive}
onChange={() => setInteractive(!interactive)}
/>
Non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveLinks}
onChange={() => setInteractiveLinks(!interactiveLinks)}
/>
Links when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveEmbeds}
onChange={() => setInteractiveEmbeds(!interactiveEmbeds)}
/>
Embeds when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveNavigation}
onChange={() => setInteractiveNavigation(!interactiveNavigation)}
/>
Navigation when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveBrowserZoom}
onChange={() =>
setInteractiveBrowserZoom(!interactiveBrowserZoom)
}
/>
Browser zoom when non-interactive
</label>
<label>
<input type="checkbox" checked={!ui} onChange={() => setUi(!ui)} />
Hide UI
</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={renderScrollbars}
onChange={() => setRenderScrollbars(!renderScrollbars)}
/>
Render scrollbars
</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 exportScene({
type: "svg",
data: {
elements: excalidrawAPI.getSceneElements(),
appState: initialData.appState,
files: excalidrawAPI.getFiles(),
},
config: { ...exportConfig, embedScene: exportEmbedScene },
});
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 exportScene({
type: "png",
data: {
elements: excalidrawAPI.getSceneElements(),
appState: initialData.appState,
files: excalidrawAPI.getFiles(),
},
config: { ...exportConfig, embedScene: exportEmbedScene },
});
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 exportScene({
type: "canvas",
data: {
elements: excalidrawAPI.getSceneElements(),
appState: initialData.appState,
files: excalidrawAPI.getFiles(),
},
config: exportConfig,
});
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 exportScene({
type: "canvas",
data: {
elements: excalidrawAPI.getSceneElements(),
appState: initialData.appState,
files: excalidrawAPI.getFiles(),
},
config: exportConfig,
});
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.setViewport({
target: elements[0],
fit: "contain",
});
}}
>
Fit to viewport, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.setViewport({
target: elements[0],
fit: "scale-down",
});
}}
>
Fit to content, first element
</button>
<div className="export export-canvas">
<img src={canvasUrl} alt="" />
</div>
</div>
</ExampleSidebar>
</div>
);
}