451 lines
14 KiB
TypeScript
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;
|
|
},
|
|
});
|