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} handleKeyboardGlobally={true}
autoFocus={true} autoFocus={true}
theme={editorTheme} theme={editorTheme}
customOptions={{
disableKeyEvents: true,
// hideMainToolbar: true,
// hideMenu: true,
// hideFooter: true,
hideContextMenu: true,
}}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;

View file

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

View file

@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues", "bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw", "repository": "https://github.com/excalidraw/excalidraw",
"scripts": { "scripts": {
"gen:types": "rm -rf types && tsc", "gen:types": "rimraf -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types" "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 { isDarwin } from "./constants";
import type { ValueOf } from "./utility-types"; import type { ValueOf } from "./utility-types";
export const CODES = { export const CODES = {
EQUAL: "Equal", EQUAL: "Equal",
MINUS: "Minus", MINUS: "Minus",
@ -140,12 +143,60 @@ export const isArrowKey = (key: string) =>
key === KEYS.ARROW_DOWN || key === KEYS.ARROW_DOWN ||
key === KEYS.ARROW_UP; key === KEYS.ARROW_UP;
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) => const shouldResizeFromCenterDefault = (event: MouseEvent | KeyboardEvent) =>
event.altKey; event.altKey;
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) => const shouldMaintainAspectRatioDefault = (event: MouseEvent | KeyboardEvent) =>
event.shiftKey; 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 = ( export const shouldRotateWithDiscreteAngle = (
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>, 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", "bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw", "repository": "https://github.com/excalidraw/excalidraw",
"scripts": { "scripts": {
"gen:types": "rm -rf types && tsc", "gen:types": "rimraf -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rimraf -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,37 +1,53 @@
import clsx from "clsx"; import clsx from "clsx";
import { useContext, type JSX } from "react";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { ButtonIcon } from "./ButtonIcon"; 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 /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>( export const ButtonIconSelect = <T extends Object>(
props: { props: ButtonIconSelectProps<T>,
options: { ) => {
value: T; const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
text: string;
icon: JSX.Element; if (customOptions?.pickerRenders?.buttonIconSelectRender) {
testId?: string; return customOptions.pickerRenders.buttonIconSelectRender(props);
/** if not supplied, defaults to value identity check */ }
active?: boolean;
}[]; const renderButtonIcon = (
value: T | null; option: ButtonIconSelectProps<T>["options"][number],
type?: "radio" | "button"; ) => {
} & ( if (props.type !== "button") {
| { type?: "radio"; group: string; onChange: (value: T) => void } return null;
| { }
type: "button";
onClick: ( if (customOptions?.pickerRenders?.CustomButtonIcon) {
value: T, return (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>, <customOptions.pickerRenders.CustomButtonIcon
) => void;
}
),
) => (
<div className="buttonList">
{props.options.map((option) =>
props.type === "button" ? (
<ButtonIcon
key={option.text} key={option.text}
icon={option.icon} icon={option.icon}
title={option.text} title={option.text}
@ -39,22 +55,66 @@ export const ButtonIconSelect = <T extends Object>(
active={option.active ?? props.value === option.value} active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)} onClick={(event) => props.onClick(option.value, event)}
/> />
) : ( );
<label }
key={option.text} return (
className={clsx({ active: props.value === option.value })} <ButtonIcon
title={option.text} key={option.text}
> icon={option.icon}
<input title={option.text}
type="radio" testId={option.testId}
name={props.group} active={option.active ?? props.value === option.value}
onChange={() => props.onChange(option.value)} onClick={(event) => props.onClick(option.value, event)}
checked={props.value === option.value} />
data-testid={option.testId} );
/> };
{option.icon}
</label> const renderRadioButtonIcon = (
), option: ButtonIconSelectProps<T>["options"][number],
)} ) => {
</div> 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 * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react"; import { useContext, useRef } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
@ -19,6 +19,11 @@ import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import {
ExcalidrawPropsCustomOptionsContext,
type AppState,
} from "../../types";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
@ -29,8 +34,6 @@ import "./ColorPicker.scss";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState } from "../../types";
const isValidColor = (color: string) => { const isValidColor = (color: string) => {
const style = new Option().style; const style = new Option().style;
style.color = color; style.color = color;
@ -220,6 +223,46 @@ export const ColorPicker = ({
updateData, updateData,
appState, appState,
}: ColorPickerProps) => { }: 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 ( return (
<div> <div>
<div role="dialog" aria-modal="true" className="color-picker-container"> <div role="dialog" aria-modal="true" className="color-picker-container">
@ -230,27 +273,7 @@ export const ColorPicker = ({
topPicks={topPicks} topPicks={topPicks}
/> />
<ButtonSeparator /> <ButtonSeparator />
<Popover.Root {renderPopover()}
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>
</div> </div>
</div> </div>
); );

View file

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

View file

@ -72,6 +72,7 @@ import type {
BinaryFiles, BinaryFiles,
UIAppState, UIAppState,
AppClassProperties, AppClassProperties,
ExcalidrawPropsCustomOptions,
} from "../types"; } from "../types";
interface LayerUIProps { interface LayerUIProps {
@ -95,6 +96,7 @@ interface LayerUIProps {
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean; isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"]; generateLinkForSelection?: AppProps["generateLinkForSelection"];
customOptions?: ExcalidrawPropsCustomOptions;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@ -153,6 +155,7 @@ const LayerUI = ({
app, app,
isCollaborating, isCollaborating,
generateLinkForSelection, generateLinkForSelection,
customOptions,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@ -209,13 +212,8 @@ const LayerUI = ({
</div> </div>
); );
const renderSelectedShapeActions = () => ( const renderSelectedShapeActions = () => {
<Section const children = (
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
<Island <Island
className={CLASSES.SHAPE_ACTIONS_MENU} className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2} padding={2}
@ -232,8 +230,19 @@ const LayerUI = ({
app={app} app={app}
/> />
</Island> </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 renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions( const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -249,7 +258,10 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <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")}> <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
@ -274,6 +286,11 @@ const LayerUI = ({
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
style={{
display: customOptions?.hideMainToolbar
? "none"
: undefined,
}}
> >
<HintViewer <HintViewer
appState={appState} appState={appState}
@ -547,12 +564,14 @@ const LayerUI = ({
> >
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()} {renderFixedSideContainer()}
<Footer {!customOptions?.hideFooter && (
appState={appState} <Footer
actionManager={actionManager} appState={appState}
showExitZenModeBtn={showExitZenModeBtn} actionManager={actionManager}
renderWelcomeScreen={renderWelcomeScreen} showExitZenModeBtn={showExitZenModeBtn}
/> renderWelcomeScreen={renderWelcomeScreen}
/>
)}
{appState.scrolledOutside && ( {appState.scrolledOutside && (
<button <button
type="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 { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n"; import { t } from "../i18n";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import "./Range.scss"; import "./Range.scss";
@ -40,28 +41,42 @@ export const Range = ({
} }
}, [value]); }, [value]);
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return ( return (
<label className="control-label"> <label className="control-label">
{t("labels.opacity")} {t("labels.opacity")}
<div className="range-wrapper"> {customOptions?.pickerRenders?.rangeRender ? (
<input customOptions?.pickerRenders?.rangeRender({
ref={rangeRef} value,
type="range" onChange: (value: number) => {
min="0" updateData(value);
max="100" },
step="10" step: 10,
onChange={(event) => { min: 0,
updateData(+event.target.value); max: 100,
}} })
value={value} ) : (
className="range-input" <div className="range-wrapper">
data-testid={testId} <input
/> ref={rangeRef}
<div className="value-bubble" ref={valueRef}> type="range"
{value !== 0 ? value : null} 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>
<div className="zero-label">0</div> )}
</div>
</label> </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"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@ -16,7 +16,8 @@ import "./css/app.scss";
import "./css/styles.scss"; import "./css/styles.scss";
import "./fonts/fonts.css"; 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(); polyfill();
@ -53,6 +54,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
renderEmbeddable, renderEmbeddable,
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
customOptions,
actionRef,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; 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 ( return (
<EditorJotaiProvider store={editorJotaiStore}> <ExcalidrawPropsCustomOptionsContext.Provider value={customOptions}>
<InitializeApp langCode={langCode} theme={theme}> <EditorJotaiProvider store={editorJotaiStore}>
<App <InitializeApp langCode={langCode} theme={theme}>
onChange={onChange} <App
initialData={initialData} ref={appRef}
excalidrawAPI={excalidrawAPI} onChange={onChange}
isCollaborating={isCollaborating} initialData={initialData}
onPointerUpdate={onPointerUpdate} excalidrawAPI={excalidrawAPI}
renderTopRightUI={renderTopRightUI} isCollaborating={isCollaborating}
langCode={langCode} onPointerUpdate={onPointerUpdate}
viewModeEnabled={viewModeEnabled} renderTopRightUI={renderTopRightUI}
zenModeEnabled={zenModeEnabled} langCode={langCode}
gridModeEnabled={gridModeEnabled} viewModeEnabled={viewModeEnabled}
libraryReturnUrl={libraryReturnUrl} zenModeEnabled={zenModeEnabled}
theme={theme} gridModeEnabled={gridModeEnabled}
name={name} libraryReturnUrl={libraryReturnUrl}
renderCustomStats={renderCustomStats} theme={theme}
UIOptions={UIOptions} name={name}
onPaste={onPaste} renderCustomStats={renderCustomStats}
detectScroll={detectScroll} UIOptions={UIOptions}
handleKeyboardGlobally={handleKeyboardGlobally} onPaste={onPaste}
onLibraryChange={onLibraryChange} detectScroll={detectScroll}
autoFocus={autoFocus} handleKeyboardGlobally={handleKeyboardGlobally}
generateIdForFile={generateIdForFile} onLibraryChange={onLibraryChange}
onLinkOpen={onLinkOpen} autoFocus={autoFocus}
generateLinkForSelection={generateLinkForSelection} generateIdForFile={generateIdForFile}
onPointerDown={onPointerDown} onLinkOpen={onLinkOpen}
onPointerUp={onPointerUp} generateLinkForSelection={generateLinkForSelection}
onScrollChange={onScrollChange} onPointerDown={onPointerDown}
onDuplicate={onDuplicate} onPointerUp={onPointerUp}
validateEmbeddable={validateEmbeddable} onScrollChange={onScrollChange}
renderEmbeddable={renderEmbeddable} onDuplicate={onDuplicate}
aiEnabled={aiEnabled !== false} validateEmbeddable={validateEmbeddable}
showDeprecatedFonts={showDeprecatedFonts} renderEmbeddable={renderEmbeddable}
> aiEnabled={aiEnabled !== false}
{children} showDeprecatedFonts={showDeprecatedFonts}
</App> customOptions={customOptions}
</InitializeApp> >
</EditorJotaiProvider> {children}
</App>
</InitializeApp>
</EditorJotaiProvider>
</ExcalidrawPropsCustomOptionsContext.Provider>
); );
}; };

View file

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

View file

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

View file

@ -1,8 +1,12 @@
import { createContext, type JSX } from "react";
import type { import type {
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
UserIdleState, UserIdleState,
throttleRAF, throttleRAF,
MIME_TYPES, MIME_TYPES,
ColorPaletteCustom,
ColorTuple,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { SuggestedBinding } from "@excalidraw/element/binding"; import type { SuggestedBinding } from "@excalidraw/element/binding";
@ -43,7 +47,9 @@ import type {
MakeBrand, MakeBrand,
} from "@excalidraw/common/utility-types"; } 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 { Spreadsheet } from "./charts";
import type { ClipboardData } from "./clipboard"; import type { ClipboardData } from "./clipboard";
import type App from "./components/App"; import type App from "./components/App";
@ -57,7 +63,8 @@ import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n"; import type { Language } from "./i18n";
import type { isOverScrollBars } from "./scene/scrollbars"; import type { isOverScrollBars } from "./scene/scrollbars";
import type React from "react"; 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" }; export type SocketId = string & { _brand: "SocketId" };
@ -512,6 +519,83 @@ export type OnUserFollowedPayload = {
action: "FOLLOW" | "UNFOLLOW"; 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 { export interface ExcalidrawProps {
onChange?: ( onChange?: (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
@ -601,6 +685,8 @@ export interface ExcalidrawProps {
) => JSX.Element | null; ) => JSX.Element | null;
aiEnabled?: boolean; aiEnabled?: boolean;
showDeprecatedFonts?: boolean; showDeprecatedFonts?: boolean;
customOptions?: ExcalidrawPropsCustomOptions;
actionRef?: React.RefObject<ExcalidrawActionType | undefined>;
} }
export type SceneData = { export type SceneData = {

View file

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