Add typing to action register()

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-07-29 18:03:38 +02:00
parent 0beba1c150
commit a5c6befdb8
9 changed files with 169 additions and 113 deletions

View File

@@ -51,7 +51,7 @@ import { register } from "./register";
import type { AppState, Offsets } from "../types";
export const actionChangeViewBackgroundColor = register({
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
trackEvent: false,
@@ -64,7 +64,7 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
captureUpdate: !!value.viewBackgroundColor
captureUpdate: !!value?.viewBackgroundColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
@@ -463,7 +463,7 @@ export const actionZoomToFit = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionToggleTheme = register({
export const actionToggleTheme = register<AppState["theme"]>({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK

View File

@@ -20,12 +20,12 @@ import { t } from "../i18n";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register";
export const actionCopy = register({
export const actionCopy = register<ClipboardEvent | null>({
name: "copy",
label: "labels.copy",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
perform: async (elements, appState, event, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
@@ -109,12 +109,12 @@ export const actionPaste = register({
keyTest: undefined,
});
export const actionCut = register({
export const actionCut = register<ClipboardEvent | null>({
name: "cut",
label: "labels.cut",
icon: cutIcon,
trackEvent: { category: "element" },
perform: (elements, appState, event: ClipboardEvent | null, app) => {
perform: (elements, appState, event, app) => {
actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState, null, app);
},

View File

@@ -31,7 +31,9 @@ import "../components/ToolIcon.scss";
import { register } from "./register";
export const actionChangeProjectName = register({
import type { AppState } from "../types";
export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName",
label: "labels.fileTitle",
trackEvent: false,
@@ -51,7 +53,7 @@ export const actionChangeProjectName = register({
),
});
export const actionChangeExportScale = register({
export const actionChangeExportScale = register<AppState["exportScale"]>({
name: "changeExportScale",
label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" },
@@ -101,7 +103,9 @@ export const actionChangeExportScale = register({
},
});
export const actionChangeExportBackground = register({
export const actionChangeExportBackground = register<
AppState["exportBackground"]
>({
name: "changeExportBackground",
label: "imageExportDialog.label.withBackground",
trackEvent: { category: "export", action: "toggleBackground" },
@@ -121,7 +125,9 @@ export const actionChangeExportBackground = register({
),
});
export const actionChangeExportEmbedScene = register({
export const actionChangeExportEmbedScene = register<
AppState["exportEmbedScene"]
>({
name: "changeExportEmbedScene",
label: "imageExportDialog.tooltip.embedScene",
trackEvent: { category: "export", action: "embedScene" },
@@ -288,7 +294,9 @@ export const actionLoadScene = register({
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
});
export const actionExportWithDarkMode = register({
export const actionExportWithDarkMode = register<
AppState["exportWithDarkMode"]
>({
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },

View File

@@ -43,7 +43,12 @@ import { register } from "./register";
import type { AppState } from "../types";
export const actionFinalize = register({
type FormData = {
event: PointerEvent;
sceneCoords: { x: number; y: number };
};
export const actionFinalize = register<FormData>({
name: "finalize",
label: "",
trackEvent: false,

View File

@@ -2,6 +2,8 @@ import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element";
import { invariant } from "@excalidraw/common";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import {
@@ -16,12 +18,17 @@ import { register } from "./register";
import type { GoToCollaboratorComponentProps } from "../components/UserList";
import type { Collaborator } from "../types";
export const actionGoToCollaborator = register({
export const actionGoToCollaborator = register<Collaborator>({
name: "goToCollaborator",
label: "Go to a collaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator: Collaborator) => {
perform: (_elements, appState, collaborator) => {
invariant(
collaborator,
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
);
if (
!collaborator.socketId ||
appState.userToFollow?.socketId === collaborator.socketId ||

View File

@@ -1,4 +1,5 @@
import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@@ -21,6 +22,7 @@ import {
getLineHeight,
isTransparent,
reduceToCommonValue,
invariant,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -292,13 +294,15 @@ const changeFontSize = (
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({
export const actionChangeStrokeColor = register<
Pick<AppState, "currentItemStrokeColor">
>({
name: "changeStrokeColor",
label: "labels.stroke",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
...(value?.currentItemStrokeColor && {
elements: changeProperty(
elements,
appState,
@@ -316,7 +320,7 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
captureUpdate: !!value.currentItemStrokeColor
captureUpdate: !!value?.currentItemStrokeColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
@@ -346,12 +350,14 @@ export const actionChangeStrokeColor = register({
),
});
export const actionChangeBackgroundColor = register({
export const actionChangeBackgroundColor = register<
Pick<AppState, "currentItemBackgroundColor" | "viewBackgroundColor">
>({
name: "changeBackgroundColor",
label: "labels.changeBackground",
trackEvent: false,
perform: (elements, appState, value, app) => {
if (!value.currentItemBackgroundColor) {
if (!value?.currentItemBackgroundColor) {
return {
appState: {
...appState,
@@ -423,7 +429,7 @@ export const actionChangeBackgroundColor = register({
),
});
export const actionChangeFillStyle = register({
export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
name: "changeFillStyle",
label: "labels.fill",
trackEvent: false,
@@ -503,7 +509,9 @@ export const actionChangeFillStyle = register({
},
});
export const actionChangeStrokeWidth = register({
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
@@ -559,7 +567,7 @@ export const actionChangeStrokeWidth = register({
),
});
export const actionChangeSloppiness = register({
export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
name: "changeSloppiness",
label: "labels.sloppiness",
trackEvent: false,
@@ -613,7 +621,9 @@ export const actionChangeSloppiness = register({
),
});
export const actionChangeStrokeStyle = register({
export const actionChangeStrokeStyle = register<
ExcalidrawElement["strokeStyle"]
>({
name: "changeStrokeStyle",
label: "labels.strokeStyle",
trackEvent: false,
@@ -666,7 +676,7 @@ export const actionChangeStrokeStyle = register({
),
});
export const actionChangeOpacity = register({
export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
name: "changeOpacity",
label: "labels.opacity",
trackEvent: false,
@@ -690,78 +700,89 @@ export const actionChangeOpacity = register({
),
});
export const actionChangeFontSize = register({
name: "changeFontSize",
label: "labels.fontSize",
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value);
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
{
name: "changeFontSize",
label: "labels.fontSize",
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(
elements,
appState,
app,
() => {
invariant(value, "actionChangeFontSize: Expected a font size value");
return value;
},
value,
);
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
);
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
@@ -821,7 +842,10 @@ type ChangeFontFamilyData = Partial<
resetContainers?: true;
};
export const actionChangeFontFamily = register({
export const actionChangeFontFamily = register<{
currentItemFontFamily: any;
currentHoveredFontFamily: any;
}>({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
@@ -858,6 +882,8 @@ export const actionChangeFontFamily = register({
};
}
invariant(value, "actionChangeFontFamily: value must be defined");
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nextCaptureUpdateAction: CaptureUpdateActionType =
@@ -1191,7 +1217,7 @@ export const actionChangeFontFamily = register({
},
});
export const actionChangeTextAlign = register({
export const actionChangeTextAlign = register<TextAlign>({
name: "changeTextAlign",
label: "Change text alignment",
trackEvent: false,
@@ -1283,7 +1309,7 @@ export const actionChangeTextAlign = register({
},
});
export const actionChangeVerticalAlign = register({
export const actionChangeVerticalAlign = register<VerticalAlign>({
name: "changeVerticalAlign",
label: "Change vertical alignment",
trackEvent: { category: "element" },
@@ -1375,7 +1401,7 @@ export const actionChangeVerticalAlign = register({
},
});
export const actionChangeRoundness = register({
export const actionChangeRoundness = register<"sharp" | "round">({
name: "changeRoundness",
label: "Change edge roundness",
trackEvent: false,
@@ -1532,15 +1558,16 @@ const getArrowheadOptions = (flip: boolean) => {
] as const;
};
export const actionChangeArrowhead = register({
export const actionChangeArrowhead = register<{
position: "start" | "end";
type: Arrowhead;
}>({
name: "changeArrowhead",
label: "Change arrowheads",
trackEvent: false,
perform: (
elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
perform: (elements, appState, value) => {
invariant(value, "actionChangeArrowhead: value must be defined");
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
@@ -1616,7 +1643,7 @@ export const actionChangeArrowhead = register({
},
});
export const actionChangeArrowType = register({
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,

View File

@@ -2,7 +2,12 @@ import type { Action } from "./types";
export let actions: readonly Action[] = [];
export const register = <T extends Action>(action: T) => {
export const register = <
TData extends any,
T extends Action<TData> = Action<TData>,
>(
action: T,
) => {
actions = actions.concat(action);
return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];

View File

@@ -32,10 +32,10 @@ export type ActionResult =
}
| false;
type ActionFn = (
type ActionFn<TData = any> = (
elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
formData: TData | undefined,
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
@@ -158,7 +158,7 @@ export type PanelComponentProps = {
) => React.JSX.Element | null;
};
export interface Action {
export interface Action<TData = any> {
name: ActionName;
label:
| string
@@ -175,7 +175,7 @@ export interface Action {
elements: readonly ExcalidrawElement[],
) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
perform: ActionFn<TData>;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,

View File

@@ -8009,7 +8009,11 @@ class App extends React.Component<AppProps, AppState> {
) < LINE_CONFIRM_THRESHOLD)
) {
this.actionManager.executeAction(actionFinalize, "ui", {
event,
event: event.nativeEvent,
sceneCoords: {
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
},
});
return;
}