Compare commits

...

28 Commits

Author SHA1 Message Date
ad1992
c4b951a0c5 convert customElementsConfig into an object 2022-05-04 14:26:54 +05:30
ad1992
c93d8f4bd0 don't use addCallback for triggering onCreate 2022-05-04 13:30:42 +05:30
ad1992
645f9a5dc0 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-05-04 13:20:08 +05:30
ad1992
128b7741c1 update config to use displayData 2022-04-27 14:03:42 +05:30
ad1992
1edde7291c fix specs 2022-04-26 12:02:50 +05:30
ad1992
1ca56204b1 don't draw if image not present 2022-04-25 20:25:14 +05:30
ad1992
f3ae7a8506 don't show move cursor if transform handles disabled 2022-04-25 14:45:17 +05:30
ad1992
5f57daa132 fix: selection not working sometimes when transformHandles disabled 2022-04-25 14:23:28 +05:30
ad1992
db9c9eb3d2 suppport disabling context menu in custom elements 2022-04-22 00:56:17 +05:30
ad1992
2e8c4d25f2 fix package example 2022-04-21 23:55:38 +05:30
ad1992
4953828d86 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-04-21 19:50:46 +05:30
ad1992
6eb0cf6a10 unbind onCreate once executed 2022-04-20 11:48:22 +05:30
ad1992
ba48aa24a0 Add onCreate in customElementConfig 2022-04-19 21:58:38 +05:30
ad1992
4e75f10b2c cache svg with element id 2022-04-18 21:30:38 +05:30
ad1992
d2d3599661 Support svg as a async function returing promise/string 2022-04-18 16:08:40 +05:30
ad1992
ed3eda3401 restore custom elements with correct type 2022-03-30 14:20:29 +05:30
ad1992
d27b32dd2c Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-03-29 17:26:46 +05:30
ad1992
2337842f57 fix typescript 2022-03-29 16:07:45 +05:30
ad1992
5b78f50fe3 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-03-29 15:34:48 +05:30
ad1992
a4a95a591a Add stackedOnTop to make sure the custom element is always rendered on top of all when stackedOnTop is true 2022-03-28 15:03:29 +05:30
ad1992
3d459076fb Merge remote-tracking branch 'origin/master' into aakansha-custom-elements
Update customType
2022-03-25 22:32:28 +05:30
ad1992
14a23c6c50 make onElementClick optional 2022-03-24 17:28:40 +05:30
ad1992
5f4a5b1789 Add onElementClick and export sceneCoordsToViewportCoords 2022-03-24 17:24:54 +05:30
ad1992
47498796e0 fix hit testing for custom elements 2022-03-24 15:06:22 +05:30
ad1992
8706277d14 rename name to customType 2022-03-24 14:04:31 +05:30
ad1992
3d0a1106ff support making transform handles optional 2022-03-23 23:24:25 +05:30
ad1992
61699ff3c2 cache the custom image element and improve jittering experience 2022-03-23 19:42:39 +05:30
ad1992
39d0084a5e feat: Support custom elements in @excalidraw/excalidraw 2022-03-23 19:04:00 +05:30
22 changed files with 636 additions and 98 deletions

View File

@ -304,21 +304,42 @@ export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.type !== "eraser") {
if (appState.activeTool.type === "custom") {
activeTool.lastActiveToolBeforeEraser = {
type: "custom",
customType: appState.activeTool.customType,
};
} else {
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
}
}
if (isEraserActive(appState)) {
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
) {
activeTool.type = "custom";
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
} else {
activeTool.type = "eraser";
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool: {
...appState.activeTool,
type: isEraserActive(appState)
? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
: "eraser",
lastActiveToolBeforeEraser:
appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
? null
: appState.activeTool.type,
},
activeTool,
},
commitToHistory: true,
};

View File

@ -136,7 +136,21 @@ export const actionFinalize = register({
) {
resetCursor(canvas);
}
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
) {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
return {
elements: newElements,
appState: {
@ -147,14 +161,7 @@ export const actionFinalize = register({
appState.activeTool.type === "freedraw") &&
multiPointElement
? appState.activeTool
: {
...appState.activeTool,
type:
appState.activeTool.type === "eraser" &&
appState.activeTool.lastActiveToolBeforeEraser
? appState.activeTool.lastActiveToolBeforeEraser
: "selection",
},
: activeTool,
draggingElement: null,
multiElement: null,
editingElement: null,

View File

@ -119,12 +119,17 @@ import {
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import {
deepCopyElement,
newCustomElement,
newFreeDrawElement,
} from "../element/newElement";
import {
hasBoundTextElement,
isBindingElement,
isBindingElementType,
isBoundToContainer,
isCustomElement,
isImageElement,
isInitializedImageElement,
isLinearElement,
@ -380,6 +385,7 @@ class App extends React.Component<AppProps, AppState> {
importLibrary: this.importLibraryFromUrl,
setToastMessage: this.setToastMessage,
id: this.id,
setCustomType: this.setCustomType,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@ -394,7 +400,7 @@ class App extends React.Component<AppProps, AppState> {
id: this.id,
};
this.scene = new Scene();
this.scene = new Scene(this);
this.library = new Library(this);
this.history = new History();
this.actionManager = new ActionManager(
@ -409,6 +415,59 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(createRedoAction(this.history));
}
setCustomType = (customType: string) => {
this.setState({
activeTool: { ...this.state.activeTool, type: "custom", customType },
});
};
renderCustomElement = (coords: { x: number; y: number }) => {
if (this.state.activeTool.type !== "custom") {
return;
}
const config =
this.props.customElementsConfig?.[this.state.activeTool.customType];
if (!config) {
return;
}
const [gridX, gridY] = getGridPoint(
coords.x,
coords.y,
this.state.gridSize,
);
const width = config.width || 40;
const height = config.height || 40;
const customElement = newCustomElement(this.state.activeTool.customType, {
type: "custom",
x: gridX - width / 2,
y: gridY - height / 2,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemLinearStrokeSharpness,
width,
height,
locked: false,
});
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
customElement,
]);
const customElementConfig =
this.props.customElementsConfig?.[customElement.customType];
if (customElementConfig && customElementConfig.onCreate) {
customElementConfig.onCreate(customElement);
}
};
private renderCanvas() {
const canvasScale = window.devicePixelRatio;
const {
@ -532,6 +591,7 @@ class App extends React.Component<AppProps, AppState> {
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderCustomElementWidget={this.props.renderCustomElementWidget}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
@ -1234,6 +1294,7 @@ class App extends React.Component<AppProps, AppState> {
imageCache: this.imageCache,
isExporting: false,
renderScrollbars: !this.deviceType.isMobile,
customElementsConfig: this.props.customElementsConfig,
},
);
@ -1591,14 +1652,18 @@ class App extends React.Component<AppProps, AppState> {
);
}
this.setState((prevState) => {
const activeTool: any = {
...prevState.activeTool,
locked: !prevState.activeTool.locked,
type: prevState.activeTool.locked
? "selection"
: prevState.activeTool.type,
};
if (prevState.activeTool.type === "custom") {
activeTool.customType = prevState.activeTool.customType;
}
return {
activeTool: {
...prevState.activeTool,
locked: !prevState.activeTool.locked,
type: prevState.activeTool.locked
? "selection"
: prevState.activeTool.type,
},
activeTool,
};
});
};
@ -2829,6 +2894,14 @@ class App extends React.Component<AppProps, AppState> {
)) &&
!hitElement?.locked
) {
if (hitElement && isCustomElement(hitElement)) {
const config =
this.props.customElementsConfig?.[hitElement.customType];
if (!config?.transformHandles) {
return;
}
}
setCursor(this.canvas, CURSOR_TYPE.MOVE);
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
@ -3051,6 +3124,12 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
});
} else if (this.state.activeTool.type === "custom") {
setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR);
this.renderCustomElement({
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
});
} else if (this.state.activeTool.type === "freedraw") {
this.handleFreeDrawElementOnPointerDown(
event,
@ -3091,15 +3170,13 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
) => {
this.lastPointerUp = event;
if (this.deviceType.isTouchScreen) {
let hitElement;
if (this.deviceType.isTouchScreen || this.props.onElementClick) {
const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY },
this.state,
);
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
hitElement = this.getElementAtPosition(scenePointer.x, scenePointer.y);
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,
@ -3112,6 +3189,24 @@ class App extends React.Component<AppProps, AppState> {
this.redirectToLink(event, this.deviceType.isTouchScreen);
}
if (
event.button !== POINTER_BUTTON.SECONDARY &&
this.state.activeTool.type === "selection" &&
this.props.onElementClick &&
hitElement
) {
const threshold = 5;
const isSinglePointClick =
distance2d(
this.lastPointerDown!.clientX,
this.lastPointerDown!.clientY,
this.lastPointerUp!.clientX,
this.lastPointerUp!.clientY,
) <= threshold;
if (isSinglePointClick) {
this.props.onElementClick(hitElement, event);
}
}
this.removePointer(event);
};
@ -4220,6 +4315,7 @@ class App extends React.Component<AppProps, AppState> {
const elementsWithinSelection = getElementsWithinSelection(
elements,
draggingElement,
this.props.customElementsConfig,
);
this.setState((prevState) =>
selectGroupsForSelectedElements(
@ -4513,6 +4609,7 @@ class App extends React.Component<AppProps, AppState> {
// Code below handles selection when element(s) weren't
// drag or added to selection on pointer down phase.
const hitElement = pointerDownState.hit.element;
if (isEraserActive(this.state)) {
const draggedDistance = distance2d(
this.lastPointerDown!.clientX,
@ -4546,7 +4643,6 @@ class App extends React.Component<AppProps, AppState> {
} else if (Object.keys(pointerDownState.elementIdsToErase).length) {
this.restoreReadyToEraseElements(pointerDownState);
}
if (
hitElement &&
!pointerDownState.drag.hasOccurred &&
@ -5347,7 +5443,6 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
) => {
event.preventDefault();
if (
(event.nativeEvent.pointerType === "touch" ||
(event.nativeEvent.pointerType === "pen" &&
@ -5364,6 +5459,16 @@ class App extends React.Component<AppProps, AppState> {
includeLockedElements: true,
});
let disableContextMenu = false;
if (element && isCustomElement(element)) {
const config = this.props.customElementsConfig?.[element.customType];
disableContextMenu = !!config?.disableContextMenu;
}
if (disableContextMenu) {
this.contextMenuOpen = true;
return false;
}
const type = element ? "element" : "canvas";
const container = this.excalidrawContainerRef.current!;

View File

@ -68,6 +68,7 @@ interface LayerUIProps {
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderCustomElementWidget?: (appState: AppState) => void;
}
const LayerUI = ({
@ -95,6 +96,7 @@ const LayerUI = ({
library,
id,
onImageAction,
renderCustomElementWidget,
}: LayerUIProps) => {
const deviceType = useDeviceType();
@ -439,6 +441,8 @@ const LayerUI = ({
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
{renderCustomElementWidget &&
renderCustomElementWidget(appState)}
</div>
</>
)}

View File

@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
import { AppProps, CustomElementConfig } from "./types";
import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw";
@ -155,6 +155,17 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
},
};
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
type: "custom",
customType: "custom",
transformHandles: true,
displayData: { content: "", type: "svg" },
width: 40,
height: 40,
stackedOnTop: false,
onCreate: () => {},
disableContextMenu: false,
};
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;

View File

@ -48,6 +48,7 @@ export const AllowedExcalidrawActiveTools: Record<
arrow: true,
freedraw: true,
eraser: false,
custom: true,
};
export type RestoredDataState = {
@ -198,6 +199,10 @@ const restoreElement = (
y,
});
}
case "custom":
return restoreElementWithProperties(element, {
customType: element.customType || "custom",
});
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
@ -255,6 +260,19 @@ export const restoreAppState = (
? localValue
: defaultValue;
}
const activeTool: any = {
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: "selection",
};
if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
if (nextAppState.activeTool.type === "custom") {
activeTool.type = "custom";
activeTool.customType = nextAppState.activeTool.customType ?? "custom";
} else {
activeTool.type = nextAppState.activeTool.type;
}
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
@ -262,13 +280,7 @@ export const restoreAppState = (
penDetected:
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool: {
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
? nextAppState.activeTool.type ?? "selection"
: "selection",
},
activeTool,
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"

View File

@ -25,6 +25,7 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawCustomElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@ -32,13 +33,20 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { hasBoundTextElement, isImageElement } from "./typeChecks";
import {
hasBoundTextElement,
isCustomElement,
isImageElement,
} from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
): boolean => {
if (isCustomElement(element)) {
return true;
}
if (element.type === "arrow") {
return false;
}
@ -166,6 +174,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "text":
case "diamond":
case "ellipse":
case "custom":
const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold);
case "freedraw": {
@ -199,6 +208,7 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "custom":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
@ -228,7 +238,8 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement,
| ExcalidrawImageElement
| ExcalidrawCustomElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -504,6 +515,7 @@ export const determineFocusDistance = (
case "rectangle":
case "image":
case "text":
case "custom":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@ -536,6 +548,7 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "custom":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
case "ellipse":
@ -586,6 +599,7 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "custom":
const corners = getCorners(element);
intersections = corners
.flatMap((point, i) => {
@ -619,7 +633,8 @@ const getCorners = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
| ExcalidrawTextElement
| ExcalidrawCustomElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
@ -628,6 +643,7 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "custom":
return [
GA.point(hx, hy),
GA.point(hx, -hy),
@ -770,7 +786,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
| ExcalidrawTextElement
| ExcalidrawCustomElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.
relativeDistance: number,

View File

@ -12,6 +12,7 @@ import {
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawRectangleElement,
ExcalidrawCustomElement,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@ -320,6 +321,17 @@ export const newImageElement = (
};
};
export const newCustomElement = (
customType: string,
opts: {
type: ExcalidrawCustomElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawCustomElement> => {
return {
..._newElementBase<ExcalidrawCustomElement>("custom", opts),
customType,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//

View File

@ -10,6 +10,7 @@ import {
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer,
ExcalidrawCustomElement,
} from "./types";
export const isGenericElement = (
@ -142,3 +143,7 @@ export const isBoundToContainer = (
element !== null && isTextElement(element) && element.containerId !== null
);
};
export const isCustomElement = (
element: ExcalidrawElement,
): element is ExcalidrawCustomElement => element && element.type === "custom";

View File

@ -84,6 +84,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
scale: [number, number];
}>;
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
Readonly<{ type: "custom"; customType: string }>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
@ -108,7 +111,8 @@ export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement;
| ExcalidrawImageElement
| ExcalidrawCustomElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
@ -134,7 +138,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement;
| ExcalidrawImageElement
| ExcalidrawCustomElement;
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement

View File

@ -8,7 +8,6 @@ import initialData from "./initialData";
// This is so that we use the bundled excalidraw.development.js file instead
// of the actual source code
const {
exportToCanvas,
exportToSvg,
@ -16,8 +15,27 @@ const {
exportToClipboard,
Excalidraw,
MIME_TYPES,
sceneCoordsToViewportCoords,
} = window.ExcalidrawLib;
const STAR_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M287.9 0C297.1 0 305.5 5.25 309.5 13.52L378.1 154.8L531.4 177.5C540.4 178.8 547.8 185.1 550.7 193.7C553.5 202.4 551.2 211.9 544.8 218.2L433.6 328.4L459.9 483.9C461.4 492.9 457.7 502.1 450.2 507.4C442.8 512.7 432.1 513.4 424.9 509.1L287.9 435.9L150.1 509.1C142.9 513.4 133.1 512.7 125.6 507.4C118.2 502.1 114.5 492.9 115.1 483.9L142.2 328.4L31.11 218.2C24.65 211.9 22.36 202.4 25.2 193.7C28.03 185.1 35.5 178.8 44.49 177.5L197.7 154.8L266.3 13.52C270.4 5.249 278.7 0 287.9 0L287.9 0zM287.9 78.95L235.4 187.2C231.9 194.3 225.1 199.3 217.3 200.5L98.98 217.9L184.9 303C190.4 308.5 192.9 316.4 191.6 324.1L171.4 443.7L276.6 387.5C283.7 383.7 292.2 383.7 299.2 387.5L404.4 443.7L384.2 324.1C382.9 316.4 385.5 308.5 391 303L476.9 217.9L358.6 200.5C350.7 199.3 343.9 194.3 340.5 187.2L287.9 78.95z" />
</svg>
);
const COMMENT_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M256 32C114.6 32 .0272 125.1 .0272 240c0 47.63 19.91 91.25 52.91 126.2c-14.88 39.5-45.87 72.88-46.37 73.25c-6.625 7-8.375 17.25-4.625 26C5.818 474.2 14.38 480 24 480c61.5 0 109.1-25.75 139.1-46.25C191.1 442.8 223.3 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32zM256.1 400c-26.75 0-53.12-4.125-78.38-12.12l-22.75-7.125l-19.5 13.75c-14.25 10.12-33.88 21.38-57.5 29c7.375-12.12 14.37-25.75 19.88-40.25l10.62-28l-20.62-21.87C69.82 314.1 48.07 282.2 48.07 240c0-88.25 93.25-160 208-160s208 71.75 208 160S370.8 400 256.1 400z" />
</svg>
);
const THUMBS_UP_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M96 191.1H32c-17.67 0-32 14.33-32 31.1v223.1c0 17.67 14.33 31.1 32 31.1h64c17.67 0 32-14.33 32-31.1V223.1C128 206.3 113.7 191.1 96 191.1zM512 227c0-36.89-30.05-66.92-66.97-66.92h-99.86C354.7 135.1 360 113.5 360 100.8c0-33.8-26.2-68.78-70.06-68.78c-46.61 0-59.36 32.44-69.61 58.5c-31.66 80.5-60.33 66.39-60.33 93.47c0 12.84 10.36 23.99 24.02 23.99c5.256 0 10.55-1.721 14.97-5.26c76.76-61.37 57.97-122.7 90.95-122.7c16.08 0 22.06 12.75 22.06 20.79c0 7.404-7.594 39.55-25.55 71.59c-2.046 3.646-3.066 7.686-3.066 11.72c0 13.92 11.43 23.1 24 23.1h137.6C455.5 208.1 464 216.6 464 227c0 9.809-7.766 18.03-17.67 18.71c-12.66 .8593-22.36 11.4-22.36 23.94c0 15.47 11.39 15.95 11.39 28.91c0 25.37-35.03 12.34-35.03 42.15c0 11.22 6.392 13.03 6.392 22.25c0 22.66-29.77 13.76-29.77 40.64c0 4.515 1.11 5.961 1.11 9.456c0 10.45-8.516 18.95-18.97 18.95h-52.53c-25.62 0-51.02-8.466-71.5-23.81l-36.66-27.51c-4.315-3.245-9.37-4.811-14.38-4.811c-13.85 0-24.03 11.38-24.03 24.04c0 7.287 3.312 14.42 9.596 19.13l36.67 27.52C235 468.1 270.6 480 306.6 480h52.53c35.33 0 64.36-27.49 66.8-62.2c17.77-12.23 28.83-32.51 28.83-54.83c0-3.046-.2187-6.107-.6406-9.122c17.84-12.15 29.28-32.58 29.28-55.28c0-5.311-.6406-10.54-1.875-15.64C499.9 270.1 512 250.2 512 227z" />
</svg>
);
const resolvablePromise = () => {
let resolve;
let reject;
@ -53,7 +71,7 @@ const renderFooter = () => {
export default function App() {
const excalidrawRef = useRef(null);
const excalidrawWrapperRef = useRef(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
@ -151,6 +169,135 @@ export default function App() {
}
}, []);
const renderCustomElementWidget = () => {
return (
<>
<button
className="custom-element"
onClick={() => {
excalidrawRef.current.setCustomType("star");
}}
>
{STAR_SVG}
</button>
<button
className="custom-element"
onClick={() => {
excalidrawRef.current.setCustomType("comment");
}}
>
{COMMENT_SVG}
</button>
<button
className="custom-element"
onClick={() => {
excalidrawRef.current.setCustomType("thumbsup");
}}
>
{THUMBS_UP_SVG}
</button>
</>
);
};
const onCreate = (element) => {
setTimeout(() => addTextArea(element), 0);
};
const getCustomElementsConfig = () => {
return {
star: {
type: "custom",
customType: "star",
displayData: {
type: "svg",
content: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M287.9 0C297.1 0 305.5 5.25 309.5 13.52L378.1 154.8L531.4 177.5C540.4 178.8 547.8 185.1 550.7 193.7C553.5 202.4 551.2 211.9 544.8 218.2L433.6 328.4L459.9 483.9C461.4 492.9 457.7 502.1 450.2 507.4C442.8 512.7 432.1 513.4 424.9 509.1L287.9 435.9L150.1 509.1C142.9 513.4 133.1 512.7 125.6 507.4C118.2 502.1 114.5 492.9 115.1 483.9L142.2 328.4L31.11 218.2C24.65 211.9 22.36 202.4 25.2 193.7C28.03 185.1 35.5 178.8 44.49 177.5L197.7 154.8L266.3 13.52C270.4 5.249 278.7 0 287.9 0L287.9 0zM287.9 78.95L235.4 187.2C231.9 194.3 225.1 199.3 217.3 200.5L98.98 217.9L184.9 303C190.4 308.5 192.9 316.4 191.6 324.1L171.4 443.7L276.6 387.5C283.7 383.7 292.2 383.7 299.2 387.5L404.4 443.7L384.2 324.1C382.9 316.4 385.5 308.5 391 303L476.9 217.9L358.6 200.5C350.7 199.3 343.9 194.3 340.5 187.2L287.9 78.95z" />
</svg>`,
},
width: 60,
height: 60,
disableContextMenu: true,
},
comment: {
type: "custom",
customType: "comment",
displayData: {
type: "svg",
content: () =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M256 32C114.6 32 .0272 125.1 .0272 240c0 47.63 19.91 91.25 52.91 126.2c-14.88 39.5-45.87 72.88-46.37 73.25c-6.625 7-8.375 17.25-4.625 26C5.818 474.2 14.38 480 24 480c61.5 0 109.1-25.75 139.1-46.25C191.1 442.8 223.3 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32zM256.1 400c-26.75 0-53.12-4.125-78.38-12.12l-22.75-7.125l-19.5 13.75c-14.25 10.12-33.88 21.38-57.5 29c7.375-12.12 14.37-25.75 19.88-40.25l10.62-28l-20.62-21.87C69.82 314.1 48.07 282.2 48.07 240c0-88.25 93.25-160 208-160s208 71.75 208 160S370.8 400 256.1 400z" />
</svg>`,
},
transformHandles: false,
stackedOnTop: true,
onCreate,
disableContextMenu: true,
},
thumbsup: {
type: "custom",
customType: "thumbsup",
displayData: {
type: "dataURL",
content: () => {
return new Promise((resolve, reject) => {
const image = document.createElement("img");
image.crossOrigin = "Anonymous";
image.src =
"https://upload.wikimedia.org/wikipedia/commons/1/1f/SMirC-thumbsup.svg";
image.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = 30 * window.devicePixelRatio;
canvas.height = 30 * window.devicePixelRatio;
const context = canvas.getContext("2d");
context.scale(window.devicePixelRatio, window.devicePixelRatio);
context.drawImage(image, 5, 5, 20, 20);
resolve(canvas.toDataURL());
};
image.onerror = (err) => reject(err);
});
},
},
},
};
};
const addTextArea = (element) => {
const { x: viewPortX, y: viewPortY } = sceneCoordsToViewportCoords(
{
sceneX: element.x,
sceneY: element.y,
},
excalidrawRef.current.getAppState(),
);
const textarea = document.createElement("textarea");
Object.assign(textarea.style, {
position: "absolute",
display: "inline-block",
left: `${viewPortX + element.width / 2}px`,
top: `${viewPortY + element.height / 2}px`,
height: `${100}px`,
width: `${100}px`,
zIndex: 10,
className: "comment-textarea",
whiteSpace: "pre-wrap",
fontSize: "13px",
});
textarea.placeholder = "Start typing your comments";
textarea.onblur = () => {
textarea.remove();
};
excalidrawWrapperRef.current.querySelector(".excalidraw").append(textarea);
textarea.focus();
};
const onElementClick = (element) => {
if (element.type === "custom" && element.customType === "comment") {
addTextArea(element);
}
};
const onCopy = async (type) => {
await exportToClipboard({
elements: excalidrawRef.current.getSceneElements(),
@ -160,6 +307,7 @@ export default function App() {
});
window.alert(`Copied to clipboard as ${type} sucessfully`);
};
return (
<div className="App">
<h1> Excalidraw Example</h1>
@ -275,14 +423,14 @@ export default function App() {
</button>
</div>
</div>
<div className="excalidraw-wrapper">
<div className="excalidraw-wrapper" ref={excalidrawWrapperRef}>
<Excalidraw
ref={excalidrawRef}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) =>
console.info("Elements :", elements, "State : ", state)
}
onPointerUpdate={(payload) => console.info(payload)}
//onPointerUpdate={(payload) => console.info(payload)}
onCollabButtonClick={() =>
window.alert("You clicked on collab button")
}
@ -295,6 +443,9 @@ export default function App() {
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
onLinkOpen={onLinkOpen}
renderCustomElementWidget={renderCustomElementWidget}
customElementsConfig={getCustomElementsConfig()}
onElementClick={onElementClick}
/>
</div>

View File

@ -6,7 +6,7 @@
.button-wrapper button {
z-index: 1;
height: 40px;
max-width: 200px;
max-width: 250px;
margin: 10px;
padding: 5px;
}
@ -16,7 +16,7 @@
}
.excalidraw-wrapper {
height: 800px;
height: 600px;
margin: 50px;
}
@ -47,3 +47,8 @@
--color-primary-darkest: #e64980;
--color-primary-light: #fcc2d7;
}
.custom-element {
width: 2rem;
height: 2rem;
}

View File

@ -69,6 +69,32 @@ export default {
status: "pending",
scale: [1, 1],
},
{
id: "z35XgE9DvTXlG1OzXmp2x",
type: "custom",
x: 147.13928993437958,
y: 328.8974609375,
width: 60,
height: 60,
angle: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
strokeSharpness: "round",
seed: 1483808630,
version: 79,
versionNonce: 861014250,
isDeleted: false,
boundElements: null,
updated: 1648630123004,
link: null,
customType: "star",
},
],
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
scrollToContent: true,

View File

@ -8,7 +8,10 @@ import "../../css/styles.scss";
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { defaultLang } from "../../i18n";
import { DEFAULT_UI_OPTIONS } from "../../constants";
import {
DEFAULT_UI_OPTIONS,
DEFAULT_CUSTOM_ELEMENT_CONFIG,
} from "../../constants";
import { Provider } from "jotai";
import { jotaiScope, jotaiStore } from "../../jotai";
@ -37,6 +40,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
autoFocus = false,
generateIdForFile,
onLinkOpen,
renderCustomElementWidget,
onElementClick,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@ -47,6 +52,10 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
...canvasActions,
},
};
const customElementsConfig = {} as AppProps["customElementsConfig"];
Object.entries(props.customElementsConfig || {}).forEach(([key, value]) => {
customElementsConfig![key] = { ...DEFAULT_CUSTOM_ELEMENT_CONFIG, ...value };
});
if (canvasActions?.export) {
UIOptions.canvasActions.export.saveFileToDisk =
@ -100,6 +109,9 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen}
renderCustomElementWidget={renderCustomElementWidget}
customElementsConfig={customElementsConfig}
onElementClick={onElementClick}
/>
</Provider>
</InitializeApp>
@ -209,3 +221,5 @@ export {
newElementWith,
bumpVersion,
} from "../../element/mutateElement";
export { sceneCoordsToViewportCoords } from "../../utils";

View File

@ -9,7 +9,7 @@ const devServerConfig = {
},
// Server Configuration options
devServer: {
port: 3001,
//port: 3001,
host: "localhost",
hot: true,
compress: true,

View File

@ -6,6 +6,7 @@ import {
NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawCustomElement,
} from "../element/types";
import {
isTextElement,
@ -189,6 +190,9 @@ const drawImagePlaceholder = (
);
};
const customElementImgCache: {
[key: ExcalidrawCustomElement["customType"]]: HTMLImageElement;
} = {};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
@ -250,6 +254,54 @@ const drawElementOnCanvas = (
}
break;
}
case "custom": {
const config = renderConfig.customElementsConfig?.[element.customType];
if (!config) {
break;
}
const cacheImage = (data: string, type: "svg" | "dataURL") => {
if (!customElementImgCache[element.id]) {
let url: string;
if (type === "svg") {
url = `data:${MIME_TYPES.svg}, ${encodeURIComponent(data)}`;
} else {
url = data;
}
const img = document.createElement("img");
img.src = url;
img.id = element.id;
customElementImgCache[element.id] = img;
}
};
const { type, content } = config.displayData;
if (typeof content === "string") {
cacheImage(content, type);
} else {
const contentData = content(element);
if (contentData instanceof Promise) {
contentData.then(
(res) => {
cacheImage(res, type);
},
(err) => console.error(err),
);
} else {
cacheImage(contentData, type);
}
}
if (customElementImgCache[element.id]) {
context.drawImage(
customElementImgCache[element.id],
0,
0,
element.width,
element.height,
);
}
break;
}
default: {
if (isTextElement(element)) {
const rtl = isRTL(element.text);
@ -779,7 +831,8 @@ export const renderElement = (
case "line":
case "arrow":
case "image":
case "text": {
case "text":
case "custom": {
generateElementShape(element, generator);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -809,6 +862,7 @@ export const renderElement = (
}
break;
}
default: {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);

View File

@ -190,7 +190,6 @@ export const renderScene = (
if (canvas === null) {
return { atLeastOneVisibleElement: false };
}
const {
renderScrollbars = true,
renderSelection = true,
@ -305,24 +304,32 @@ export const renderScene = (
!appState.editingLinearElement
) {
const selections = elements.reduce((acc, element) => {
const isCustom = element.type === "custom";
let config;
const selectionColors = [];
// local user
if (
appState.selectedElementIds[element.id] &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(oc.black);
if (element.type === "custom") {
config = renderConfig.customElementsConfig?.[element.customType];
}
// remote users
if (renderConfig.remoteSelectedElementIds[element.id]) {
selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map(
(socketId) => {
const { background } = getClientColors(socketId, appState);
return background;
},
),
);
if (!isCustom || (isCustom && config && config.transformHandles)) {
// local user
if (
appState.selectedElementIds[element.id] &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(oc.black);
}
// remote users
if (renderConfig.remoteSelectedElementIds[element.id]) {
selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map(
(socketId) => {
const { background } = getClientColors(socketId, appState);
return background;
},
),
);
}
}
if (selectionColors.length) {
const [elementX1, elementY1, elementX2, elementY2] =
@ -352,7 +359,6 @@ export const renderScene = (
selectionColors: [oc.black],
});
};
for (const groupId of getSelectedGroupIds(appState)) {
// TODO: support multiplayer selected group IDs
addSelectionForGroupId(groupId);
@ -372,19 +378,33 @@ export const renderScene = (
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
if (locallySelectedElements.length === 1) {
context.fillStyle = oc.white;
const transformHandles = getTransformHandles(
locallySelectedElements[0],
renderConfig.zoom,
"mouse", // when we render we don't know which pointer type so use mouse
);
if (!appState.viewModeEnabled) {
renderTransformHandles(
context,
renderConfig,
transformHandles,
locallySelectedElements[0].angle,
let showTransformHandles = true;
if (locallySelectedElements[0].type === "custom") {
const config =
renderConfig.customElementsConfig?.[
locallySelectedElements[0].customType
];
if (!config || !config.transformHandles) {
showTransformHandles = false;
}
}
if (showTransformHandles) {
context.fillStyle = oc.white;
const transformHandles = getTransformHandles(
locallySelectedElements[0],
renderConfig.zoom,
"mouse", // when we render we don't know which pointer type so use mouse
);
if (!appState.viewModeEnabled) {
renderTransformHandles(
context,
renderConfig,
transformHandles,
locallySelectedElements[0].angle,
);
}
}
} else if (locallySelectedElements.length > 1 && !appState.isRotating) {
const dashedLinePadding = 4 / renderConfig.zoom.value;
@ -573,6 +593,7 @@ const renderTransformHandles = (
renderConfig: RenderConfig,
transformHandles: TransformHandles,
angle: number,
name?: string,
): void => {
Object.keys(transformHandles).forEach((key) => {
const transformHandle = transformHandles[key as TransformHandleType];

View File

@ -5,6 +5,8 @@ import {
} from "../element/types";
import { getNonDeletedElements, isNonDeletedElement } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import App from "../components/App";
import { isCustomElement } from "../element/typeChecks";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@ -26,7 +28,11 @@ class Scene {
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
private app: App;
constructor(app: App) {
this.app = app;
}
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
this.sceneMapById.set(elementKey, scene);
@ -91,12 +97,28 @@ class Scene {
}
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this.elements = nextElements;
this.elements = [];
const elements: ExcalidrawElement[] = [];
this.elementsMap.clear();
const elementsToBeStackedOnTop: ExcalidrawElement[] = [];
nextElements.forEach((element) => {
if (isCustomElement(element)) {
const config =
this.app.props.customElementsConfig?.[element.customType];
if (config?.stackedOnTop) {
elementsToBeStackedOnTop.push(element);
} else {
elements.push(element);
}
} else {
elements.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
});
elementsToBeStackedOnTop.forEach((ele) => elements.push(ele));
this.elements = elements;
this.nonDeletedElements = getNonDeletedElements(this.elements);
this.informMutation();
}

View File

@ -6,6 +6,7 @@ import {
import { getElementAbsoluteCoords } from "../element";
import { isTextBindableContainer } from "../element/typeChecks";
import { AppState } from "../types";
export const hasBackground = (type: string) =>
type === "rectangle" ||
@ -31,7 +32,7 @@ export const hasStrokeStyle = (type: string) =>
type === "arrow" ||
type === "line";
export const canChangeSharpness = (type: string) =>
export const canChangeSharpness = (type: AppState["activeTool"]["type"]) =>
type === "rectangle" ||
type === "arrow" ||
type === "line" ||

View File

@ -3,20 +3,25 @@ import {
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types";
import { AppProps, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
customElementConfig: AppProps["customElementsConfig"],
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection);
return elements.filter((element) => {
const [elementX1, elementY1, elementX2, elementY2] =
getElementBounds(element);
const isCustom = element.type === "custom";
const allowSelection = isCustom
? customElementConfig?.[element.customType]?.transformHandles
: true;
return (
allowSelection &&
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&

View File

@ -1,5 +1,5 @@
import { ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { AppClassProperties, AppProps, AppState } from "../types";
export type RenderConfig = {
// AppState values
@ -27,6 +27,7 @@ export type RenderConfig = {
/** when exporting the behavior is slightly different (e.g. we can't use
CSS filters), and we disable render optimizations for best output */
isExporting: boolean;
customElementsConfig?: AppProps["customElementsConfig"];
};
export type SceneScroll = {

View File

@ -66,6 +66,14 @@ export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type LastActiveToolBeforeEraser =
| typeof SHAPES[number]["value"]
| {
type: "custom";
customType: string;
}
| null;
export type AppState = {
isLoading: boolean;
errorMessage: string | null;
@ -80,11 +88,18 @@ export type AppState = {
// (e.g. text element when typing into the input)
editingElement: NonDeletedExcalidrawElement | null;
editingLinearElement: LinearElementEditor | null;
activeTool: {
type: typeof SHAPES[number]["value"] | "eraser";
lastActiveToolBeforeEraser: typeof SHAPES[number]["value"] | null;
locked: boolean;
};
activeTool:
| {
type: typeof SHAPES[number]["value"] | "eraser";
lastActiveToolBeforeEraser: LastActiveToolBeforeEraser;
locked: boolean;
}
| {
type: "custom";
customType: string;
lastActiveToolBeforeEraser: LastActiveToolBeforeEraser;
locked: boolean;
};
penMode: boolean;
penDetected: boolean;
exportBackground: boolean;
@ -212,6 +227,22 @@ export type ExcalidrawAPIRefValue =
ready?: false;
};
export type CustomElementConfig = {
type: "custom";
customType: string;
transformHandles?: boolean;
displayData: {
type: "svg" | "dataURL";
content:
| string
| ((element?: ExcalidrawElement) => string | Promise<string>);
};
width?: number;
height?: number;
stackedOnTop: boolean;
onCreate?: (element: ExcalidrawElement) => void;
disableContextMenu: boolean;
};
export type ExcalidrawInitialDataState = Merge<
ImportedDataState,
{
@ -271,6 +302,12 @@ export interface ExcalidrawProps {
nativeEvent: MouseEvent | React.PointerEvent<HTMLCanvasElement>;
}>,
) => void;
renderCustomElementWidget?: (appState: AppState) => void;
customElementsConfig?: Record<string, CustomElementConfig>;
onElementClick?: (
element: NonDeleted<ExcalidrawElement>,
event: React.PointerEvent<HTMLCanvasElement>,
) => void;
}
export type SceneData = {
@ -324,6 +361,7 @@ export type AppProps = ExcalidrawProps & {
detectScroll: boolean;
handleKeyboardGlobally: boolean;
isCollaborating: boolean;
customElementsConfig: Required<CustomElementConfig>[] | undefined;
};
/** A subset of App class properties that we need to use elsewhere
@ -431,6 +469,7 @@ export type ExcalidrawImperativeAPI = {
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
id: string;
setCustomType: InstanceType<typeof App>["setCustomType"];
};
export type DeviceType = {