mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
* [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)
1018 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|