Merge pull request #1 from mg-chao/feats/20250416_custom

为 Snow Shot 提供自定义支持
This commit is contained in:
mg-chao 2025-04-28 21:20:39 +08:00 committed by GitHub
commit 4a6039b9a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1997 additions and 765 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

@ -438,6 +438,7 @@ export const TOOL_TYPE = {
magicframe: "magicframe", magicframe: "magicframe",
embeddable: "embeddable", embeddable: "embeddable",
laser: "laser", laser: "laser",
blur: "blur",
} as const; } as const;
export const EDITOR_LS_KEYS = { export const EDITOR_LS_KEYS = {

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

@ -98,6 +98,7 @@ export const generateRoughOptions = (
case "iframe": case "iframe":
case "embeddable": case "embeddable":
case "diamond": case "diamond":
case "blur":
case "ellipse": { case "ellipse": {
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
@ -326,6 +327,7 @@ export const _generateElementShape = (
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe": case "iframe":
case "blur":
case "embeddable": { case "embeddable": {
let shape: ElementShapes[typeof element.type]; let shape: ElementShapes[typeof element.type];
// this is for rendering the stroke/bg of the embeddable, especially // this is for rendering the stroke/bg of the embeddable, especially

View file

@ -10,7 +10,10 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe"; type !== "image" &&
type !== "frame" &&
type !== "magicframe" &&
type !== "blur";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
@ -39,6 +42,10 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
type === "diamond" || type === "diamond" ||
type === "image"; type === "image";
export const canChangeBlur = (type: ElementOrToolType) => type === "blur";
export const canChangeLayer = (type: ElementOrToolType) => type !== "blur";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow"; export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";

View file

@ -46,6 +46,7 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawBlurElement,
} from "./types"; } from "./types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@ -212,6 +213,25 @@ export const newMagicFrameElement = (
return frameElement; return frameElement;
}; };
export const newBlurElement = (
opts: {
blur: number;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawBlurElement> => {
const blurElement = newElementWith(
{
..._newElementBase<ExcalidrawBlurElement>("blur", opts),
type: "blur",
blur: opts.blur,
fillStyle: "solid",
backgroundColor: "#000000",
},
{},
);
return blurElement;
};
/** computes element x/y offset based on textAlign/verticalAlign */ /** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = ( const getTextElementPositionOffsets = (
opts: { opts: {

View file

@ -404,6 +404,8 @@ const drawElementOnCanvas = (
rc.draw(ShapeCache.get(element)!); rc.draw(ShapeCache.get(element)!);
break; break;
} }
case "blur":
break;
case "arrow": case "arrow":
case "line": { case "line": {
context.lineJoin = "round"; context.lineJoin = "round";
@ -814,6 +816,7 @@ export const renderElement = (
case "image": case "image":
case "text": case "text":
case "iframe": case "iframe":
case "blur":
case "embeddable": { case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call // TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords) // beforehand because math helpers (such as getElementAbsoluteCoords)

View file

@ -58,6 +58,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
case "embeddable": case "embeddable":
case "image": case "image":
case "iframe": case "iframe":
case "blur":
case "text": case "text":
case "selection": case "selection":
return getPolygonShape(element); return getPolygonShape(element);

View file

@ -28,6 +28,7 @@ import type {
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawBlurElement,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -107,6 +108,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type); return element != null && isLinearElementType(element.type);
}; };
export const isBlurElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBlurElement => {
return element != null && isBlurElementType(element.type);
};
export const isArrowElement = ( export const isArrowElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawArrowElement => {
@ -127,6 +134,10 @@ export const isLinearElementType = (
); );
}; };
export const isBlurElementType = (elementType: ElementOrToolType): boolean => {
return elementType === "blur";
};
export const isBindingElement = ( export const isBindingElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
@ -231,6 +242,7 @@ export const isExcalidrawElement = (
case "frame": case "frame":
case "magicframe": case "magicframe":
case "image": case "image":
case "blur":
case "selection": { case "selection": {
return true; return true;
} }

View file

@ -89,6 +89,11 @@ export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
type: "rectangle"; type: "rectangle";
}; };
export type ExcalidrawBlurElement = _ExcalidrawElementBase & {
type: "blur";
blur: number;
};
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & { export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
type: "diamond"; type: "diamond";
}; };
@ -212,7 +217,8 @@ export type ExcalidrawElement =
| ExcalidrawFrameElement | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement | ExcalidrawMagicFrameElement
| ExcalidrawIframeElement | ExcalidrawIframeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawBlurElement;
export type ExcalidrawNonSelectionElement = Exclude< export type ExcalidrawNonSelectionElement = Exclude<
ExcalidrawElement, ExcalidrawElement,

View file

@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align"; import { alignElements } from "@excalidraw/element/align";
import { useContext } from "react";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align"; import type { Alignment } from "@excalidraw/element/align";
@ -27,9 +29,14 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import {
ExcalidrawPropsCustomOptionsContext,
type AppClassProperties,
type AppState,
type UIAppState,
} from "../types";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import { register } from "./register";
export const alignActionsPredicate = ( export const alignActionsPredicate = (
appState: UIAppState, appState: UIAppState,
@ -81,7 +88,24 @@ export const actionAlignTop = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.alignTop")}`,
children: AlignTopIcon,
name: "alignTop",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !alignActionsPredicate(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
@ -91,9 +115,13 @@ export const actionAlignTop = register({
"CtrlOrCmd+Shift+Up", "CtrlOrCmd+Shift+Up",
)}`} )}`}
aria-label={t("labels.alignTop")} aria-label={t("labels.alignTop")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });
export const actionAlignBottom = register({ export const actionAlignBottom = register({
@ -115,7 +143,24 @@ export const actionAlignBottom = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.alignBottom")}`,
children: AlignBottomIcon,
name: "alignBottom",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !alignActionsPredicate(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
@ -125,9 +170,13 @@ export const actionAlignBottom = register({
"CtrlOrCmd+Shift+Down", "CtrlOrCmd+Shift+Down",
)}`} )}`}
aria-label={t("labels.alignBottom")} aria-label={t("labels.alignBottom")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });
export const actionAlignLeft = register({ export const actionAlignLeft = register({
@ -149,7 +198,24 @@ export const actionAlignLeft = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.alignLeft")}`,
children: AlignLeftIcon,
name: "alignLeft",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !alignActionsPredicate(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
@ -159,9 +225,13 @@ export const actionAlignLeft = register({
"CtrlOrCmd+Shift+Left", "CtrlOrCmd+Shift+Left",
)}`} )}`}
aria-label={t("labels.alignLeft")} aria-label={t("labels.alignLeft")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });
export const actionAlignRight = register({ export const actionAlignRight = register({
@ -183,7 +253,23 @@ export const actionAlignRight = register({
}, },
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.alignRight")}`,
children: AlignRightIcon,
name: "alignRight",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !alignActionsPredicate(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
@ -193,9 +279,13 @@ export const actionAlignRight = register({
"CtrlOrCmd+Shift+Right", "CtrlOrCmd+Shift+Right",
)}`} )}`}
aria-label={t("labels.alignRight")} aria-label={t("labels.alignRight")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
@ -215,7 +305,24 @@ export const actionAlignVerticallyCentered = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.centerVertically")}`,
children: CenterVerticallyIcon,
name: "alignVerticallyCentered",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !alignActionsPredicate(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
@ -223,9 +330,13 @@ export const actionAlignVerticallyCentered = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerVertically")} title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")} aria-label={t("labels.centerVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
@ -245,7 +356,24 @@ export const actionAlignHorizontallyCentered = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.centerHorizontally")}`,
children: CenterHorizontallyIcon,
name: "alignHorizontallyCentered",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !alignActionsPredicate(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
@ -253,7 +381,11 @@ export const actionAlignHorizontallyCentered = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={t("labels.centerHorizontally")} title={t("labels.centerHorizontally")}
aria-label={t("labels.centerHorizontally")} aria-label={t("labels.centerHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });

View file

@ -0,0 +1,48 @@
import { newElementWith } from "@excalidraw/element/mutateElement";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
import { isBlurElement } from "@excalidraw/element/typeChecks";
import { BlurRange } from "../components/Range";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import { changeProperty } from "./actionProperties";
export const actionChangeBlur = register({
name: "changeBlur",
label: "labels.blur",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(el) => {
if (!isBlurElement(el)) {
return el;
}
return newElementWith(el, {
blur: value,
});
},
true,
),
appState: { ...appState, currentItemBlur: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<BlurRange
updateData={updateData}
elements={elements}
appState={appState}
testId="blur"
/>
),
});

View file

@ -17,6 +17,8 @@ import {
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
} from "@excalidraw/element/groups"; } from "@excalidraw/element/groups";
import { useContext } from "react";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@ -25,6 +27,8 @@ import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@ -310,14 +314,34 @@ export const actionDeleteSelected = register({
keyTest: (event, appState, elements) => keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) && (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.delete")}`,
children: TrashIcon,
name: "deleteSelectedElements",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
});
}
return (
<ToolButton <ToolButton
type="button" type="button"
icon={TrashIcon} icon={TrashIcon}
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });

View file

@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra
import { distributeElements } from "@excalidraw/element/distribute"; import { distributeElements } from "@excalidraw/element/distribute";
import { useContext } from "react";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element/distribute"; import type { Distribution } from "@excalidraw/element/distribute";
@ -23,9 +25,13 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import {
ExcalidrawPropsCustomOptionsContext,
type AppClassProperties,
type AppState,
} from "../types";
import type { AppClassProperties, AppState } from "../types"; import { register } from "./register";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => { const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
@ -75,7 +81,24 @@ export const distributeHorizontally = register({
}, },
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.distributeHorizontally")}`,
children: DistributeHorizontallyIcon,
name: "distributeHorizontally",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !enableActionGroup(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!enableActionGroup(appState, app)} hidden={!enableActionGroup(appState, app)}
type="button" type="button"
@ -85,9 +108,13 @@ export const distributeHorizontally = register({
"Alt+H", "Alt+H",
)}`} )}`}
aria-label={t("labels.distributeHorizontally")} aria-label={t("labels.distributeHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });
export const distributeVertically = register({ export const distributeVertically = register({
@ -106,15 +133,38 @@ export const distributeVertically = register({
}, },
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.distributeVertically")}`,
children: DistributeVerticallyIcon,
name: "distributeVertically",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !enableActionGroup(appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!enableActionGroup(appState, app)} hidden={!enableActionGroup(appState, app)}
type="button" type="button"
icon={DistributeVerticallyIcon} icon={DistributeVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`} title={`${t("labels.distributeVertically")}${getShortcutKey(
"Alt+V",
)}`}
aria-label={t("labels.distributeVertically")} aria-label={t("labels.distributeVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });

View file

@ -18,6 +18,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element/duplicate"; import { duplicateElements } from "@excalidraw/element/duplicate";
import { useContext } from "react";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
@ -25,6 +27,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register"; import { register } from "./register";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
@ -105,7 +109,23 @@ export const actionDuplicateSelection = register({
}; };
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.duplicateSelection")}`,
children: DuplicateIcon,
name: "duplicateSelection",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
});
}
return (
<ToolButton <ToolButton
type="button" type="button"
icon={DuplicateIcon} icon={DuplicateIcon}
@ -114,7 +134,11 @@ export const actionDuplicateSelection = register({
)}`} )}`}
aria-label={t("labels.duplicateSelection")} aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/> />
), );
},
}); });

View file

@ -28,6 +28,8 @@ import {
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { useContext } from "react";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@ -42,9 +44,13 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import {
ExcalidrawPropsCustomOptionsContext,
type AppClassProperties,
type AppState,
} from "../types";
import type { AppClassProperties, AppState } from "../types"; import { register } from "./register";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@ -195,7 +201,24 @@ export const actionGroup = register({
enableActionGroup(elements, appState, app), enableActionGroup(elements, appState, app),
keyTest: (event) => keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.group")}`,
children: <GroupIcon theme={appState.theme} />,
name: "group",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: !enableActionGroup(elements, appState, app),
});
}
return (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState, app)} hidden={!enableActionGroup(elements, appState, app)}
type="button" type="button"
@ -203,9 +226,13 @@ export const actionGroup = register({
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.group")}${getShortcutKey("CtrlOrCmd+G")}`} title={`${t("labels.group")}${getShortcutKey("CtrlOrCmd+G")}`}
aria-label={t("labels.group")} aria-label={t("labels.group")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
></ToolButton> ></ToolButton>
), );
},
}); });
export const actionUngroup = register({ export const actionUngroup = register({
@ -304,15 +331,38 @@ export const actionUngroup = register({
event.key === KEYS.G.toUpperCase(), event.key === KEYS.G.toUpperCase(),
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: `${t("labels.ungroup")}`,
children: <UngroupIcon theme={appState.theme} />,
name: "ungroup",
visible: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
hidden: getSelectedGroupIds(appState).length === 0,
});
}
return (
<ToolButton <ToolButton
type="button" type="button"
hidden={getSelectedGroupIds(appState).length === 0} hidden={getSelectedGroupIds(appState).length === 0}
icon={<UngroupIcon theme={appState.theme} />} icon={<UngroupIcon theme={appState.theme} />}
onClick={() => updateData(null)} onClick={() => updateData(null)}
title={`${t("labels.ungroup")}${getShortcutKey("CtrlOrCmd+Shift+G")}`} title={`${t("labels.ungroup")}${getShortcutKey(
"CtrlOrCmd+Shift+G",
)}`}
aria-label={t("labels.ungroup")} aria-label={t("labels.ungroup")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
></ToolButton> ></ToolButton>
), );
},
}); });

View file

@ -1,5 +1,7 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
import { useContext, useEffect } from "react";
import type { SceneElementsMap } from "@excalidraw/element/types"; import type { SceneElementsMap } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
@ -9,9 +11,14 @@ import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import {
ExcalidrawHistoryContext,
type AppClassProperties,
type AppState,
} from "../types";
import type { History } from "../history"; import type { History } from "../history";
import type { Store } from "../store"; import type { Store } from "../store";
import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types"; import type { Action, ActionResult } from "./types";
const executeHistoryAction = ( const executeHistoryAction = (
@ -74,6 +81,12 @@ export const createUndoAction: ActionCreator = (history, store) => ({
), ),
); );
const historyContext = useContext(ExcalidrawHistoryContext);
historyContext &&
(historyContext.undoRef.current = () => {
updateData();
});
return ( return (
<ToolButton <ToolButton
type="button" type="button"
@ -114,6 +127,12 @@ export const createRedoAction: ActionCreator = (history, store) => ({
), ),
); );
const historyContext = useContext(ExcalidrawHistoryContext);
historyContext &&
(historyContext.redoRef.current = () => {
updateData();
});
return ( return (
<ToolButton <ToolButton
type="button" type="button"

View file

@ -2,6 +2,8 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS, getShortcutKey } from "@excalidraw/common";
import { useContext } from "react";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons"; import { LinkIcon } from "../components/icons";
@ -10,6 +12,8 @@ import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register"; import { register } from "./register";
export const actionLink = register({ export const actionLink = register({
@ -40,6 +44,20 @@ export const actionLink = register({
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
onClick: () => updateData(null),
title: isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label"),
children: LinkIcon,
name: "link",
visible: selectedElements.length === 1 && !!selectedElements[0].link,
});
}
return ( return (
<ToolButton <ToolButton
type="button" type="button"

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,
@ -133,10 +133,16 @@ import {
} from "../scene"; } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import {
ExcalidrawPropsCustomOptionsContext,
type AppClassProperties,
type AppState,
type Primitive,
} from "../types";
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";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -318,11 +324,17 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : 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> <h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS} topPicks={
customOptions?.pickerRenders?.elementStrokeColors ??
DEFAULT_ELEMENT_STROKE_PICKS
}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
type="elementStroke" type="elementStroke"
label={t("labels.stroke")} label={t("labels.stroke")}
@ -339,7 +351,8 @@ export const actionChangeStrokeColor = register({
updateData={updateData} updateData={updateData}
/> />
</> </>
), );
},
}); });
export const actionChangeBackgroundColor = register({ export const actionChangeBackgroundColor = register({
@ -364,11 +377,17 @@ export const actionChangeBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY, : 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> <h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} topPicks={
customOptions?.pickerRenders?.elementBackgroundColors ??
DEFAULT_ELEMENT_BACKGROUND_PICKS
}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
type="elementBackground" type="elementBackground"
label={t("labels.background")} label={t("labels.background")}
@ -379,13 +398,16 @@ export const actionChangeBackgroundColor = register({
true, true,
appState.currentItemBackgroundColor, appState.currentItemBackgroundColor,
)} )}
onChange={(color) => updateData({ currentItemBackgroundColor: color })} onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
}
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
/> />
</> </>
), );
},
}); });
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
@ -1520,11 +1542,54 @@ export const actionChangeArrowhead = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
{customOptions?.pickerRenders?.ButtonList && (
<customOptions.pickerRenders.ButtonList className="iconPickerList">
<IconPicker
label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead,
)}
onChange={(value) =>
updateData({ position: "start", type: value })
}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</customOptions.pickerRenders.ButtonList>
)}
{!customOptions?.pickerRenders?.ButtonList && (
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
label="arrowhead_start" label="arrowhead_start"
@ -1539,7 +1604,9 @@ export const actionChangeArrowhead = register({
true, true,
appState.currentItemStartArrowhead, appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) =>
updateData({ position: "start", type: value })
}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
@ -1560,6 +1627,7 @@ export const actionChangeArrowhead = register({
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
)}
</fieldset> </fieldset>
); );
}, },

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,7 +40,19 @@ 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 }) => {
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 <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
@ -45,7 +61,8 @@ export const actionSendBackward = register({
> >
{SendBackwardIcon} {SendBackwardIcon}
</button> </button>
), );
},
}); });
export const actionBringForward = register({ export const actionBringForward = register({
@ -66,7 +83,19 @@ 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 }) => {
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 <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
@ -75,7 +104,8 @@ export const actionBringForward = register({
> >
{BringForwardIcon} {BringForwardIcon}
</button> </button>
), );
},
}); });
export const actionSendToBack = register({ export const actionSendToBack = register({
@ -99,7 +129,19 @@ 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 }) => {
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 <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
@ -112,7 +154,8 @@ export const actionSendToBack = register({
> >
{SendToBackIcon} {SendToBackIcon}
</button> </button>
), );
},
}); });
export const actionBringToFront = register({ export const actionBringToFront = register({
@ -137,7 +180,19 @@ 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 }) => {
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 <button
type="button" type="button"
className="zIndexButton" className="zIndexButton"
@ -150,5 +205,6 @@ export const actionBringToFront = register({
> >
{BringToFrontIcon} {BringToFrontIcon}
</button> </button>
), );
},
}); });

View file

@ -20,6 +20,8 @@ export {
actionChangeVerticalAlign, actionChangeVerticalAlign,
} from "./actionProperties"; } from "./actionProperties";
export { actionChangeBlur } from "./actionBlur";
export { export {
actionChangeViewBackgroundColor, actionChangeViewBackgroundColor,
actionClearCanvas, actionClearCanvas,

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

@ -140,7 +140,8 @@ export type ActionName =
| "linkToElement" | "linkToElement"
| "cropEditor" | "cropEditor"
| "wrapSelectionInFrame" | "wrapSelectionInFrame"
| "toggleLassoTool"; | "toggleLassoTool"
| "changeBlur";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View file

@ -33,6 +33,7 @@ export const getDefaultAppState = (): Omit<
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity, currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemBlur: 50,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
@ -161,6 +162,7 @@ const APP_STATE_STORAGE_CONF = (<
server: false, server: false,
}, },
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemBlur: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeColor: { browser: true, export: false, server: false },

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,
@ -21,7 +21,12 @@ import {
isTextElement, isTextElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element/comparisons"; import {
canChangeBlur,
canChangeLayer,
hasStrokeColor,
toolIsArrow,
} from "@excalidraw/element/comparisons";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -46,6 +51,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";
@ -86,6 +93,7 @@ export const canChangeStrokeColor = (
(hasStrokeColor(appState.activeTool.type) && (hasStrokeColor(appState.activeTool.type) &&
commonSelectedType !== "image" && commonSelectedType !== "image" &&
commonSelectedType !== "frame" && commonSelectedType !== "frame" &&
commonSelectedType !== "blur" &&
commonSelectedType !== "magicframe") || commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type)) targetElements.some((element) => hasStrokeColor(element.type))
); );
@ -112,6 +120,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;
@ -153,6 +163,12 @@ export const SelectedShapeActions = ({
const showAlignActions = const showAlignActions =
!isSingleElementBoundContainer && alignActionsPredicate(appState, app); !isSingleElementBoundContainer && alignActionsPredicate(appState, app);
const showLayerActions =
appState.activeTool.type === "selection"
? !targetElements.some((element) => !canChangeLayer(element.type))
: canChangeLayer(appState.activeTool.type) ||
targetElements.some((element) => canChangeLayer(element.type));
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@ -208,21 +224,38 @@ export const SelectedShapeActions = ({
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
{(canChangeBlur(appState.activeTool.type) ||
targetElements.some((element) => canChangeBlur(element.type))) &&
renderAction("changeBlur")}
{renderAction("changeOpacity")} {renderAction("changeOpacity")}
{showLayerActions && (
<fieldset> <fieldset>
<legend>{t("labels.layers")}</legend> <legend>{t("labels.layers")}</legend>
<div className="buttonList"> {!customOptions?.pickerRenders?.ButtonList && (
<div className={"buttonList"}>
{renderAction("sendToBack")} {renderAction("sendToBack")}
{renderAction("sendBackward")} {renderAction("sendBackward")}
{renderAction("bringForward")} {renderAction("bringForward")}
{renderAction("bringToFront")} {renderAction("bringToFront")}
</div> </div>
)}
{customOptions?.pickerRenders?.ButtonList && (
<customOptions.pickerRenders.ButtonList>
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</customOptions.pickerRenders.ButtonList>
)}
</fieldset> </fieldset>
)}
{showAlignActions && !isSingleElementBoundContainer && ( {showAlignActions && !isSingleElementBoundContainer && (
<fieldset> <fieldset>
<legend>{t("labels.align")}</legend> <legend>{t("labels.align")}</legend>
{!customOptions?.pickerRenders?.ButtonList && (
<div className="buttonList"> <div className="buttonList">
{ {
// swap this order for RTL so the button positions always match their action // swap this order for RTL so the button positions always match their action
@ -260,20 +293,69 @@ export const SelectedShapeActions = ({
renderAction("distributeVertically")} renderAction("distributeVertically")}
</div> </div>
</div> </div>
)}
{customOptions?.pickerRenders?.ButtonList && (
<>
<customOptions.pickerRenders.ButtonList>
{
// swap this order for RTL so the button positions always match their action
// (i.e. the leftmost button aligns left)
}
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{targetElements.length > 2 &&
renderAction("distributeHorizontally")}
</customOptions.pickerRenders.ButtonList>
<customOptions.pickerRenders.ButtonList>
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{targetElements.length > 2 &&
renderAction("distributeVertically")}
</customOptions.pickerRenders.ButtonList>
</>
)}
</fieldset> </fieldset>
)} )}
{!isEditingTextOrNewElement && targetElements.length > 0 && ( {!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
{!customOptions?.pickerRenders?.ButtonList && (
<div className="buttonList"> <div className="buttonList">
{!device.editor.isMobile && renderAction("duplicateSelection")} {!device.editor.isMobile && renderAction("duplicateSelection")}
{!device.editor.isMobile && renderAction("deleteSelectedElements")} {!device.editor.isMobile &&
renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")} {showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")} {showLineEditorAction && renderAction("toggleLinearEditor")}
</div> </div>
)}
{customOptions?.pickerRenders?.ButtonList && (
<customOptions.pickerRenders.ButtonList>
{!device.editor.isMobile && renderAction("duplicateSelection")}
{!device.editor.isMobile &&
renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</customOptions.pickerRenders.ButtonList>
)}
</fieldset> </fieldset>
)} )}
</div> </div>

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 {
@ -136,6 +140,7 @@ import {
newLinearElement, newLinearElement,
newTextElement, newTextElement,
refreshTextDimensions, refreshTextDimensions,
newBlurElement,
} from "@excalidraw/element/newElement"; } from "@excalidraw/element/newElement";
import { import {
@ -643,6 +648,9 @@ class App extends React.Component<AppProps, AppState> {
public id: string; public id: string;
private store: Store; private store: Store;
private history: History; private history: History;
public getHistory = () => this.history;
public excalidrawContainerValue: { public excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
id: string; id: string;
@ -804,7 +812,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
this.fonts = new Fonts(this.scene); this.fonts = new Fonts(this.scene);
this.history = new History(); this.history = new History(this.props.customOptions?.onHistoryChange);
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
this.actionManager.registerAction( this.actionManager.registerAction(
@ -813,6 +821,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) {
@ -1651,10 +1679,12 @@ 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-container-inner">
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
@ -1760,7 +1790,8 @@ class App extends React.Component<AppProps, AppState> {
closable={this.state.toast.closable} closable={this.state.toast.closable}
/> />
)} )}
{this.state.contextMenu && ( {this.state.contextMenu &&
!this.props.customOptions?.hideContextMenu && (
<ContextMenu <ContextMenu
items={this.state.contextMenu.items} items={this.state.contextMenu.items}
top={this.state.contextMenu.top} top={this.state.contextMenu.top}
@ -1792,8 +1823,10 @@ class App extends React.Component<AppProps, AppState> {
renderGrid: isGridModeEnabled(this), renderGrid: isGridModeEnabled(this),
canvasBackgroundColor: canvasBackgroundColor:
this.state.viewBackgroundColor, this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus, embedsValidationStatus:
elementsPendingErasure: this.elementsPendingErasure, this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: pendingFlowchartNodes:
this.flowChartCreator.pendingNodes, this.flowChartCreator.pendingNodes,
}} }}
@ -1857,6 +1890,7 @@ class App extends React.Component<AppProps, AppState> {
/> />
)} )}
{this.renderFrameNames()} {this.renderFrameNames()}
</div>
</ExcalidrawActionManagerContext.Provider> </ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()} {this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider> </ExcalidrawElementsContext.Provider>
@ -2252,7 +2286,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
@ -2266,6 +2300,8 @@ class App extends React.Component<AppProps, AppState> {
name, name,
errorMessage, errorMessage,
}; };
return res;
}); });
didUpdate = true; didUpdate = true;
@ -2973,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,
); );
@ -4109,6 +4149,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 (
@ -5986,7 +6030,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
@ -6240,6 +6289,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set(elementsToErase); this.elementsPendingErasure = new Set(elementsToErase);
this.triggerRender(); this.triggerRender();
this.props.customOptions?.onHandleEraser?.(this.elementsPendingErasure);
}; };
// set touch moving for mobile context menu // set touch moving for mobile context menu
@ -7820,7 +7870,8 @@ class App extends React.Component<AppProps, AppState> {
| "diamond" | "diamond"
| "ellipse" | "ellipse"
| "iframe" | "iframe"
| "embeddable", | "embeddable"
| "blur",
) { ) {
return this.state.currentItemRoundness === "round" return this.state.currentItemRoundness === "round"
? { ? {
@ -7832,7 +7883,7 @@ class App extends React.Component<AppProps, AppState> {
} }
private createGenericElementOnPointerDown = ( private createGenericElementOnPointerDown = (
elementType: ExcalidrawGenericElement["type"] | "embeddable", elementType: ExcalidrawGenericElement["type"] | "embeddable" | "blur",
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): void => { ): void => {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
@ -7869,6 +7920,11 @@ class App extends React.Component<AppProps, AppState> {
type: "embeddable", type: "embeddable",
...baseElementAttributes, ...baseElementAttributes,
}); });
} else if (elementType === "blur") {
element = newBlurElement({
...baseElementAttributes,
blur: this.state.currentItemBlur,
});
} else { } else {
element = newElement({ element = newElement({
type: elementType, type: elementType,
@ -8653,7 +8709,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,
@ -10578,7 +10640,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),
scene: this.scene, scene: this.scene,
zoom: this.state.zoom.value, zoom: this.state.zoom.value,
informMutation: false, informMutation: false,
@ -10868,11 +10930,16 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType, transformHandleType,
selectedElements, selectedElements,
this.scene, this.scene,
shouldRotateWithDiscreteAngle(event), (
this.props.customOptions?.shouldRotateWithDiscreteAngle ??
shouldRotateWithDiscreteAngle
)(event),
shouldResizeFromCenter(event), shouldResizeFromCenter(event),
selectedElements.some((element) => isImageElement(element)) selectedElements.some((element) =>
isImageElement(element)
? !shouldMaintainAspectRatio(event) ? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event), : shouldMaintainAspectRatio(event),
),
resizeX, resizeX,
resizeY, resizeY,
pointerDownState.resize.center.x, pointerDownState.resize.center.x,

View file

@ -1,12 +1,12 @@
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> = {
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
props: {
options: { options: {
value: T; value: T;
text: string; text: string;
@ -17,7 +17,7 @@ export const ButtonIconSelect = <T extends Object>(
}[]; }[];
value: T | null; value: T | null;
type?: "radio" | "button"; type?: "radio" | "button";
} & ( } & (
| { type?: "radio"; group: string; onChange: (value: T) => void } | { type?: "radio"; group: string; onChange: (value: T) => void }
| { | {
type: "button"; type: "button";
@ -26,11 +26,38 @@ export const ButtonIconSelect = <T extends Object>(
event: React.MouseEvent<HTMLButtonElement, MouseEvent>, event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void; ) => void;
} }
), );
) => (
<div className="buttonList"> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
{props.options.map((option) => export const ButtonIconSelect = <T extends Object>(
props.type === "button" ? ( 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 <ButtonIcon
key={option.text} key={option.text}
icon={option.icon} icon={option.icon}
@ -39,7 +66,31 @@ 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)}
/> />
) : ( );
};
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 <label
key={option.text} key={option.text}
className={clsx({ active: props.value === option.value })} className={clsx({ active: props.value === option.value })}
@ -54,7 +105,16 @@ export const ButtonIconSelect = <T extends Object>(
/> />
{option.icon} {option.icon}
</label> </label>
), );
};
return (
<div className="buttonList">
{props.options.map((option) =>
props.type === "button"
? renderButtonIcon(option)
: renderRadioButtonIcon(option),
)} )}
</div> </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,16 +223,22 @@ 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 ( return (
<div>
<div role="dialog" aria-modal="true" className="color-picker-container">
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -251,6 +260,20 @@ export const ColorPicker = ({
/> />
)} )}
</Popover.Root> </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>
</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,7 +58,21 @@ 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) => {
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 <button
className={clsx("color-picker__button", { className={clsx("color-picker__button", {
active: color === activeColor, active: color === activeColor,
@ -68,7 +91,8 @@ export const TopPicks = ({
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
</button> </button>
))} );
})}
</div> </div>
); );
}; };

View file

@ -1,12 +1,14 @@
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 React, { useEffect } from "react"; import React, { useContext, useEffect } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common"; import { isArrowKey, KEYS } from "@excalidraw/common";
import { atom, useAtom } from "../editor-jotai"; import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useDevice } from "./App"; import { useDevice } from "./App";
@ -115,10 +117,33 @@ function Picker<T>({
} }
}, [value, alwaysVisibleOptions, setShowMoreOptions]); }, [value, alwaysVisibleOptions, setShowMoreOptions]);
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
const renderOptions = (options: Option<T>[]) => { const renderOptions = (options: Option<T>[]) => {
return ( return (
<div className="picker-content"> <div className="picker-content">
{options.map((option, i) => ( {options.map((option, i) => {
if (customOptions?.pickerRenders?.layerButtonRender) {
return customOptions.pickerRenders.layerButtonRender({
active: value === option.value,
title: option.text,
children: (
<>
{option.icon}
{/* {option.keyBinding && (
<span className="picker-keybinding">
{option.keyBinding}
</span>
)} */}
</>
),
key: option.text,
onClick: () => onChange(option.value),
name: option.text,
});
}
return (
<button <button
type="button" type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
@ -147,7 +172,8 @@ function Picker<T>({
<span className="picker-keybinding">{option.keyBinding}</span> <span className="picker-keybinding">{option.keyBinding}</span>
)} )}
</button> </button>
))} );
})}
</div> </div>
); );
}; };
@ -162,7 +188,7 @@ function Picker<T>({
align="start" align="start"
sideOffset={12} sideOffset={12}
style={{ zIndex: "var(--zIndex-popup)" }} style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={customOptions?.disableKeyEvents ? undefined : handleKeyDown}
> >
<div <div
className={`picker`} className={`picker`}
@ -209,12 +235,15 @@ export function IconPicker<T>({
numberOfOptionsToAlwaysShow?: number; numberOfOptionsToAlwaysShow?: number;
group?: string; group?: string;
}) { }) {
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const rPickerButton = React.useRef<any>(null);
const renderTrigger = () => {
if (customOptions?.pickerRenders?.layerButtonRender) {
return ( return (
<div> <>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger <Popover.Trigger
name={group} name={group}
type="button" type="button"
@ -222,9 +251,30 @@ export function IconPicker<T>({
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton} ref={rPickerButton}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
style={{
padding: 0,
border: "unset",
width: 0,
}}
> >
{options.find((option) => option.value === value)?.icon} {options.find((option) => option.value === value)?.icon}
</Popover.Trigger> </Popover.Trigger>
{customOptions.pickerRenders.layerButtonRender({
name: group,
title: "",
onClick: () => setActive(!isActive),
children: options.find((option) => option.value === value)?.icon,
active: isActive,
})}
</>
);
}
};
return (
<div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
{renderTrigger()}
{isActive && ( {isActive && (
<Picker <Picker
options={options} options={options}

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>
);
return (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
{customOptions?.layoutRenders?.menuRender?.({ children }) ?? children}
</Section> </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}
@ -543,12 +560,14 @@ const LayerUI = ({
> >
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()} {renderFixedSideContainer()}
{!customOptions?.hideFooter && (
<Footer <Footer
appState={appState} appState={appState}
actionManager={actionManager} actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn} showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
)}
{appState.scrolledOutside && ( {appState.scrolledOutside && (
<button <button
type="button" type="button"

View file

@ -1,7 +1,10 @@
import React, { useEffect } from "react"; import React, { useContext, useEffect } from "react";
import { isBlurElement } from "@excalidraw/element/typeChecks";
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,9 +43,22 @@ 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")}
{customOptions?.pickerRenders?.rangeRender ? (
customOptions?.pickerRenders?.rangeRender({
value,
onChange: (value: number) => {
updateData(value);
},
step: 10,
min: 0,
max: 100,
})
) : (
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
ref={rangeRef} ref={rangeRef}
@ -62,6 +78,80 @@ export const Range = ({
</div> </div>
<div className="zero-label">0</div> <div className="zero-label">0</div>
</div> </div>
)}
</label>
);
};
export const BlurRange = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => {
if (isBlurElement(element)) {
return element.blur;
}
return appState.currentItemBlur;
},
true,
appState.currentItemBlur,
);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
}
}, [value]);
const customOptions = useContext(ExcalidrawPropsCustomOptionsContext);
return (
<label className="control-label">
{t("labels.blur")}
{customOptions?.pickerRenders?.rangeRender ? (
customOptions?.pickerRenders?.rangeRender({
value,
onChange: (value: number) => {
updateData(value);
},
step: 10,
min: 0,
max: 100,
})
) : (
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
)}
</label> </label>
); );
}; };

View file

@ -47,6 +47,7 @@ const StaticCanvas = (props: StaticCanvasProps) => {
wrapper.replaceChildren(canvas); wrapper.replaceChildren(canvas);
canvas.classList.add("excalidraw__canvas", "static"); canvas.classList.add("excalidraw__canvas", "static");
canvas.id = "excalidraw__content-canvas";
} }
const widthString = `${props.appState.width}px`; const widthString = `${props.appState.width}px`;

View file

@ -9,8 +9,8 @@ import {
LineIcon, LineIcon,
FreedrawIcon, FreedrawIcon,
TextIcon, TextIcon,
ImageIcon,
EraserIcon, EraserIcon,
ImageIcon,
} from "./icons"; } from "./icons";
export const SHAPES = [ export const SHAPES = [
@ -77,6 +77,13 @@ export const SHAPES = [
numericKey: KEYS["9"], numericKey: KEYS["9"],
fillable: false, fillable: false,
}, },
{
icon: RectangleIcon,
key: null,
value: "blur",
numericKey: KEYS["9"],
fillable: false,
},
{ {
icon: EraserIcon, icon: EraserIcon,
value: "eraser", value: "eraser",

View file

@ -102,6 +102,7 @@ export const AllowedExcalidrawActiveTools: Record<
hand: true, hand: true,
laser: false, laser: false,
magicframe: false, magicframe: false,
blur: true,
}; };
export type RestoredDataState = { export type RestoredDataState = {
@ -383,6 +384,7 @@ const restoreElement = (
// generic elements // generic elements
case "ellipse": case "ellipse":
case "blur":
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "iframe": case "iframe":

View file

@ -20,6 +20,13 @@ export class History {
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
constructor(
private readonly onChange: (
history: History,
type: "undo" | "redo" | "record" | "clear",
) => void = () => {},
) {}
private readonly undoStack: HistoryStack = []; private readonly undoStack: HistoryStack = [];
private readonly redoStack: HistoryStack = []; private readonly redoStack: HistoryStack = [];
@ -34,6 +41,8 @@ export class History {
public clear() { public clear() {
this.undoStack.length = 0; this.undoStack.length = 0;
this.redoStack.length = 0; this.redoStack.length = 0;
this.onChange(this, "clear");
} }
/** /**
@ -43,6 +52,8 @@ export class History {
elementsChange: ElementsChange, elementsChange: ElementsChange,
appStateChange: AppStateChange, appStateChange: AppStateChange,
) { ) {
this.onChange(this, "record");
const entry = HistoryEntry.create(appStateChange, elementsChange); const entry = HistoryEntry.create(appStateChange, elementsChange);
if (!entry.isEmpty()) { if (!entry.isEmpty()) {
@ -67,6 +78,8 @@ export class History {
appState: AppState, appState: AppState,
snapshot: Readonly<Snapshot>, snapshot: Readonly<Snapshot>,
) { ) {
this.onChange(this, "undo");
return this.perform( return this.perform(
elements, elements,
appState, appState,
@ -81,6 +94,8 @@ export class History {
appState: AppState, appState: AppState,
snapshot: Readonly<Snapshot>, snapshot: Readonly<Snapshot>,
) { ) {
this.onChange(this, "redo");
return this.perform( return this.perform(
elements, elements,
appState, appState,

View file

@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@ -16,7 +16,16 @@ 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 {
ExcalidrawHistoryContext,
ExcalidrawPropsCustomOptionsContext,
type AppProps,
type ExcalidrawProps,
} from "./types";
import type { ExcalidrawHistoryContextType } from "./types";
import type { ActionResult } from "./actions/types";
polyfill(); polyfill();
@ -53,6 +62,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
renderEmbeddable, renderEmbeddable,
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
customOptions,
actionRef,
renderScrollbars, renderScrollbars,
} = props; } = props;
@ -109,10 +120,43 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
}; };
}, []); }, []);
const appRef = useRef<App>(null);
const historyRedoRef = useRef<() => void>(undefined);
const historyUndoRef = useRef<() => void>(undefined);
useImperativeHandle(
actionRef,
() => ({
syncActionResult: (actionResult: ActionResult) => {
appRef.current?.syncActionResult(actionResult);
},
getHistory: () => appRef.current?.getHistory(),
historyRedo: () => {
historyRedoRef.current?.();
},
historyUndo: () => {
historyUndoRef.current?.();
},
}),
[],
);
const historyContextValue = useMemo<ExcalidrawHistoryContextType>(
() => ({
undoRef: historyUndoRef,
redoRef: historyRedoRef,
}),
[],
);
return ( return (
<ExcalidrawHistoryContext.Provider value={historyContextValue}>
<ExcalidrawPropsCustomOptionsContext.Provider value={customOptions}>
<EditorJotaiProvider store={editorJotaiStore}> <EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}> <InitializeApp langCode={langCode} theme={theme}>
<App <App
ref={appRef}
onChange={onChange} onChange={onChange}
initialData={initialData} initialData={initialData}
excalidrawAPI={excalidrawAPI} excalidrawAPI={excalidrawAPI}
@ -144,12 +188,15 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
renderEmbeddable={renderEmbeddable} renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
customOptions={customOptions}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
> >
{children} {children}
</App> </App>
</InitializeApp> </InitializeApp>
</EditorJotaiProvider> </EditorJotaiProvider>
</ExcalidrawPropsCustomOptionsContext.Provider>
</ExcalidrawHistoryContext.Provider>
); );
}; };

View file

@ -127,6 +127,7 @@
"unbindText": "Unbind text", "unbindText": "Unbind text",
"bindText": "Bind text to the container", "bindText": "Bind text to the container",
"createContainerFromText": "Wrap text in a container", "createContainerFromText": "Wrap text in a container",
"blur": "Strength",
"link": { "link": {
"edit": "Edit link", "edit": "Edit link",
"editEmbed": "Edit embeddable link", "editEmbed": "Edit embeddable link",
@ -279,6 +280,7 @@
"lasso": "Lasso selection", "lasso": "Lasso selection",
"image": "Insert image", "image": "Insert image",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"blur": "Blur",
"diamond": "Diamond", "diamond": "Diamond",
"ellipse": "Ellipse", "ellipse": "Ellipse",
"arrow": "Arrow", "arrow": "Arrow",
@ -312,6 +314,7 @@
"magicframe": "Wireframe to code", "magicframe": "Wireframe to code",
"embeddable": "Web Embed", "embeddable": "Web Embed",
"selection": "Selection", "selection": "Selection",
"blur": "Blur",
"iframe": "IFrame" "iframe": "IFrame"
}, },
"headings": { "headings": {

View file

@ -44,11 +44,19 @@
"arrowhead_triangle_outline": "三角箭头(空心)", "arrowhead_triangle_outline": "三角箭头(空心)",
"arrowhead_diamond": "菱形", "arrowhead_diamond": "菱形",
"arrowhead_diamond_outline": "菱形(空心)", "arrowhead_diamond_outline": "菱形(空心)",
"arrowhead_crowfoot_many": "交叉箭头(多个)",
"arrowhead_crowfoot_one": "交叉箭头(一个)",
"arrowhead_crowfoot_one_or_many": "交叉箭头(一个或多个)",
"arrowtypes": "箭头类型",
"arrowtype_sharp": "尖锐箭头",
"arrowtype_round": "圆润箭头",
"arrowtype_elbowed": "弯曲箭头",
"fontSize": "字体大小", "fontSize": "字体大小",
"fontFamily": "字体", "fontFamily": "字体",
"addWatermark": "添加 “使用 Excalidraw 创建” 水印", "addWatermark": "添加 “使用 Excalidraw 创建” 水印",
"handDrawn": "手写", "handDrawn": "手写",
"normal": "普通", "normal": "普通",
"more_options": "更多选项",
"code": "代码", "code": "代码",
"small": "小", "small": "小",
"medium": "中", "medium": "中",
@ -113,6 +121,7 @@
"unbindText": "取消文本绑定", "unbindText": "取消文本绑定",
"bindText": "将文本绑定到容器", "bindText": "将文本绑定到容器",
"createContainerFromText": "将文本包围在容器中", "createContainerFromText": "将文本包围在容器中",
"blur": "强度",
"link": { "link": {
"edit": "编辑链接", "edit": "编辑链接",
"editEmbed": "编辑链接与嵌入", "editEmbed": "编辑链接与嵌入",
@ -237,6 +246,7 @@
"selection": "选择", "selection": "选择",
"image": "插入图像", "image": "插入图像",
"rectangle": "矩形", "rectangle": "矩形",
"blur": "模糊",
"diamond": "菱形", "diamond": "菱形",
"ellipse": "椭圆", "ellipse": "椭圆",
"arrow": "箭头", "arrow": "箭头",

View file

@ -44,11 +44,19 @@
"arrowhead_triangle_outline": "三角形(外框)", "arrowhead_triangle_outline": "三角形(外框)",
"arrowhead_diamond": "菱形", "arrowhead_diamond": "菱形",
"arrowhead_diamond_outline": "菱形(外框)", "arrowhead_diamond_outline": "菱形(外框)",
"arrowhead_crowfoot_many": "交叉箭頭(多個)",
"arrowhead_crowfoot_one": "交叉箭頭(一個)",
"arrowhead_crowfoot_one_or_many": "交叉箭頭(一個或多個)",
"arrowtypes": "箭頭類型",
"arrowtype_sharp": "尖銳箭頭",
"arrowtype_round": "圓潤箭頭",
"arrowtype_elbowed": "彎曲箭頭",
"fontSize": "字型大小", "fontSize": "字型大小",
"fontFamily": "字體集", "fontFamily": "字體集",
"addWatermark": "加上 \"Made with Excalidraw\" 浮水印", "addWatermark": "加上 \"Made with Excalidraw\" 浮水印",
"handDrawn": "手寫", "handDrawn": "手寫",
"normal": "一般", "normal": "一般",
"more_options": "更多選項",
"code": "代碼", "code": "代碼",
"small": "小", "small": "小",
"medium": "中", "medium": "中",
@ -113,6 +121,7 @@
"unbindText": "取消綁定文字", "unbindText": "取消綁定文字",
"bindText": "結合文字至容器", "bindText": "結合文字至容器",
"createContainerFromText": "將文字包於容器中", "createContainerFromText": "將文字包於容器中",
"blur": "強度",
"link": { "link": {
"edit": "編輯連結", "edit": "編輯連結",
"editEmbed": "編輯連結&嵌入", "editEmbed": "編輯連結&嵌入",
@ -237,6 +246,7 @@
"selection": "選取", "selection": "選取",
"image": "插入圖片", "image": "插入圖片",
"rectangle": "長方形", "rectangle": "長方形",
"blur": "模糊",
"diamond": "菱形", "diamond": "菱形",
"ellipse": "橢圓", "ellipse": "橢圓",
"arrow": "箭頭", "arrow": "箭頭",

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

@ -152,6 +152,7 @@ const renderElementToSvg = (
} }
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "blur":
case "ellipse": { case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null); const shape = ShapeCache.generateElementShape(element, null);
const node = roughSVGDrawWithPrecision( const node = roughSVGDrawWithPrecision(

View file

@ -145,6 +145,7 @@ export type ElementShape = Drawable | Drawable[] | null;
export type ElementShapes = { export type ElementShapes = {
rectangle: Drawable; rectangle: Drawable;
blur: Drawable;
ellipse: Drawable; ellipse: Drawable;
diamond: Drawable; diamond: Drawable;
iframe: Drawable; iframe: Drawable;

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

@ -8,6 +8,7 @@ import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/comm
import { import {
newArrowElement, newArrowElement,
newBlurElement,
newElement, newElement,
newEmbeddableElement, newEmbeddableElement,
newFrameElement, newFrameElement,
@ -361,6 +362,9 @@ export class API {
case "magicframe": case "magicframe":
element = newMagicFrameElement({ ...base, width, height }); element = newMagicFrameElement({ ...base, width, height });
break; break;
case "blur":
element = newBlurElement({ ...base, width, height, blur: appState.currentItemBlur});
break;
default: default:
assertNever( assertNever(
type, type,

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,10 @@ 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";
import type { History } from "./history";
export type SocketId = string & { _brand: "SocketId" }; export type SocketId = string & { _brand: "SocketId" };
@ -150,7 +159,8 @@ export type ToolType =
| "frame" | "frame"
| "magicframe" | "magicframe"
| "embeddable" | "embeddable"
| "laser"; | "laser"
| "blur";
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom"; export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
@ -325,6 +335,7 @@ export interface AppState {
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"]; currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemRoughness: number; currentItemRoughness: number;
currentItemOpacity: number; currentItemOpacity: number;
currentItemBlur: number;
currentItemFontFamily: FontFamilyValues; currentItemFontFamily: FontFamilyValues;
currentItemFontSize: number; currentItemFontSize: number;
currentItemTextAlign: TextAlign; currentItemTextAlign: TextAlign;
@ -512,6 +523,104 @@ export type OnUserFollowedPayload = {
action: "FOLLOW" | "UNFOLLOW"; action: "FOLLOW" | "UNFOLLOW";
}; };
export interface ExcalidrawPropsCustomOptions {
disableKeyEvents?: boolean;
hideMainToolbar?: boolean;
hideMenu?: boolean;
hideFooter?: boolean;
hideContextMenu?: boolean;
onHistoryChange?: (
history: History,
type: "undo" | "redo" | "record" | "clear",
) => void;
shouldResizeFromCenter?: (event: MouseEvent | KeyboardEvent) => boolean;
shouldMaintainAspectRatio?: (event: MouseEvent | KeyboardEvent) => boolean;
shouldRotateWithDiscreteAngle?: (
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
) => boolean;
shouldSnapping?: (event: KeyboardModifiersObject) => boolean;
onHandleEraser?: (elements: Set<ExcalidrawElement["id"]>) => void;
layoutRenders?: {
menuRender?: (props: { children: React.ReactNode }) => React.ReactNode;
};
pickerRenders?: {
ButtonList?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
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;
visible?: boolean;
hidden?: boolean;
key?: string;
active?: boolean;
}) => 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;
getHistory: () => History | undefined;
historyRedo: () => void;
historyUndo: () => void;
}
export interface ExcalidrawHistoryContextType {
undoRef: React.RefObject<(() => void) | undefined>;
redoRef: React.RefObject<(() => void) | undefined>;
}
export const ExcalidrawHistoryContext = createContext<
ExcalidrawHistoryContextType | undefined
>(undefined);
export interface ExcalidrawProps { export interface ExcalidrawProps {
onChange?: ( onChange?: (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
@ -601,6 +710,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>;
renderScrollbars?: boolean; renderScrollbars?: boolean;
} }

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"
} }
} }

View file

@ -39,6 +39,7 @@ import { getElementAbsoluteCoords } from "@excalidraw/element/bounds";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawBlurElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
@ -107,6 +108,7 @@ type RectangularElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement | ExcalidrawEmbeddableElement
| ExcalidrawBlurElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawIframeElement | ExcalidrawIframeElement
| ExcalidrawTextElement | ExcalidrawTextElement