Files
2026-09-16 05:31:04 +05:00

451 lines
14 KiB
TypeScript

import {
DEFAULT_CANVAS_BACKGROUND_PICKS,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
CODES,
KEYS,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getCommonBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/common";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconButton } from "../components/IconButton";
import { Tooltip } from "../components/Tooltip";
import {
MoonIcon,
SunIcon,
TrashIcon,
zoomAreaIcon,
ZoomInIcon,
ZoomOutIcon,
ZoomResetIcon,
} from "../components/icons";
import { useAppStateValue } from "../hooks/useAppStateValue";
import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import {
constrainScrollState,
getViewportForZoomWithScrollConstraints,
zoomToFitBounds,
} from "../viewport";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
import type { AppState } from "../types";
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
!appState.viewModeEnabled
);
},
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
captureUpdate: !!value?.viewBackgroundColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
<ColorPicker
palette={null}
topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
data-testid="canvas-background-picker"
elements={elements}
appState={appState}
updateData={updateData}
/>
);
},
});
export const actionClearCanvas = register({
name: "clearCanvas",
label: "labels.clearCanvas",
icon: TrashIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector"
);
},
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
files: {},
theme: appState.theme,
penMode: appState.penMode,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
colorTopPicks: appState.colorTopPicks,
activeTool:
appState.activeTool.type === "image"
? {
...appState.activeTool,
type: app.state.preferredSelectionTool.type,
}
: appState.activeTool,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});
export const actionZoomIn = register({
name: "zoomIn",
label: "buttons.zoomIn",
viewMode: true,
navigation: true,
icon: ZoomInIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (_elements, appState, _, app) => {
app.requestUnfollow();
const nextState = {
...appState,
...getViewportForZoomWithScrollConstraints(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
};
return {
appState: nextState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData }) => {
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
return (
<IconButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={zoomValue >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
/>
);
},
keyTest: (event) =>
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
export const actionZoomOut = register({
name: "zoomOut",
label: "buttons.zoomOut",
icon: ZoomOutIcon,
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (_elements, appState, _, app) => {
app.requestUnfollow();
const nextState = {
...appState,
...getViewportForZoomWithScrollConstraints(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
};
return {
appState: nextState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData }) => {
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
return (
<IconButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={zoomValue <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
/>
);
},
keyTest: (event) =>
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
export const actionResetZoom = register({
name: "resetZoom",
label: "buttons.resetZoom",
icon: ZoomResetIcon,
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (_elements, appState, _, app) => {
app.requestUnfollow();
// reset to 100%, unless a zoom lock floors the zoom higher — then reset to
// the locked minimum zoom (the lock's resting zoom level)
const nextZoom = appState.scrollConstraints?.lockZoom
? appState.scrollConstraints.zoom
: 1;
const nextState = {
...appState,
...getViewportForZoomWithScrollConstraints(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(nextZoom),
},
appState,
),
};
return {
appState: nextState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData }) => {
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
return (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
<IconButton
type="button"
className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
>
{(zoomValue * 100).toFixed(0)}%
</IconButton>
</Tooltip>
);
},
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
// under a viewport lock, zoom-to-fit targets the locked box rather than the
// scene elements
const getScrollConstraintsBounds = (
scrollConstraints: NonNullable<AppState["scrollConstraints"]>,
) => {
return [
scrollConstraints.x,
scrollConstraints.y,
scrollConstraints.x + scrollConstraints.width,
scrollConstraints.y + scrollConstraints.height,
] as Bounds;
};
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
label: "labels.zoomToFitViewport",
icon: zoomAreaIcon,
// with no selection (as is always the case in view mode & when
// non-interactive), fits all elements, or the locked viewport box when
// a viewport lock is active
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (elements, appState, _, app) => {
app.requestUnfollow();
const selectedElements = app.scene.getSelectedElements(appState);
const bounds = selectedElements.length
? getCommonBounds(getNonDeletedElements(selectedElements))
: appState.scrollConstraints
? getScrollConstraintsBounds(appState.scrollConstraints)
: getCommonBounds(getNonDeletedElements(elements));
const result = zoomToFitBounds({
bounds,
appState,
fit: "scale-down",
canvasOffsets: app.viewport.getOffsets(),
});
return {
...result,
// re-clamp so the fit can't escape an active scroll/zoom lock
appState: {
...result.appState,
...constrainScrollState(result.appState),
},
};
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
label: "helpDialog.zoomToSelection",
icon: zoomAreaIcon,
// with no selection (as is always the case in view mode & when
// non-interactive), fits all elements, or the locked viewport box when
// a viewport lock is active
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (elements, appState, _, app) => {
app.requestUnfollow();
const selectedElements = app.scene.getSelectedElements(appState);
const bounds = selectedElements.length
? getCommonBounds(getNonDeletedElements(selectedElements))
: appState.scrollConstraints
? getScrollConstraintsBounds(appState.scrollConstraints)
: getCommonBounds(getNonDeletedElements(elements));
const result = zoomToFitBounds({
bounds,
appState,
fit: "contain",
canvasOffsets: app.viewport.getOffsets(),
});
return {
...result,
// re-clamp so the fit can't escape an active scroll/zoom lock
appState: {
...result.appState,
...constrainScrollState(result.appState),
},
};
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
label: "helpDialog.zoomToFit",
icon: zoomAreaIcon,
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (elements, appState, _, app) => {
app.requestUnfollow();
// under a viewport lock, fits the locked box rather than the elements
const bounds = appState.scrollConstraints
? getScrollConstraintsBounds(appState.scrollConstraints)
: getCommonBounds(getNonDeletedElements(elements));
const result = zoomToFitBounds({
bounds,
appState,
fit: "scale-down",
canvasOffsets: app.viewport.getOffsets(),
});
return {
...result,
// re-clamp so the fit can't escape an active scroll/zoom lock
appState: {
...result.appState,
...constrainScrollState(result.appState),
},
};
},
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionToggleTheme = register<AppState["theme"]>({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK
? "buttons.lightMode"
: "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState, elements) =>
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value, app) => {
const nextTheme =
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
if (app.props.onThemeChange) {
app.props.onThemeChange(nextTheme);
return false;
}
return {
appState: {
...appState,
theme: nextTheme,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D,
predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme;
},
});