feat: split gridSize from enabled state & support custom gridStep (#8364)

This commit is contained in:
David Luzar 2024-08-14 14:59:14 +02:00 committed by GitHub
parent 4320a3cf41
commit 3cfcc7b489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 737 additions and 278 deletions

View file

@ -60,7 +60,6 @@ import {
ENV,
EVENT,
FRAME_STYLE,
GRID_SIZE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT,
isBrave,
@ -258,6 +257,7 @@ import type {
UnsubscribeCallback,
EmbedsValidationStatus,
ElementsPendingErasure,
NullableGridSize,
} from "../types";
import {
debounce,
@ -661,7 +661,7 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled,
zenModeEnabled,
objectsSnapModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
gridModeEnabled: gridModeEnabled ?? defaultAppState.gridModeEnabled,
name,
width: window.innerWidth,
height: window.innerHeight,
@ -812,6 +812,18 @@ class App extends React.Component<AppProps, AppState> {
}
}
/**
* Returns gridSize taking into account `gridModeEnabled`.
* If disabled, returns null.
*/
public getEffectiveGridSize = () => {
return (
this.props.gridModeEnabled ?? this.state.gridModeEnabled
? this.state.gridSize
: null
) as NullableGridSize;
};
private getHTMLIFrameElement(
element: ExcalidrawIframeLikeElement,
): HTMLIFrameElement | undefined {
@ -1684,7 +1696,9 @@ class App extends React.Component<AppProps, AppState> {
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: true,
renderGrid:
this.props.gridModeEnabled ??
this.state.gridModeEnabled,
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
@ -2171,7 +2185,6 @@ class App extends React.Component<AppProps, AppState> {
if (actionResult.appState || editingElement || this.state.contextMenu) {
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
const theme =
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
const name = actionResult?.appState?.name ?? this.state.name;
@ -2185,10 +2198,6 @@ class App extends React.Component<AppProps, AppState> {
zenModeEnabled = this.props.zenModeEnabled;
}
if (typeof this.props.gridModeEnabled !== "undefined") {
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
}
editingElement = actionResult.appState?.editingElement || null;
// make sure editingElement points to latest element reference
@ -2220,7 +2229,6 @@ class App extends React.Component<AppProps, AppState> {
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
@ -2777,12 +2785,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ theme: this.props.theme });
}
if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
this.setState({
gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
});
}
this.excalidrawContainerRef.current?.classList.toggle(
"theme--dark",
this.state.theme === THEME.DARK,
@ -3185,7 +3187,7 @@ class App extends React.Component<AppProps, AppState> {
const dx = x - elementsCenterX;
const dy = y - elementsCenterY;
const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
const newElements = duplicateElements(
elements.map((element) => {
@ -3570,7 +3572,10 @@ class App extends React.Component<AppProps, AppState> {
* Zooms on canvas viewport center
*/
zoomCanvas = (
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
/**
* Decimal fraction, auto-clamped between MIN_ZOOM and MAX_ZOOM.
* 1 = 100% zoom, 2 = 200% zoom, 0.5 = 50% zoom
*/
value: number,
) => {
this.setState({
@ -4148,10 +4153,10 @@ class App extends React.Component<AppProps, AppState> {
? elbowArrow.startBinding || elbowArrow.endBinding
? 0
: ELEMENT_TRANSLATE_AMOUNT
: (this.state.gridSize &&
: (this.getEffectiveGridSize() &&
(event.shiftKey
? ELEMENT_TRANSLATE_AMOUNT
: this.state.gridSize)) ||
: this.getEffectiveGridSize())) ||
(event.shiftKey
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT);
@ -5496,7 +5501,7 @@ class App extends React.Component<AppProps, AppState> {
event,
scenePointerX,
scenePointerY,
this.state,
this,
this.scene.getNonDeletedElementsMap(),
);
@ -5586,7 +5591,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
);
const [lastCommittedX, lastCommittedY] =
@ -6553,7 +6558,7 @@ class App extends React.Component<AppProps, AppState> {
origin.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
),
),
scrollbars: isOverScrollBars(
@ -6730,7 +6735,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement || this.state.selectedLinearElement;
const ret = LinearElementEditor.handlePointerDown(
event,
this.state,
this,
this.store,
pointerDownState.origin,
linearElementEditor,
@ -7093,7 +7098,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
);
const element = newIframeElement({
@ -7133,7 +7138,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
);
const embedLink = getEmbedLink(link);
@ -7186,7 +7191,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
);
const topLayerFrame = addToFrameUnderCursor
@ -7283,7 +7288,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@ -7404,7 +7409,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@ -7462,7 +7467,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
: this.getEffectiveGridSize(),
);
const constructorOpts = {
@ -7598,7 +7603,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
// for arrows/lines, don't start dragging until a given threshold
@ -7645,7 +7650,7 @@ class App extends React.Component<AppProps, AppState> {
const ret = LinearElementEditor.addMidpoint(
this.state.selectedLinearElement,
pointerCoords,
this.state,
this,
!event[KEYS.CTRL_OR_CMD],
elementsMap,
);
@ -7688,7 +7693,7 @@ class App extends React.Component<AppProps, AppState> {
const didDrag = LinearElementEditor.handlePointDragging(
event,
this.state,
this,
pointerCoords.x,
pointerCoords.y,
(element, pointsSceneCoords) => {
@ -7822,7 +7827,7 @@ class App extends React.Component<AppProps, AppState> {
dragOffset,
this.scene,
snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
this.setState({
@ -9794,7 +9799,7 @@ class App extends React.Component<AppProps, AppState> {
let [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const image =
@ -9898,7 +9903,7 @@ class App extends React.Component<AppProps, AppState> {
let [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const frameElementsOffsetsMap = new Map<
@ -9929,7 +9934,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const dragOffset = {

View file

@ -360,7 +360,7 @@ const LayerUI = ({
)}
{shouldShowStats && (
<Stats
scene={app.scene}
app={app}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}

View file

@ -0,0 +1,67 @@
import StatsDragInput from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getStepSizedValue } from "./utils";
import { getNormalizedGridStep } from "../../scene";
interface PositionProps {
property: "gridStep";
scene: Scene;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}
const STEP_SIZE = 5;
const CanvasGrid = ({
property,
scene,
appState,
setAppState,
}: PositionProps) => {
return (
<StatsDragInput
label="Grid step"
sensitivity={8}
elements={[]}
dragInputCallback={({
nextValue,
instantChange,
shouldChangeByStepSize,
setInputValue,
}) => {
setAppState((state) => {
let nextGridStep;
if (nextValue) {
nextGridStep = nextValue;
} else if (instantChange) {
nextGridStep = shouldChangeByStepSize
? getStepSizedValue(
state.gridStep + STEP_SIZE * Math.sign(instantChange),
STEP_SIZE,
)
: state.gridStep + instantChange;
}
if (!nextGridStep) {
setInputValue(state.gridStep);
return null;
}
nextGridStep = getNormalizedGridStep(nextGridStep);
setInputValue(nextGridStep);
return {
gridStep: nextGridStep,
};
});
}}
scene={scene}
value={appState.gridStep}
property={property}
appState={appState}
/>
);
};
export default CanvasGrid;

View file

@ -18,7 +18,8 @@
flex-shrink: 0;
border: 1px solid var(--default-border-color);
border-right: 0;
width: 2rem;
padding: 0 0.5rem 0 0.75rem;
min-width: 1rem;
height: 2rem;
box-sizing: border-box;
color: var(--popup-text-color);

View file

@ -29,6 +29,7 @@ export type DragInputCallbackType<
nextValue?: number;
property: P;
originalAppState: AppState;
setInputValue: (value: number) => void;
}) => void;
interface StatsDragInputProps<
@ -45,6 +46,8 @@ interface StatsDragInputProps<
property: T;
scene: Scene;
appState: AppState;
/** how many px you need to drag to get 1 unit change */
sensitivity?: number;
}
const StatsDragInput = <
@ -61,6 +64,7 @@ const StatsDragInput = <
property,
scene,
appState,
sensitivity = 1,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
@ -126,6 +130,7 @@ const StatsDragInput = <
nextValue: rounded,
property,
originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
@ -172,6 +177,8 @@ const StatsDragInput = <
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
document.body.classList.add("excalidraw-cursor-resize");
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
@ -196,35 +203,43 @@ const StatsDragInput = <
const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize");
let accumulatedChange = 0;
let stepChange = 0;
const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (
lastPointer &&
originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null
originalElements !== null
) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
dragInputCallback({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
});
if (instantChange !== 0) {
stepChange += instantChange;
if (Math.abs(stepChange) >= sensitivity) {
stepChange =
Math.sign(stepChange) *
Math.floor(Math.abs(stepChange) / sensitivity);
accumulatedChange += stepChange;
dragInputCallback({
accumulatedChange,
instantChange: stepChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
});
stepChange = 0;
}
}
}
lastPointer = {
@ -246,7 +261,8 @@ const StatsDragInput = <
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = null;
accumulatedChange = 0;
stepChange = 0;
originalElements = null;
originalElementsMap = null;

View file

@ -2,7 +2,11 @@ import { useEffect, useMemo, useState, memo } from "react";
import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import type { AppState, ExcalidrawProps } from "../../types";
import type {
AppClassProperties,
AppState,
ExcalidrawProps,
} from "../../types";
import { CloseIcon } from "../icons";
import { Island } from "../Island";
import { throttle } from "lodash";
@ -16,17 +20,17 @@ import MultiFontSize from "./MultiFontSize";
import Position from "./Position";
import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
interface StatsProps {
scene: Scene;
app: AppClassProperties;
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}
@ -35,11 +39,13 @@ const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState();
const sceneNonce = props.scene.getSceneNonce() || 1;
const selectedElements = props.scene.getSelectedElements({
const sceneNonce = props.app.scene.getSceneNonce() || 1;
const selectedElements = props.app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
const gridModeEnabled =
props.app.props.gridModeEnabled ?? appState.gridModeEnabled;
return (
<StatsInner
@ -47,6 +53,7 @@ export const Stats = (props: StatsProps) => {
appState={appState}
sceneNonce={sceneNonce}
selectedElements={selectedElements}
gridModeEnabled={gridModeEnabled}
/>
);
};
@ -97,17 +104,20 @@ Stats.StatsRows = StatsRows;
export const StatsInner = memo(
({
scene,
app,
onClose,
renderCustomStats,
selectedElements,
appState,
sceneNonce,
gridModeEnabled,
}: StatsProps & {
sceneNonce: number;
selectedElements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
gridModeEnabled: boolean;
}) => {
const scene = app.scene;
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
const setAppState = useExcalidrawSetAppState();
@ -189,6 +199,19 @@ export const StatsInner = memo(
<div>{t("stats.height")}</div>
<div>{sceneDimension.height}</div>
</StatsRow>
{gridModeEnabled && (
<>
<StatsRow heading>Canvas</StatsRow>
<StatsRow>
<CanvasGrid
property="gridStep"
scene={scene}
appState={appState}
setAppState={setAppState}
/>
</StatsRow>
</>
)}
</StatsRows>
{renderCustomStats?.(elements, appState)}
@ -362,7 +385,9 @@ export const StatsInner = memo(
return (
prev.sceneNonce === next.sceneNonce &&
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels
prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
);
},
);

View file

@ -41,7 +41,8 @@ export type StatsInputProperty =
| "width"
| "height"
| "angle"
| "fontSize";
| "fontSize"
| "gridStep";
export const SMALLEST_DELTA = 0.01;

View file

@ -101,6 +101,7 @@ const getRelevantAppStateProps = (
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,