fix: do not modify elements while erasing (#7531)

This commit is contained in:
David Luzar 2024-01-11 16:00:07 +01:00 committed by GitHub
parent 3ecf72a507
commit 872973f145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 101 additions and 113 deletions

View File

@ -57,7 +57,6 @@ import {
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
ELEMENT_READY_TO_ERASE_OPACITY,
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
ELEMENT_TRANSLATE_AMOUNT,
ENV,
@ -247,6 +246,7 @@ import {
ToolType,
OnUserFollowedPayload,
UnsubscribeCallback,
ElementsPendingErasure,
} from "../types";
import {
debounce,
@ -402,6 +402,7 @@ import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import FollowMode from "./FollowMode/FollowMode";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { getRenderOpacity } from "../renderer/renderElement";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -527,6 +528,8 @@ class App extends React.Component<AppProps, AppState> {
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
private elementsPendingErasure: ElementsPendingErasure = new Set();
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
@ -1075,7 +1078,11 @@ class App extends React.Component<AppProps, AppState> {
}px) scale(${scale})`
: "none",
display: isVisible ? "block" : "none",
opacity: el.opacity / 100,
opacity: getRenderOpacity(
el,
getContainingFrame(el),
this.elementsPendingErasure,
),
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
el,
@ -1583,6 +1590,7 @@ class App extends React.Component<AppProps, AppState> {
renderGrid: true,
canvasBackgroundColor:
this.state.viewBackgroundColor,
elementsPendingErasure: this.elementsPendingErasure,
}}
/>
<InteractiveCanvas
@ -5062,31 +5070,25 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState,
scenePointer: { x: number; y: number },
) => {
const updateElementIds = (elements: ExcalidrawElement[]) => {
elements.forEach((element) => {
let didChange = false;
const processElements = (elements: ExcalidrawElement[]) => {
for (const element of elements) {
if (element.locked) {
return;
}
idsToUpdate.push(element.id);
if (event.altKey) {
if (
pointerDownState.elementIdsToErase[element.id] &&
pointerDownState.elementIdsToErase[element.id].erase
) {
pointerDownState.elementIdsToErase[element.id].erase = false;
if (this.elementsPendingErasure.delete(element.id)) {
didChange = true;
}
} else if (!pointerDownState.elementIdsToErase[element.id]) {
pointerDownState.elementIdsToErase[element.id] = {
erase: true,
opacity: element.opacity,
};
} else if (!this.elementsPendingErasure.has(element.id)) {
didChange = true;
this.elementsPendingErasure.add(element.id);
}
});
}
};
const idsToUpdate: Array<string> = [];
const distance = distance2d(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
@ -5098,7 +5100,7 @@ class App extends React.Component<AppProps, AppState> {
let samplingInterval = 0;
while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y);
updateElementIds(hitElements);
processElements(hitElements);
// Exit since we reached current point
if (samplingInterval === distance) {
@ -5117,35 +5119,31 @@ class App extends React.Component<AppProps, AppState> {
point.y = nextY;
}
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
const id =
isBoundToContainer(ele) && idsToUpdate.includes(ele.containerId)
? ele.containerId
: ele.id;
if (idsToUpdate.includes(id)) {
if (event.altKey) {
if (
pointerDownState.elementIdsToErase[id] &&
pointerDownState.elementIdsToErase[id].erase === false
) {
return newElementWith(ele, {
opacity: pointerDownState.elementIdsToErase[id].opacity,
});
}
} else {
return newElementWith(ele, {
opacity: ELEMENT_READY_TO_ERASE_OPACITY,
});
}
}
return ele;
});
this.scene.replaceAllElements(elements);
pointerDownState.lastCoords.x = scenePointer.x;
pointerDownState.lastCoords.y = scenePointer.y;
if (didChange) {
for (const element of this.scene.getNonDeletedElements()) {
if (
isBoundToContainer(element) &&
(this.elementsPendingErasure.has(element.id) ||
this.elementsPendingErasure.has(element.containerId))
) {
if (event.altKey) {
this.elementsPendingErasure.delete(element.id);
this.elementsPendingErasure.delete(element.containerId);
} else {
this.elementsPendingErasure.add(element.id);
this.elementsPendingErasure.add(element.containerId);
}
}
}
this.elementsPendingErasure = new Set(this.elementsPendingErasure);
this.onSceneUpdated();
}
};
// set touch moving for mobile context menu
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
invalidateContextMenu = true;
@ -5831,7 +5829,6 @@ class App extends React.Component<AppProps, AppState> {
boxSelection: {
hasOccurred: false,
},
elementIdsToErase: {},
};
}
@ -7815,18 +7812,14 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
hitElements.forEach(
(hitElement) =>
(pointerDownState.elementIdsToErase[hitElement.id] = {
erase: true,
opacity: hitElement.opacity,
}),
hitElements.forEach((hitElement) =>
this.elementsPendingErasure.add(hitElement.id),
);
}
this.eraseElements(pointerDownState);
this.eraseElements();
return;
} else if (Object.keys(pointerDownState.elementIdsToErase).length) {
this.restoreReadyToEraseElements(pointerDownState);
} else if (this.elementsPendingErasure.size) {
this.restoreReadyToEraseElements();
}
if (
@ -8087,65 +8080,32 @@ class App extends React.Component<AppProps, AppState> {
});
}
private restoreReadyToEraseElements = (
pointerDownState: PointerDownState,
) => {
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
if (
pointerDownState.elementIdsToErase[ele.id] &&
pointerDownState.elementIdsToErase[ele.id].erase
) {
return newElementWith(ele, {
opacity: pointerDownState.elementIdsToErase[ele.id].opacity,
});
} else if (
isBoundToContainer(ele) &&
pointerDownState.elementIdsToErase[ele.containerId] &&
pointerDownState.elementIdsToErase[ele.containerId].erase
) {
return newElementWith(ele, {
opacity: pointerDownState.elementIdsToErase[ele.containerId].opacity,
});
} else if (
ele.frameId &&
pointerDownState.elementIdsToErase[ele.frameId] &&
pointerDownState.elementIdsToErase[ele.frameId].erase
) {
return newElementWith(ele, {
opacity: pointerDownState.elementIdsToErase[ele.frameId].opacity,
});
}
return ele;
});
this.scene.replaceAllElements(elements);
private restoreReadyToEraseElements = () => {
this.elementsPendingErasure = new Set();
this.onSceneUpdated();
};
private eraseElements = (pointerDownState: PointerDownState) => {
private eraseElements = () => {
let didChange = false;
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
if (
pointerDownState.elementIdsToErase[ele.id] &&
pointerDownState.elementIdsToErase[ele.id].erase
) {
return newElementWith(ele, { isDeleted: true });
} else if (
isBoundToContainer(ele) &&
pointerDownState.elementIdsToErase[ele.containerId] &&
pointerDownState.elementIdsToErase[ele.containerId].erase
) {
return newElementWith(ele, { isDeleted: true });
} else if (
ele.frameId &&
pointerDownState.elementIdsToErase[ele.frameId] &&
pointerDownState.elementIdsToErase[ele.frameId].erase
this.elementsPendingErasure.has(ele.id) ||
(ele.frameId && this.elementsPendingErasure.has(ele.frameId)) ||
(isBoundToContainer(ele) &&
this.elementsPendingErasure.has(ele.containerId))
) {
didChange = true;
return newElementWith(ele, { isDeleted: true });
}
return ele;
});
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
this.elementsPendingErasure = new Set();
if (didChange) {
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
}
};
private initializeImage = async ({

View File

@ -5,6 +5,7 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawFrameLikeElement,
} from "../element/types";
import {
isTextElement,
@ -36,10 +37,12 @@ import {
BinaryFiles,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
} from "../types";
import { getDefaultAppState } from "../appState";
import {
BOUND_TEXT_PADDING,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES,
@ -94,6 +97,27 @@ const shouldResetImageFilter = (
const getCanvasPadding = (element: ExcalidrawElement) =>
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
export const getRenderOpacity = (
element: ExcalidrawElement,
containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure,
) => {
// multiplying frame opacity with element opacity to combine them
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000;
// if pending erasure, multiply again to combine further
// (so that erasing always results in lower opacity than original)
if (
elementsPendingErasure.has(element.id) ||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
) {
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
}
return opacity;
};
export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement;
@ -269,8 +293,6 @@ const drawElementOnCanvas = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) {
case "rectangle":
case "iframe":
@ -372,7 +394,6 @@ const drawElementOnCanvas = (
}
}
}
context.globalAlpha = 1;
};
export const elementWithCanvasCache = new WeakMap<
@ -595,6 +616,12 @@ export const renderElement = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element),
renderConfig.elementsPendingErasure,
);
switch (element.type) {
case "magicframe":
case "frame": {
@ -831,6 +858,8 @@ export const renderElement = (
throw new Error(`Unimplemented type ${element.type}`);
}
}
context.globalAlpha = 1;
};
const roughSVGDrawWithPrecision = (

View File

@ -266,6 +266,7 @@ export const exportToCanvas = async (
imageCache,
renderGrid: false,
isExporting: true,
elementsPendingErasure: new Set(),
},
});

View File

@ -7,6 +7,7 @@ import {
import {
AppClassProperties,
AppState,
ElementsPendingErasure,
InteractiveCanvasAppState,
StaticCanvasAppState,
} from "../types";
@ -20,6 +21,7 @@ export type StaticCanvasRenderConfig = {
/** when exporting the behavior is slightly different (e.g. we can't use
CSS filters), and we disable render optimizations for best output */
isExporting: boolean;
elementsPendingErasure: ElementsPendingErasure;
};
export type SVGRenderConfig = {

View File

@ -633,12 +633,6 @@ export type PointerDownState = Readonly<{
boxSelection: {
hasOccurred: boolean;
};
elementIdsToErase: {
[key: ExcalidrawElement["id"]]: {
opacity: ExcalidrawElement["opacity"];
erase: boolean;
};
};
}>;
export type UnsubscribeCallback = () => void;
@ -751,3 +745,5 @@ export type Primitive =
| undefined;
export type JSONValue = string | number | boolean | null | object;
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;