feats: 为 snow-shot 适配功能

This commit is contained in:
chao 2025-04-24 01:16:48 +08:00
parent 82525f6c13
commit 5652b8e01b
35 changed files with 942 additions and 286 deletions

View file

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

View file

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

View file

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

View file

@ -46,6 +46,7 @@ import type {
ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
ExcalidrawBlurElement,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
@ -212,6 +213,25 @@ export const newMagicFrameElement = (
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 */
const getTextElementPositionOffsets = (
opts: {

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align";
import { useContext } from "react";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align";
@ -27,9 +29,14 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
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 = (
appState: UIAppState,
@ -87,7 +94,24 @@ export const actionAlignTop = register({
},
keyTest: (event) =>
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
hidden={!alignActionsPredicate(appState, app)}
type="button"
@ -97,9 +121,13 @@ export const actionAlignTop = register({
"CtrlOrCmd+Shift+Up",
)}`}
aria-label={t("labels.alignTop")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/>
),
);
},
});
export const actionAlignBottom = register({
@ -121,7 +149,24 @@ export const actionAlignBottom = register({
},
keyTest: (event) =>
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
hidden={!alignActionsPredicate(appState, app)}
type="button"
@ -131,9 +176,13 @@ export const actionAlignBottom = register({
"CtrlOrCmd+Shift+Down",
)}`}
aria-label={t("labels.alignBottom")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/>
),
);
},
});
export const actionAlignLeft = register({
@ -155,7 +204,24 @@ export const actionAlignLeft = register({
},
keyTest: (event) =>
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
hidden={!alignActionsPredicate(appState, app)}
type="button"
@ -165,9 +231,13 @@ export const actionAlignLeft = register({
"CtrlOrCmd+Shift+Left",
)}`}
aria-label={t("labels.alignLeft")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/>
),
);
},
});
export const actionAlignRight = register({
@ -189,7 +259,23 @@ export const actionAlignRight = register({
},
keyTest: (event) =>
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
hidden={!alignActionsPredicate(appState, app)}
type="button"
@ -199,9 +285,13 @@ export const actionAlignRight = register({
"CtrlOrCmd+Shift+Right",
)}`}
aria-label={t("labels.alignRight")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/>
),
);
},
});
export const actionAlignVerticallyCentered = register({
@ -221,7 +311,24 @@ export const actionAlignVerticallyCentered = register({
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
hidden={!alignActionsPredicate(appState, app)}
type="button"
@ -229,9 +336,13 @@ export const actionAlignVerticallyCentered = register({
onClick={() => updateData(null)}
title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/>
),
);
},
});
export const actionAlignHorizontallyCentered = register({
@ -251,7 +362,24 @@ export const actionAlignHorizontallyCentered = register({
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
hidden={!alignActionsPredicate(appState, app)}
type="button"
@ -259,7 +387,11 @@ export const actionAlignHorizontallyCentered = register({
onClick={() => updateData(null)}
title={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

@ -20,6 +20,8 @@ import {
selectGroupsForSelectedElements,
} from "@excalidraw/element/groups";
import { useContext } from "react";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
@ -28,6 +30,8 @@ import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
@ -310,14 +314,34 @@ export const actionDeleteSelected = register({
keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!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
type="button"
icon={TrashIcon}
title={t("labels.delete")}
aria-label={t("labels.delete")}
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 { useContext } from "react";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element/distribute";
@ -23,9 +25,13 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
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 selectedElements = app.scene.getSelectedElements(appState);
@ -75,7 +81,24 @@ export const distributeHorizontally = register({
},
keyTest: (event) =>
!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
hidden={!enableActionGroup(appState, app)}
type="button"
@ -85,9 +108,13 @@ export const distributeHorizontally = register({
"Alt+H",
)}`}
aria-label={t("labels.distributeHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
/>
),
);
},
});
export const distributeVertically = register({
@ -106,15 +133,38 @@ export const distributeVertically = register({
},
keyTest: (event) =>
!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
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeVerticallyIcon}
onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`}
title={`${t("labels.distributeVertically")}${getShortcutKey(
"Alt+V",
)}`}
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 { useContext } from "react";
import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons";
@ -25,6 +27,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register";
export const actionDuplicateSelection = register({
@ -105,7 +109,23 @@ export const actionDuplicateSelection = register({
};
},
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
type="button"
icon={DuplicateIcon}
@ -114,7 +134,11 @@ export const actionDuplicateSelection = register({
)}`}
aria-label={t("labels.duplicateSelection")}
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 { useContext } from "react";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@ -42,9 +44,13 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
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[]) => {
if (elements.length >= 2) {
@ -195,7 +201,24 @@ export const actionGroup = register({
enableActionGroup(elements, appState, app),
keyTest: (event) =>
!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
hidden={!enableActionGroup(elements, appState, app)}
type="button"
@ -203,9 +226,13 @@ export const actionGroup = register({
onClick={() => updateData(null)}
title={`${t("labels.group")}${getShortcutKey("CtrlOrCmd+G")}`}
aria-label={t("labels.group")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
></ToolButton>
),
);
},
});
export const actionUngroup = register({
@ -304,15 +331,38 @@ export const actionUngroup = register({
event.key === KEYS.G.toUpperCase(),
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
type="button"
hidden={getSelectedGroupIds(appState).length === 0}
icon={<UngroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
title={`${t("labels.ungroup")}${getShortcutKey("CtrlOrCmd+Shift+G")}`}
title={`${t("labels.ungroup")}${getShortcutKey(
"CtrlOrCmd+Shift+G",
)}`}
aria-label={t("labels.ungroup")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
visible={isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)}
></ToolButton>
),
);
},
});

View file

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

View file

@ -2,6 +2,8 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import { KEYS, getShortcutKey } from "@excalidraw/common";
import { useContext } from "react";
import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
@ -10,6 +12,8 @@ import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { ExcalidrawPropsCustomOptionsContext } from "../types";
import { register } from "./register";
export const actionLink = register({
@ -40,6 +44,20 @@ export const actionLink = register({
PanelComponent: ({ elements, appState, updateData }) => {
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 (
<ToolButton
type="button"

View file

@ -134,9 +134,6 @@ import {
} from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { CaptureUpdateActionType } from "../store";
import {
ExcalidrawPropsCustomOptionsContext,
type AppClassProperties,
@ -144,6 +141,10 @@ import {
type Primitive,
} from "../types";
import { register } from "./register";
import type { CaptureUpdateActionType } from "../store";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
export const changeProperty = (

View file

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

View file

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

View file

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

View file

@ -21,7 +21,12 @@ import {
isTextElement,
} from "@excalidraw/element/typeChecks";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element/comparisons";
import {
canChangeBlur,
canChangeLayer,
hasStrokeColor,
toolIsArrow,
} from "@excalidraw/element/comparisons";
import type {
ExcalidrawElement,
@ -88,6 +93,7 @@ export const canChangeStrokeColor = (
(hasStrokeColor(appState.activeTool.type) &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "blur" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))
);
@ -157,6 +163,12 @@ export const SelectedShapeActions = ({
const showAlignActions =
!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 (
<div className="panelColumn">
<div>
@ -212,8 +224,13 @@ export const SelectedShapeActions = ({
<>{renderAction("changeArrowhead")}</>
)}
{(canChangeBlur(appState.activeTool.type) ||
targetElements.some((element) => canChangeBlur(element.type))) &&
renderAction("changeBlur")}
{renderAction("changeOpacity")}
{showLayerActions && (
<fieldset>
<legend>{t("labels.layers")}</legend>
{!customOptions?.pickerRenders?.ButtonList && (
@ -233,10 +250,12 @@ export const SelectedShapeActions = ({
</customOptions.pickerRenders.ButtonList>
)}
</fieldset>
)}
{showAlignActions && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
{!customOptions?.pickerRenders?.ButtonList && (
<div className="buttonList">
{
// swap this order for RTL so the button positions always match their action
@ -274,20 +293,69 @@ export const SelectedShapeActions = ({
renderAction("distributeVertically")}
</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>
)}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
{!customOptions?.pickerRenders?.ButtonList && (
<div className="buttonList">
{!device.editor.isMobile && renderAction("duplicateSelection")}
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
{!device.editor.isMobile &&
renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</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>
)}
</div>

View file

@ -143,6 +143,7 @@ import {
newLinearElement,
newTextElement,
refreshTextDimensions,
newBlurElement,
} from "@excalidraw/element/newElement";
import {
@ -646,6 +647,9 @@ class App extends React.Component<AppProps, AppState> {
public id: string;
private store: Store;
private history: History;
public getHistory = () => this.history;
public excalidrawContainerValue: {
container: HTMLDivElement | null;
id: string;
@ -806,7 +810,7 @@ class App extends React.Component<AppProps, AppState> {
};
this.fonts = new Fonts(this.scene);
this.history = new History();
this.history = new History(this.props.customOptions?.onHistoryChange);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(
@ -2288,19 +2292,6 @@ class App extends React.Component<AppProps, AppState> {
errorMessage,
};
// Print differences between prevAppState and res
const differences = Object.keys(res).filter((key) => {
return (prevAppState as any)[key] !== (res as any)[key];
});
console.log(
"State differences:",
differences.map((key) => ({
key,
prev: (prevAppState as any)[key],
new: (res as any)[key],
})),
);
return res;
});
@ -6270,6 +6261,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set(elementsToErase);
this.triggerRender();
this.props.customOptions?.onHandleEraser?.(this.elementsPendingErasure);
};
// set touch moving for mobile context menu
@ -7850,7 +7842,8 @@ class App extends React.Component<AppProps, AppState> {
| "diamond"
| "ellipse"
| "iframe"
| "embeddable",
| "embeddable"
| "blur",
) {
return this.state.currentItemRoundness === "round"
? {
@ -7862,7 +7855,7 @@ class App extends React.Component<AppProps, AppState> {
}
private createGenericElementOnPointerDown = (
elementType: ExcalidrawGenericElement["type"] | "embeddable",
elementType: ExcalidrawGenericElement["type"] | "embeddable" | "blur",
pointerDownState: PointerDownState,
): void => {
const [gridX, gridY] = getGridPoint(
@ -7899,6 +7892,11 @@ class App extends React.Component<AppProps, AppState> {
type: "embeddable",
...baseElementAttributes,
});
} else if (elementType === "blur") {
element = newBlurElement({
...baseElementAttributes,
blur: this.state.currentItemBlur,
});
} else {
element = newElement({
type: elementType,

View file

@ -1,4 +1,6 @@
import React, { useCallback, useContext, useEffect } from "react";
import React, { useContext, useEffect } from "react";
import { isBlurElement } from "@excalidraw/element/typeChecks";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
@ -80,3 +82,76 @@ export const Range = ({
</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>
);
};

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import React, { useEffect, useImperativeHandle, useRef } from "react";
import React, { useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@ -16,7 +16,15 @@ import "./css/app.scss";
import "./css/styles.scss";
import "./fonts/fonts.css";
import { ExcalidrawPropsCustomOptionsContext, type AppProps, type ExcalidrawProps } from "./types";
import {
ExcalidrawHistoryContext,
ExcalidrawPropsCustomOptionsContext,
type AppProps,
type ExcalidrawProps,
} from "./types";
import type { ExcalidrawHistoryContextType } from "./types";
import type { ActionResult } from "./actions/types";
polyfill();
@ -112,17 +120,37 @@ 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 (
<ExcalidrawHistoryContext.Provider value={historyContextValue}>
<ExcalidrawPropsCustomOptionsContext.Provider value={customOptions}>
<EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}>
@ -166,6 +194,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
</InitializeApp>
</EditorJotaiProvider>
</ExcalidrawPropsCustomOptionsContext.Provider>
</ExcalidrawHistoryContext.Provider>
);
};

View file

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

View file

@ -44,6 +44,7 @@
"arrowhead_triangle_outline": "三角箭头(空心)",
"arrowhead_diamond": "菱形",
"arrowhead_diamond_outline": "菱形(空心)",
"arrowtypes": "箭头类型",
"fontSize": "字体大小",
"fontFamily": "字体",
"addWatermark": "添加 “使用 Excalidraw 创建” 水印",
@ -113,6 +114,7 @@
"unbindText": "取消文本绑定",
"bindText": "将文本绑定到容器",
"createContainerFromText": "将文本包围在容器中",
"blur": "强度",
"link": {
"edit": "编辑链接",
"editEmbed": "编辑链接与嵌入",
@ -237,6 +239,7 @@
"selection": "选择",
"image": "插入图像",
"rectangle": "矩形",
"blur": "模糊",
"diamond": "菱形",
"ellipse": "椭圆",
"arrow": "箭头",

View file

@ -113,6 +113,7 @@
"unbindText": "取消綁定文字",
"bindText": "結合文字至容器",
"createContainerFromText": "將文字包於容器中",
"blur": "強度",
"link": {
"edit": "編輯連結",
"editEmbed": "編輯連結&嵌入",
@ -237,6 +238,7 @@
"selection": "選取",
"image": "插入圖片",
"rectangle": "長方形",
"blur": "模糊",
"diamond": "菱形",
"ellipse": "橢圓",
"arrow": "箭頭",

View file

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

View file

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

View file

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

View file

@ -66,6 +66,8 @@ import type React 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 Collaborator = Readonly<{
@ -157,7 +159,8 @@ export type ToolType =
| "frame"
| "magicframe"
| "embeddable"
| "laser";
| "laser"
| "blur";
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
@ -332,6 +335,7 @@ export interface AppState {
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemRoughness: number;
currentItemOpacity: number;
currentItemBlur: number;
currentItemFontFamily: FontFamilyValues;
currentItemFontSize: number;
currentItemTextAlign: TextAlign;
@ -525,12 +529,17 @@ export interface ExcalidrawPropsCustomOptions {
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;
};
@ -558,6 +567,8 @@ export interface ExcalidrawPropsCustomOptions {
title: string;
children: React.ReactNode;
name: string;
visible?: boolean;
hidden?: boolean;
}) => JSX.Element;
rangeRender?: (props: {
value: number;
@ -594,8 +605,20 @@ export const ExcalidrawPropsCustomOptionsContext = createContext<
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 {
onChange?: (
elements: readonly OrderedExcalidrawElement[],

View file

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