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,11 +327,17 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
PanelComponent: ({ elements, appState, updateData, appProps }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
topPicks={
customOptions?.pickerRenders?.elementStrokeColors ??
DEFAULT_ELEMENT_STROKE_PICKS
}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
type="elementStroke"
label={t("labels.stroke")}
@ -343,7 +354,8 @@ export const actionChangeStrokeColor = register({
updateData={updateData}
/>
</>
),
);
},
});
export const actionChangeBackgroundColor = register({
@ -368,11 +380,17 @@ export const actionChangeBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
PanelComponent: ({ elements, appState, updateData, appProps }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<>
<h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
topPicks={
customOptions?.pickerRenders?.elementBackgroundColors ??
DEFAULT_ELEMENT_BACKGROUND_PICKS
}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
type="elementBackground"
label={t("labels.background")}
@ -383,13 +401,16 @@ export const actionChangeBackgroundColor = register({
true,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
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,7 +40,19 @@ export const actionSendBackward = register({
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
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"
@ -45,7 +61,8 @@ export const actionSendBackward = register({
>
{SendBackwardIcon}
</button>
),
);
},
});
export const actionBringForward = register({
@ -66,7 +83,19 @@ export const actionBringForward = register({
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
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"
@ -75,7 +104,8 @@ export const actionBringForward = register({
>
{BringForwardIcon}
</button>
),
);
},
});
export const actionSendToBack = register({
@ -99,7 +129,19 @@ export const actionSendToBack = register({
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
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"
@ -112,7 +154,8 @@ export const actionSendToBack = register({
>
{SendToBackIcon}
</button>
),
);
},
});
export const actionBringToFront = register({
@ -137,7 +180,19 @@ export const actionBringToFront = register({
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
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"
@ -150,5 +205,6 @@ export const actionBringToFront = register({
>
{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">
{!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,10 +1672,12 @@ class App extends React.Component<AppProps, AppState> {
generateLinkForSelection={
this.props.generateLinkForSelection
}
customOptions={this.props.customOptions}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-container-inner">
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
@ -1757,7 +1783,8 @@ class App extends React.Component<AppProps, AppState> {
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
{this.state.contextMenu &&
!this.props.customOptions?.hideContextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
@ -1789,8 +1816,10 @@ class App extends React.Component<AppProps, AppState> {
renderGrid: isGridModeEnabled(this),
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
elementsPendingErasure: this.elementsPendingErasure,
embedsValidationStatus:
this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
}}
@ -1851,6 +1880,7 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
{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))
selectedElements.some((element) =>
isImageElement(element)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
),
resizeX,
resizeY,
pointerDownState.resize.center.x,

View file

@ -1,12 +1,12 @@
import clsx from "clsx";
import { useContext, type JSX } from "react";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { ButtonIcon } from "./ButtonIcon";
import type { JSX } from "react";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
props: {
export type ButtonIconSelectProps<T> = {
options: {
value: T;
text: string;
@ -26,11 +26,38 @@ export const ButtonIconSelect = <T extends Object>(
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
),
) => (
<div className="buttonList">
{props.options.map((option) =>
props.type === "button" ? (
);
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
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}
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
);
}
return (
<ButtonIcon
key={option.text}
icon={option.icon}
@ -39,7 +66,31 @@ export const ButtonIconSelect = <T extends Object>(
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 })}
@ -54,7 +105,16 @@ export const ButtonIconSelect = <T extends Object>(
/>
{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,16 +223,22 @@ 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 (
<div>
<div role="dialog" aria-modal="true" className="color-picker-container">
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
@ -251,6 +260,20 @@ export const ColorPicker = ({
/>
)}
</Popover.Root>
);
};
return (
<div>
<div role="dialog" aria-modal="true" className="color-picker-container">
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
{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,7 +58,21 @@ export const TopPicks = ({
return (
<div className="color-picker__top-picks">
{colors.map((color: string) => (
{colors.map((color: string) => {
if (customOptions?.pickerRenders?.colorPickerTopPickesButtonRender) {
return customOptions.pickerRenders.colorPickerTopPickesButtonRender({
active: color === activeColor,
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,
@ -68,7 +91,8 @@ export const TopPicks = ({
>
<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>
);
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()}
{!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,9 +41,22 @@ export const Range = ({
}
}, [value]);
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<label className="control-label">
{t("labels.opacity")}
{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}
@ -62,6 +76,7 @@ export const Range = ({
</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,10 +111,23 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
};
}, []);
const appRef = useRef<App>(null);
useImperativeHandle(
actionRef,
() => ({
syncActionResult: (actionResult: ActionResult) => {
appRef.current?.syncActionResult(actionResult);
},
}),
[],
);
return (
<ExcalidrawPropsCustomOptionsContext.Provider value={customOptions}>
<EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}>
<App
ref={appRef}
onChange={onChange}
initialData={initialData}
excalidrawAPI={excalidrawAPI}
@ -143,11 +159,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
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"
}
}