feats: 为 snow-shot 提供自定义功能

This commit is contained in:
chao 2025-04-18 01:07:56 +08:00
parent 58f7d33d80
commit 27e0350fa7
21 changed files with 940 additions and 478 deletions

View file

@ -848,6 +848,13 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true}
autoFocus={true}
theme={editorTheme}
customOptions={{
disableKeyEvents: true,
// hideMainToolbar: true,
// hideMenu: true,
// hideFooter: true,
hideContextMenu: true,
}}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;

View file

@ -78,8 +78,8 @@
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"rm:build": "rimraf -rfexcalidraw-app/{build,dist,dev-dist} && rimraf -rfpackages/*/{dist,build} && rimraf -rfexamples/*/{build,dist}",
"rm:node_modules": "rimraf -rfnode_modules && rimraf -rfexcalidraw-app/node_modules && rimraf -rfpackages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {

View file

@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf -rf types && tsc",
"build:esm": "rimraf -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View file

@ -1,7 +1,10 @@
import type { KeyboardModifiersObject } from "@excalidraw/excalidraw/types";
import { isDarwin } from "./constants";
import type { ValueOf } from "./utility-types";
export const CODES = {
EQUAL: "Equal",
MINUS: "Minus",
@ -140,12 +143,60 @@ export const isArrowKey = (key: string) =>
key === KEYS.ARROW_DOWN ||
key === KEYS.ARROW_UP;
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
const shouldResizeFromCenterDefault = (event: MouseEvent | KeyboardEvent) =>
event.altKey;
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
const shouldMaintainAspectRatioDefault = (event: MouseEvent | KeyboardEvent) =>
event.shiftKey;
const shouldRotateWithDiscreteAngleDefault = (
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
) => event.shiftKey;
const shouldSnappingDefault = (event: KeyboardModifiersObject) =>
event[KEYS.CTRL_OR_CMD];
let shouldResizeFromCenterFunction = shouldResizeFromCenterDefault;
let shouldMaintainAspectRatioFunction = shouldMaintainAspectRatioDefault;
let shouldRotateWithDiscreteAngleFunction =
shouldRotateWithDiscreteAngleDefault;
let shouldSnappingFunction = shouldSnappingDefault;
export const setShouldResizeFromCenter = (
shouldResizeFromCenter: (event: MouseEvent | KeyboardEvent) => boolean,
) => {
shouldResizeFromCenterFunction = shouldResizeFromCenter;
};
export const setShouldMaintainAspectRatio = (
shouldMaintainAspectRatio: (event: MouseEvent | KeyboardEvent) => boolean,
) => {
shouldMaintainAspectRatioFunction = shouldMaintainAspectRatio;
};
export const setShouldRotateWithDiscreteAngle = (
shouldRotateWithDiscreteAngle: (
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
) => boolean,
) => {
shouldRotateWithDiscreteAngleFunction = shouldRotateWithDiscreteAngle;
};
export const setShouldSnapping = (
shouldSnapping: (event: KeyboardModifiersObject) => boolean,
) => {
shouldSnappingFunction = shouldSnapping;
};
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
shouldResizeFromCenterFunction(event);
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
shouldMaintainAspectRatioFunction(event);
export const shouldRotateWithDiscreteAngle = (
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
) => event.shiftKey;
) => shouldRotateWithDiscreteAngleFunction(event);
export const shouldSnapping = (event: KeyboardModifiersObject) =>
shouldSnappingFunction(event);

View file

@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf -rf types && tsc",
"build:esm": "rimraf -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View file

@ -1,5 +1,5 @@
import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@ -137,7 +137,12 @@ import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { CaptureUpdateActionType } from "../store";
import type { AppClassProperties, AppState, Primitive } from "../types";
import {
ExcalidrawPropsCustomOptionsContext,
type AppClassProperties,
type AppState,
type Primitive,
} from "../types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -322,28 +327,35 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
elements,
appState,
(element) => element.strokeColor,
true,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
),
PanelComponent: ({ elements, appState, updateData, appProps }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
topPicks={
customOptions?.pickerRenders?.elementStrokeColors ??
DEFAULT_ELEMENT_STROKE_PICKS
}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
elements,
appState,
(element) => element.strokeColor,
true,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
});
export const actionChangeBackgroundColor = register({
@ -368,28 +380,37 @@ export const actionChangeBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
<>
<h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
type="elementBackground"
label={t("labels.background")}
color={getFormValue(
elements,
appState,
(element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
),
PanelComponent: ({ elements, appState, updateData, appProps }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<>
<h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker
topPicks={
customOptions?.pickerRenders?.elementBackgroundColors ??
DEFAULT_ELEMENT_BACKGROUND_PICKS
}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
type="elementBackground"
label={t("labels.background")}
color={getFormValue(
elements,
appState,
(element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor,
)}
onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
});
export const actionChangeFillStyle = register({

View file

@ -7,6 +7,8 @@ import {
moveAllRight,
} from "@excalidraw/element/zindex";
import { useContext } from "react";
import {
BringForwardIcon,
BringToFrontIcon,
@ -16,6 +18,8 @@ import {
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register";
export const actionSendBackward = register({
@ -36,16 +40,29 @@ export const actionSendBackward = register({
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
>
{SendBackwardIcon}
</button>
),
PanelComponent: ({ updateData, appState }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.sendBackward")}`,
children: SendBackwardIcon,
name: "sendBackward",
});
}
return (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
>
{SendBackwardIcon}
</button>
);
},
});
export const actionBringForward = register({
@ -66,16 +83,29 @@ export const actionBringForward = register({
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
>
{BringForwardIcon}
</button>
),
PanelComponent: ({ updateData, appState }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.bringForward")}`,
children: BringForwardIcon,
name: "bringForward",
});
}
return (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
>
{BringForwardIcon}
</button>
);
},
});
export const actionSendToBack = register({
@ -99,20 +129,33 @@ export const actionSendToBack = register({
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendToBack")}${
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+[")
}`}
>
{SendToBackIcon}
</button>
),
PanelComponent: ({ updateData, appState }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.sendToBack")}`,
children: SendToBackIcon,
name: "sendToBack",
});
}
return (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendToBack")}${
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+[")
}`}
>
{SendToBackIcon}
</button>
);
},
});
export const actionBringToFront = register({
@ -137,18 +180,31 @@ export const actionBringToFront = register({
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={(event) => updateData(null)}
title={`${t("labels.bringToFront")}${
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]")
}`}
>
{BringToFrontIcon}
</button>
),
PanelComponent: ({ updateData, appState }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.bringToFront")}`,
children: BringToFrontIcon,
name: "bringToFront",
});
}
return (
<button
type="button"
className="zIndexButton"
onClick={(event) => updateData(null)}
title={`${t("labels.bringToFront")}${
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]")
}`}
>
{BringToFrontIcon}
</button>
);
},
});

View file

@ -160,7 +160,6 @@ export class ActionManager {
const appState = this.getAppState();
const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater(
action.perform(
this.getElementsIncludingDeleted(),

View file

@ -1,5 +1,5 @@
import clsx from "clsx";
import { useState } from "react";
import { useState, useContext } from "react";
import {
CLASSES,
@ -46,6 +46,8 @@ import {
hasStrokeWidth,
} from "../scene";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { SHAPES } from "./shapes";
import "./Actions.scss";
@ -112,6 +114,8 @@ export const SelectedShapeActions = ({
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
const targetElements = getTargetElements(elementsMap, appState);
let isSingleElementBoundContainer = false;
@ -212,12 +216,22 @@ export const SelectedShapeActions = ({
<fieldset>
<legend>{t("labels.layers")}</legend>
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
{!customOptions?.pickerRenders?.ButtonList && (
<div className={"buttonList"}>
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
)}
{customOptions?.pickerRenders?.ButtonList && (
<customOptions.pickerRenders.ButtonList>
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</customOptions.pickerRenders.ButtonList>
)}
</fieldset>
{showAlignActions && !isSingleElementBoundContainer && (

View file

@ -100,6 +100,10 @@ import {
arrayToMap,
type EXPORT_IMAGE_TYPES,
randomInteger,
setShouldResizeFromCenter,
setShouldMaintainAspectRatio,
setShouldRotateWithDiscreteAngle,
setShouldSnapping,
} from "@excalidraw/common";
import {
@ -810,6 +814,26 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
// 初始化一些配置
if (this.props.customOptions?.shouldResizeFromCenter) {
setShouldResizeFromCenter(
this.props.customOptions.shouldResizeFromCenter,
);
}
if (this.props.customOptions?.shouldMaintainAspectRatio) {
setShouldMaintainAspectRatio(
this.props.customOptions.shouldMaintainAspectRatio,
);
}
if (this.props.customOptions?.shouldRotateWithDiscreteAngle) {
setShouldRotateWithDiscreteAngle(
this.props.customOptions.shouldRotateWithDiscreteAngle,
);
}
if (this.props.customOptions?.shouldSnapping) {
setShouldSnapping(this.props.customOptions.shouldSnapping);
}
}
private onWindowMessage(event: MessageEvent) {
@ -1648,209 +1672,215 @@ class App extends React.Component<AppProps, AppState> {
generateLinkForSelection={
this.props.generateLinkForSelection
}
customOptions={this.props.customOptions}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[
this.laserTrails,
this.lassoTrail,
this.eraserTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={firstSelectedElement.id}
element={firstSelectedElement}
elementsMap={allElementsMap}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
updateEmbedValidationStatus={
this.updateEmbedValidationStatus
}
<div className="excalidraw-container-inner">
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[
this.laserTrails,
this.lassoTrail,
this.eraserTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={firstSelectedElement.id}
element={firstSelectedElement}
elementsMap={allElementsMap}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
updateEmbedValidationStatus={
this.updateEmbedValidationStatus
}
/>
)}
{this.props.aiEnabled !== false &&
selectedElements.length === 1 &&
isMagicFrameElement(firstSelectedElement) && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.convertToCode")}
icon={MagicIcon}
checked={false}
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
</ElementCanvasButtons>
)}
{selectedElements.length === 1 &&
isIframeElement(firstSelectedElement) &&
firstSelectedElement.customData?.generationData
?.status === "done" && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.copySource")}
icon={copyIcon}
checked={false}
onChange={() =>
this.onIframeSrcCopy(firstSelectedElement)
}
/>
<ElementCanvasButton
title="Enter fullscreen"
icon={fullscreenIcon}
checked={false}
onChange={() => {
const iframe =
this.getHTMLIFrameElement(
firstSelectedElement,
);
if (iframe) {
try {
iframe.requestFullscreen();
this.setState({
activeEmbeddable: {
element: firstSelectedElement,
state: "active",
},
selectedElementIds: {
[firstSelectedElement.id]: true,
},
newElement: null,
selectionElement: null,
});
} catch (err: any) {
console.warn(err);
this.setState({
errorMessage:
"Couldn't enter fullscreen",
});
}
}
}}
/>
</ElementCanvasButtons>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.props.aiEnabled !== false &&
selectedElements.length === 1 &&
isMagicFrameElement(firstSelectedElement) && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.convertToCode")}
icon={MagicIcon}
checked={false}
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
</ElementCanvasButtons>
)}
{selectedElements.length === 1 &&
isIframeElement(firstSelectedElement) &&
firstSelectedElement.customData?.generationData
?.status === "done" && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.copySource")}
icon={copyIcon}
checked={false}
onChange={() =>
this.onIframeSrcCopy(firstSelectedElement)
}
/>
<ElementCanvasButton
title="Enter fullscreen"
icon={fullscreenIcon}
checked={false}
onChange={() => {
const iframe =
this.getHTMLIFrameElement(
firstSelectedElement,
);
if (iframe) {
try {
iframe.requestFullscreen();
this.setState({
activeEmbeddable: {
element: firstSelectedElement,
state: "active",
},
selectedElementIds: {
[firstSelectedElement.id]: true,
},
newElement: null,
selectionElement: null,
});
} catch (err: any) {
console.warn(err);
this.setState({
errorMessage:
"Couldn't enter fullscreen",
});
}
}
{this.state.contextMenu &&
!this.props.customOptions?.hideContextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/>
</ElementCanvasButtons>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/>
)}
<StaticCanvas
canvas={this.canvas}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: isGridModeEnabled(this),
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
}}
/>
{this.state.newElement && (
<NewElementCanvas
appState={this.state}
scale={window.devicePixelRatio}
)}
<StaticCanvas
canvas={this.canvas}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: false,
renderGrid: isGridModeEnabled(this),
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus:
this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
}}
/>
)}
<InteractiveCanvas
containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
device={this.device}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/>
{this.state.userToFollow && (
<FollowMode
width={this.state.width}
height={this.state.height}
userToFollow={this.state.userToFollow}
onDisconnect={this.maybeUnfollowRemoteUser}
{this.state.newElement && (
<NewElementCanvas
appState={this.state}
scale={window.devicePixelRatio}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: false,
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus:
this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
}}
/>
)}
<InteractiveCanvas
containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
device={this.device}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/>
)}
{this.renderFrameNames()}
{this.state.userToFollow && (
<FollowMode
width={this.state.width}
height={this.state.height}
userToFollow={this.state.userToFollow}
onDisconnect={this.maybeUnfollowRemoteUser}
/>
)}
{this.renderFrameNames()}
</div>
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
@ -2242,7 +2272,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevAppState) => {
const actionAppState = actionResult.appState || {};
return {
const res = {
...prevAppState,
...actionAppState,
// NOTE this will prevent opening context menu using an action
@ -2256,6 +2286,21 @@ class App extends React.Component<AppProps, AppState> {
name,
errorMessage,
};
// Print differences between prevAppState and res
const differences = Object.keys(res).filter((key) => {
return (prevAppState as any)[key] !== (res as any)[key];
});
console.log(
"State differences:",
differences.map((key) => ({
key,
prev: (prevAppState as any)[key],
new: (res as any)[key],
})),
);
return res;
});
didUpdate = true;
@ -2964,6 +3009,10 @@ class App extends React.Component<AppProps, AppState> {
// Copy/paste
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
if (this.props.customOptions?.disableKeyEvents) {
return;
}
const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
document.activeElement,
);
@ -4089,6 +4138,10 @@ class App extends React.Component<AppProps, AppState> {
// Input handling
private onKeyDown = withBatchedUpdates(
(event: React.KeyboardEvent | KeyboardEvent) => {
if (this.props.customOptions?.disableKeyEvents) {
return;
}
// normalize `event.key` when CapsLock is pressed #2372
if (
@ -5958,7 +6011,12 @@ class App extends React.Component<AppProps, AppState> {
let dxFromLastCommitted = gridX - rx - lastCommittedX;
let dyFromLastCommitted = gridY - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) {
if (
(
this.props.customOptions?.shouldRotateWithDiscreteAngle ??
shouldRotateWithDiscreteAngle
)(event)
) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
// actual coordinate of the last committed point
@ -8560,7 +8618,13 @@ class App extends React.Component<AppProps, AppState> {
let dx = gridX - newElement.x;
let dy = gridY - newElement.y;
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
if (
(
this.props.customOptions?.shouldRotateWithDiscreteAngle ??
shouldRotateWithDiscreteAngle
)(event) &&
points.length === 2
) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x,
newElement.y,
@ -10451,7 +10515,7 @@ class App extends React.Component<AppProps, AppState> {
width: distance(pointerDownState.origin.x, pointerCoords.x),
height: distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: false,
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
informMutation,
});
@ -10744,11 +10808,16 @@ class App extends React.Component<AppProps, AppState> {
selectedElements,
this.scene.getElementsMapIncludingDeleted(),
this.scene,
shouldRotateWithDiscreteAngle(event),
(
this.props.customOptions?.shouldRotateWithDiscreteAngle ??
shouldRotateWithDiscreteAngle
)(event),
shouldResizeFromCenter(event),
selectedElements.some((element) => isImageElement(element))
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
selectedElements.some((element) =>
isImageElement(element)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
),
resizeX,
resizeY,
pointerDownState.resize.center.x,

View file

@ -1,37 +1,53 @@
import clsx from "clsx";
import { useContext, type JSX } from "react";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { ButtonIcon } from "./ButtonIcon";
import type { JSX } from "react";
export type ButtonIconSelectProps<T> = {
options: {
value: T;
text: string;
icon: JSX.Element;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
}[];
value: T | null;
type?: "radio" | "button";
} & (
| { type?: "radio"; group: string; onChange: (value: T) => void }
| {
type: "button";
onClick: (
value: T,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
);
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
props: {
options: {
value: T;
text: string;
icon: JSX.Element;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
}[];
value: T | null;
type?: "radio" | "button";
} & (
| { type?: "radio"; group: string; onChange: (value: T) => void }
| {
type: "button";
onClick: (
value: T,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
),
) => (
<div className="buttonList">
{props.options.map((option) =>
props.type === "button" ? (
<ButtonIcon
props: ButtonIconSelectProps<T>,
) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.buttonIconSelectRender) {
return customOptions.pickerRenders.buttonIconSelectRender(props);
}
const renderButtonIcon = (
option: ButtonIconSelectProps<T>["options"][number],
) => {
if (props.type !== "button") {
return null;
}
if (customOptions?.pickerRenders?.CustomButtonIcon) {
return (
<customOptions.pickerRenders.CustomButtonIcon
key={option.text}
icon={option.icon}
title={option.text}
@ -39,22 +55,66 @@ export const ButtonIconSelect = <T extends Object>(
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
) : (
<label
key={option.text}
className={clsx({ active: props.value === option.value })}
title={option.text}
>
<input
type="radio"
name={props.group}
onChange={() => props.onChange(option.value)}
checked={props.value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
),
)}
</div>
);
);
}
return (
<ButtonIcon
key={option.text}
icon={option.icon}
title={option.text}
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
);
};
const renderRadioButtonIcon = (
option: ButtonIconSelectProps<T>["options"][number],
) => {
if (props.type === "button") {
return null;
}
if (customOptions?.pickerRenders?.buttonIconSelectRadioRender) {
return customOptions.pickerRenders.buttonIconSelectRadioRender({
key: option.text,
active: props.value === option.value,
title: option.text,
name: props.group,
onChange: () => props.onChange(option.value),
checked: props.value === option.value,
dataTestid: option.testId ?? "",
children: option.icon,
value: option.value,
});
}
return (
<label
key={option.text}
className={clsx({ active: props.value === option.value })}
title={option.text}
>
<input
type="radio"
name={props.group}
onChange={() => props.onChange(option.value)}
checked={props.value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
);
};
return (
<div className="buttonList">
{props.options.map((option) =>
props.type === "button"
? renderButtonIcon(option)
: renderRadioButtonIcon(option),
)}
</div>
);
};

View file

@ -1,6 +1,6 @@
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import { useRef } from "react";
import { useContext, useRef } from "react";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
@ -19,6 +19,11 @@ import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import {
ExcalidrawPropsCustomOptionsContext,
type AppState,
} from "../../types";
import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading";
@ -29,8 +34,6 @@ import "./ColorPicker.scss";
import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState } from "../../types";
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
@ -220,6 +223,46 @@ export const ColorPicker = ({
updateData,
appState,
}: ColorPickerProps) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
const renderPopover = () => {
if (customOptions?.pickerRenders?.colorPickerPopoverRender) {
return customOptions.pickerRenders.colorPickerPopoverRender({
color,
label,
type,
onChange,
elements,
palette,
updateData,
});
}
return (
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
updateData({ openPopup: open ? type : null });
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger color={color} label={label} type={type} />
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
type={type}
color={color}
onChange={onChange}
label={label}
elements={elements}
palette={palette}
updateData={updateData}
/>
)}
</Popover.Root>
);
};
return (
<div>
<div role="dialog" aria-modal="true" className="color-picker-container">
@ -230,27 +273,7 @@ export const ColorPicker = ({
topPicks={topPicks}
/>
<ButtonSeparator />
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
updateData({ openPopup: open ? type : null });
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger color={color} label={label} type={type} />
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
type={type}
color={color}
onChange={onChange}
label={label}
elements={elements}
palette={palette}
updateData={updateData}
/>
)}
</Popover.Root>
{renderPopover()}
</div>
</div>
);

View file

@ -7,10 +7,13 @@ import {
DEFAULT_ELEMENT_STROKE_PICKS,
} from "@excalidraw/common";
import { useContext } from "react";
import { ExcalidrawPropsCustomOptionsContext } from "@excalidraw/excalidraw/types";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps {
onChange: (color: string) => void;
type: ColorPickerType;
@ -24,13 +27,19 @@ export const TopPicks = ({
activeColor,
topPicks,
}: TopPicksProps) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
let colors;
if (type === "elementStroke") {
colors = DEFAULT_ELEMENT_STROKE_PICKS;
colors =
customOptions?.pickerRenders?.elementStrokeColors ??
DEFAULT_ELEMENT_STROKE_PICKS;
}
if (type === "elementBackground") {
colors = DEFAULT_ELEMENT_BACKGROUND_PICKS;
colors =
customOptions?.pickerRenders?.elementBackgroundColors ??
DEFAULT_ELEMENT_BACKGROUND_PICKS;
}
if (type === "canvasBackground") {
@ -49,26 +58,41 @@ export const TopPicks = ({
return (
<div className="color-picker__top-picks">
{colors.map((color: string) => (
<button
className={clsx("color-picker__button", {
{colors.map((color: string) => {
if (customOptions?.pickerRenders?.colorPickerTopPickesButtonRender) {
return customOptions.pickerRenders.colorPickerTopPickesButtonRender({
active: color === activeColor,
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(
color,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
),
})}
style={{ "--swatch-color": color }}
key={color}
type="button"
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
>
<div className="color-picker__button-outline" />
</button>
))}
color,
isTransparent: color === "transparent" || !color,
hasOutline: !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
onClick: () => onChange(color),
dataTestid: `color-top-pick-${color}`,
children: <div className="color-picker__button-outline" />,
key: color,
});
}
return (
<button
className={clsx("color-picker__button", {
active: color === activeColor,
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(
color,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
),
})}
style={{ "--swatch-color": color }}
key={color}
type="button"
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
>
<div className="color-picker__button-outline" />
</button>
);
})}
</div>
);
};

View file

@ -72,6 +72,7 @@ import type {
BinaryFiles,
UIAppState,
AppClassProperties,
ExcalidrawPropsCustomOptions,
} from "../types";
interface LayerUIProps {
@ -95,6 +96,7 @@ interface LayerUIProps {
app: AppClassProperties;
isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
customOptions?: ExcalidrawPropsCustomOptions;
}
const DefaultMainMenu: React.FC<{
@ -153,6 +155,7 @@ const LayerUI = ({
app,
isCollaborating,
generateLinkForSelection,
customOptions,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@ -209,13 +212,8 @@ const LayerUI = ({
</div>
);
const renderSelectedShapeActions = () => (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
const renderSelectedShapeActions = () => {
const children = (
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
@ -232,8 +230,19 @@ const LayerUI = ({
app={app}
/>
</Island>
</Section>
);
);
return (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
{customOptions?.layoutRenders?.menuRender?.({ children }) ?? children}
</Section>
);
};
const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -249,7 +258,10 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<div
className="App-menu App-menu_top"
style={{ display: customOptions?.hideMenu ? "none" : undefined }}
>
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
@ -274,6 +286,11 @@ const LayerUI = ({
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
})}
style={{
display: customOptions?.hideMainToolbar
? "none"
: undefined,
}}
>
<HintViewer
appState={appState}
@ -547,12 +564,14 @@ const LayerUI = ({
>
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()}
<Footer
appState={appState}
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
{!customOptions?.hideFooter && (
<Footer
appState={appState}
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
)}
{appState.scrolledOutside && (
<button
type="button"

View file

@ -1,7 +1,8 @@
import React, { useEffect } from "react";
import React, { useCallback, useContext, useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import "./Range.scss";
@ -40,28 +41,42 @@ export const Range = ({
}
}, [value]);
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<label className="control-label">
{t("labels.opacity")}
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
{customOptions?.pickerRenders?.rangeRender ? (
customOptions?.pickerRenders?.rangeRender({
value,
onChange: (value: number) => {
updateData(value);
},
step: 10,
min: 0,
max: 100,
})
) : (
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
<div className="zero-label">0</div>
</div>
)}
</label>
);
};

View file

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useImperativeHandle, useRef } from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@ -16,7 +16,8 @@ import "./css/app.scss";
import "./css/styles.scss";
import "./fonts/fonts.css";
import type { AppProps, ExcalidrawProps } from "./types";
import { ExcalidrawPropsCustomOptionsContext, type AppProps, type ExcalidrawProps } from "./types";
import type { ActionResult } from "./actions/types";
polyfill();
@ -53,6 +54,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
renderEmbeddable,
aiEnabled,
showDeprecatedFonts,
customOptions,
actionRef,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@ -108,46 +111,61 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
};
}, []);
const appRef = useRef<App>(null);
useImperativeHandle(
actionRef,
() => ({
syncActionResult: (actionResult: ActionResult) => {
appRef.current?.syncActionResult(actionResult);
},
}),
[],
);
return (
<EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}>
<App
onChange={onChange}
initialData={initialData}
excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
renderTopRightUI={renderTopRightUI}
langCode={langCode}
viewModeEnabled={viewModeEnabled}
zenModeEnabled={zenModeEnabled}
gridModeEnabled={gridModeEnabled}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
name={name}
renderCustomStats={renderCustomStats}
UIOptions={UIOptions}
onPaste={onPaste}
detectScroll={detectScroll}
handleKeyboardGlobally={handleKeyboardGlobally}
onLibraryChange={onLibraryChange}
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen}
generateLinkForSelection={generateLinkForSelection}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onScrollChange={onScrollChange}
onDuplicate={onDuplicate}
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts}
>
{children}
</App>
</InitializeApp>
</EditorJotaiProvider>
<ExcalidrawPropsCustomOptionsContext.Provider value={customOptions}>
<EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}>
<App
ref={appRef}
onChange={onChange}
initialData={initialData}
excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
renderTopRightUI={renderTopRightUI}
langCode={langCode}
viewModeEnabled={viewModeEnabled}
zenModeEnabled={zenModeEnabled}
gridModeEnabled={gridModeEnabled}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
name={name}
renderCustomStats={renderCustomStats}
UIOptions={UIOptions}
onPaste={onPaste}
detectScroll={detectScroll}
handleKeyboardGlobally={handleKeyboardGlobally}
onLibraryChange={onLibraryChange}
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen}
generateLinkForSelection={generateLinkForSelection}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onScrollChange={onScrollChange}
onDuplicate={onDuplicate}
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts}
customOptions={customOptions}
>
{children}
</App>
</InitializeApp>
</EditorJotaiProvider>
</ExcalidrawPropsCustomOptionsContext.Provider>
);
};

View file

@ -129,7 +129,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildPackage.js && yarn gen:types"
"gen:types": "rimraf -rf types && tsc",
"build:esm": "rimraf -rf dist && node ../../scripts/buildPackage.js && yarn gen:types"
}
}

View file

@ -7,7 +7,7 @@ import {
type GlobalPoint,
} from "@excalidraw/math";
import { TOOL_TYPE, KEYS } from "@excalidraw/common";
import { TOOL_TYPE, KEYS, shouldSnapping } from "@excalidraw/common";
import {
getCommonBounds,
getDraggedElementsBounds,
@ -173,9 +173,9 @@ export const isSnappingEnabled = ({
}) => {
if (event) {
return (
(app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(app.state.objectsSnapModeEnabled && !shouldSnapping(event)) ||
(!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] &&
shouldSnapping(event) &&
!isGridModeEnabled(app))
);
}

View file

@ -1,8 +1,12 @@
import { createContext, type JSX } from "react";
import type {
IMAGE_MIME_TYPES,
UserIdleState,
throttleRAF,
MIME_TYPES,
ColorPaletteCustom,
ColorTuple,
} from "@excalidraw/common";
import type { SuggestedBinding } from "@excalidraw/element/binding";
@ -43,7 +47,9 @@ import type {
MakeBrand,
} from "@excalidraw/common/utility-types";
import type { Action } from "./actions/types";
import type { ColorPickerType } from "./components/ColorPicker/colorPickerUtils";
import type { Action, ActionResult } from "./actions/types";
import type { Spreadsheet } from "./charts";
import type { ClipboardData } from "./clipboard";
import type App from "./components/App";
@ -57,7 +63,8 @@ import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n";
import type { isOverScrollBars } from "./scene/scrollbars";
import type React from "react";
import type { JSX } from "react";
import type { ButtonIconSelectProps } from "./components/ButtonIconSelect";
import type { ButtonIcon } from "./components/ButtonIcon";
export type SocketId = string & { _brand: "SocketId" };
@ -512,6 +519,83 @@ export type OnUserFollowedPayload = {
action: "FOLLOW" | "UNFOLLOW";
};
export interface ExcalidrawPropsCustomOptions {
disableKeyEvents?: boolean;
hideMainToolbar?: boolean;
hideMenu?: boolean;
hideFooter?: boolean;
hideContextMenu?: boolean;
shouldResizeFromCenter?: (event: MouseEvent | KeyboardEvent) => boolean;
shouldMaintainAspectRatio?: (event: MouseEvent | KeyboardEvent) => boolean;
shouldRotateWithDiscreteAngle?: (
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
) => boolean;
shouldSnapping?: (event: KeyboardModifiersObject) => boolean;
layoutRenders?: {
menuRender?: (props: { children: React.ReactNode }) => React.ReactNode;
};
pickerRenders?: {
ButtonList?: React.ComponentType<{ children: React.ReactNode }>;
elementStrokeColors?: ColorTuple;
elementBackgroundColors?: ColorTuple;
buttonIconSelectRender?: <T extends Object>(
props: ButtonIconSelectProps<T>,
) => JSX.Element;
buttonIconSelectRadioRender?: (props: {
key: string;
active: boolean;
title: string;
name: string;
onChange: () => void;
checked: boolean;
dataTestid?: string;
children: React.ReactNode;
value: any;
}) => JSX.Element;
CustomButtonIcon?: typeof ButtonIcon;
layerButtonRender?: (props: {
onClick: () => void;
title: string;
children: React.ReactNode;
name: string;
}) => JSX.Element;
rangeRender?: (props: {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step: number;
}) => React.ReactNode;
colorPickerTopPickesButtonRender?: (props: {
active: boolean;
color: string;
isTransparent: boolean;
hasOutline: boolean;
onClick: () => void;
dataTestid: string;
children: React.ReactNode;
key: string;
}) => React.ReactNode;
colorPickerPopoverRender?: (props: {
color: string;
label: string;
type: ColorPickerType;
onChange: (color: string) => void;
elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom | null;
updateData: (formData?: any) => void;
}) => React.ReactNode;
};
}
export const ExcalidrawPropsCustomOptionsContext = createContext<
ExcalidrawPropsCustomOptions | undefined
>({});
export interface ExcalidrawActionType {
syncActionResult: (actionResult: ActionResult) => void;
}
export interface ExcalidrawProps {
onChange?: (
elements: readonly OrderedExcalidrawElement[],
@ -601,6 +685,8 @@ export interface ExcalidrawProps {
) => JSX.Element | null;
aiEnabled?: boolean;
showDeprecatedFonts?: boolean;
customOptions?: ExcalidrawPropsCustomOptions;
actionRef?: React.RefObject<ExcalidrawActionType | undefined>;
}
export type SceneData = {

View file

@ -54,7 +54,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf -rf types && tsc",
"build:esm": "rimraf -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View file

@ -69,7 +69,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types"
"gen:types": "rimraf -rf types && tsc",
"build:esm": "rimraf -rf dist && node ../../scripts/buildUtils.js && yarn gen:types"
}
}