fix: undo/redo

This commit is contained in:
dwelle 2024-10-10 23:58:28 +02:00
parent 81e1d61d42
commit c498247e0f
10 changed files with 73 additions and 29 deletions

View File

@ -17,13 +17,16 @@ import {
hasBoundTextElement, hasBoundTextElement,
isBindableElement, isBindableElement,
isBoundToContainer, isBoundToContainer,
isImageElement,
isTextElement, isTextElement,
} from "./element/typeChecks"; } from "./element/typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
Ordered,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap, SceneElementsMap,
} from "./element/types"; } from "./element/types";
@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
); );
break; break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId": case "editingGroupId":
const editingGroupId = nextAppState[key]; const editingGroupId = nextAppState[key];
@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds, selectedElementIds,
editingLinearElementId, editingLinearElementId,
selectedLinearElementId, selectedLinearElementId,
croppingElementId,
...standaloneProps ...standaloneProps
} = delta as ObservedAppState; } = delta as ObservedAppState;
@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
} }
} }
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">; type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
}); });
} }
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) { if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change // strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;

View File

@ -5173,15 +5173,19 @@ class App extends React.Component<AppProps, AppState> {
}; };
private startImageCropping = (image: ExcalidrawImageElement) => { private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement();
this.setState({ this.setState({
croppingElementId: image.id, croppingElementId: image.id,
}); });
}; };
private finishImageCropping = () => { private finishImageCropping = () => {
if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement();
this.setState({ this.setState({
croppingElementId: null, croppingElementId: null,
}); });
}
}; };
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (

View File

@ -11,6 +11,7 @@ import {
vectorScale, vectorScale,
pointFromVector, pointFromVector,
clamp, clamp,
isCloseTo,
} from "../../math"; } from "../../math";
import type { TransformHandleType } from "./transformHandles"; import type { TransformHandleType } from "./transformHandles";
import type { import type {
@ -64,12 +65,14 @@ export const cropElement = (
let nextWidth = element.width; let nextWidth = element.width;
let nextHeight = element.height; let nextHeight = element.height;
const crop = element.crop ?? {
let crop: ImageCrop | null = element.crop ?? {
x: 0, x: 0,
y: 0, y: 0,
width: naturalWidth, width: naturalWidth,
height: naturalHeight, height: naturalHeight,
naturalDimension: [naturalWidth, naturalHeight], naturalWidth,
naturalHeight,
}; };
const previousCropHeight = crop.height; const previousCropHeight = crop.height;
@ -138,6 +141,14 @@ export const cropElement = (
nextHeight, nextHeight,
); );
// reset crop to null if we're back to orig size
if (
isCloseTo(crop.width, crop.naturalWidth) &&
isCloseTo(crop.height, crop.naturalHeight)
) {
crop = null;
}
return { return {
x: newOrigin[0], x: newOrigin[0],
y: newOrigin[1], y: newOrigin[1],
@ -242,12 +253,12 @@ export const getUncroppedImageElement = (
topLeftVector, topLeftVector,
vectorScale( vectorScale(
topEdgeNormalized, topEdgeNormalized,
(-cropX * width) / element.crop.naturalDimension[0], (-cropX * width) / element.crop.naturalWidth,
), ),
), ),
vectorScale( vectorScale(
leftEdgeNormalized, leftEdgeNormalized,
(-cropY * height) / element.crop.naturalDimension[1], (-cropY * height) / element.crop.naturalHeight,
), ),
); );
@ -282,9 +293,9 @@ export const getUncroppedImageElement = (
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => { export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
if (element.crop) { if (element.crop) {
const width = const width =
element.width / (element.crop.width / element.crop.naturalDimension[0]); element.width / (element.crop.width / element.crop.naturalWidth);
const height = const height =
element.height / (element.crop.height / element.crop.naturalDimension[1]); element.height / (element.crop.height / element.crop.naturalHeight);
return { return {
width, width,
@ -309,11 +320,11 @@ const adjustCropPosition = (
const flipY = scale[1] === -1; const flipY = scale[1] === -1;
if (flipX) { if (flipX) {
cropX = crop.naturalDimension[0] - Math.abs(cropX) - crop.width; cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
} }
if (flipY) { if (flipY) {
cropY = crop.naturalDimension[1] - Math.abs(cropY) - crop.height; cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
} }
return { return {

View File

@ -137,7 +137,8 @@ export type ImageCrop = {
y: number; y: number;
width: number; width: number;
height: number; height: number;
naturalDimension: [number, number]; naturalWidth: number;
naturalHeight: number;
}; };
export type ExcalidrawImageElement = _ExcalidrawElementBase & export type ExcalidrawImageElement = _ExcalidrawElementBase &

View File

@ -428,11 +428,9 @@ const renderElementToSvg = (
symbol.setAttribute( symbol.setAttribute(
"viewBox", "viewBox",
`${ `${
element.crop.x / element.crop.x / (element.crop.naturalWidth / uncroppedWidth)
(element.crop.naturalDimension[0] / uncroppedWidth)
} ${ } ${
element.crop.y / element.crop.y / (element.crop.naturalHeight / uncroppedHeight)
(element.crop.naturalDimension[1] / uncroppedHeight)
} ${width} ${height}`, } ${width} ${height}`,
); );
image.setAttribute("width", `${uncroppedWidth}`); image.setAttribute("width", `${uncroppedWidth}`);

View File

@ -21,6 +21,7 @@ export const getObservedAppState = (appState: AppState): ObservedAppState => {
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null, editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null, selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

@ -3,7 +3,7 @@ import ReactDOM from "react-dom";
import { vi } from "vitest"; import { vi } from "vitest";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import type { ExcalidrawImageElement, ImageCrop } from "../element/types"; import type { ExcalidrawImageElement, ImageCrop } from "../element/types";
import { GlobalTestState, render } from "./test-utils"; import { act, GlobalTestState, render } from "./test-utils";
import { Excalidraw, exportToCanvas, exportToSvg } from ".."; import { Excalidraw, exportToCanvas, exportToSvg } from "..";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import type { NormalizedZoomValue } from "../types"; import type { NormalizedZoomValue } from "../types";
@ -60,15 +60,7 @@ const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => {
const propA = cropA[key]; const propA = cropA[key];
const propB = cropB[key]; const propB = cropB[key];
if (key === "naturalDimension") {
const [naturalWidthA, naturalHeightA] = propA as [number, number];
const [naturalWidthB, naturalHeightB] = propB as [number, number];
expect(naturalWidthA).toBeCloseTo(naturalWidthB);
expect(naturalHeightA).toBeCloseTo(naturalHeightB);
} else {
expect(propA as number).toBeCloseTo(propB as number); expect(propA as number).toBeCloseTo(propB as number);
}
}); });
}; };
@ -158,7 +150,9 @@ describe("Cropping and other features", async () => {
]); ]);
Keyboard.keyDown(KEYS.ESCAPE); Keyboard.keyDown(KEYS.ESCAPE);
const duplicatedImage = duplicateElement(null, new Map(), image, {}); const duplicatedImage = duplicateElement(null, new Map(), image, {});
act(() => {
h.app.scene.insertElement(duplicatedImage); h.app.scene.insertElement(duplicatedImage);
});
expect(duplicatedImage.width).toBe(image.width); expect(duplicatedImage.width).toBe(image.width);
expect(duplicatedImage.height).toBe(image.height); expect(duplicatedImage.height).toBe(image.height);

View File

@ -590,7 +590,7 @@ export class UI {
clientY + mouseMove[1], clientY + mouseMove[1],
); );
mutateElement(element, mutations); API.updateElement(element, mutations);
} }
static rotate( static rotate(

View File

@ -224,6 +224,7 @@ export type ObservedElementsAppState = {
editingLinearElementId: LinearElementEditor["elementId"] | null; editingLinearElementId: LinearElementEditor["elementId"] | null;
// Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId // Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId
selectedLinearElementId: LinearElementEditor["elementId"] | null; selectedLinearElementId: LinearElementEditor["elementId"] | null;
croppingElementId: AppState["croppingElementId"];
}; };
export interface AppState { export interface AppState {

View File

@ -28,3 +28,6 @@ export const average = (a: number, b: number) => (a + b) / 2;
export const isFiniteNumber = (value: any): value is number => { export const isFiniteNumber = (value: any): value is number => {
return typeof value === "number" && Number.isFinite(value); return typeof value === "number" && Number.isFinite(value);
}; };
export const isCloseTo = (a: number, b: number, precision = PRECISION) =>
Math.abs(a - b) < precision;