fix: undo/redo
This commit is contained in:
parent
81e1d61d42
commit
c498247e0f
@ -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;
|
||||||
|
@ -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 = (
|
||||||
|
@ -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 {
|
||||||
|
@ -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 &
|
||||||
|
@ -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}`);
|
||||||
|
@ -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, {
|
||||||
|
@ -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);
|
||||||
|
@ -590,7 +590,7 @@ export class UI {
|
|||||||
clientY + mouseMove[1],
|
clientY + mouseMove[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
mutateElement(element, mutations);
|
API.updateElement(element, mutations);
|
||||||
}
|
}
|
||||||
|
|
||||||
static rotate(
|
static rotate(
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user