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

pull/7511/head^2
David Luzar 1 year ago committed by GitHub
parent 3ecf72a507
commit 872973f145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -5,6 +5,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawFrameLikeElement,
} from "../element/types"; } from "../element/types";
import { import {
isTextElement, isTextElement,
@ -36,10 +37,12 @@ import {
BinaryFiles, BinaryFiles,
Zoom, Zoom,
InteractiveCanvasAppState, InteractiveCanvasAppState,
ElementsPendingErasure,
} from "../types"; } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE, FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
@ -94,6 +97,27 @@ const shouldResetImageFilter = (
const getCanvasPadding = (element: ExcalidrawElement) => const getCanvasPadding = (element: ExcalidrawElement) =>
element.type === "freedraw" ? element.strokeWidth * 12 : 20; 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 { export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement; element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@ -269,8 +293,6 @@ const drawElementOnCanvas = (
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe": case "iframe":
@ -372,7 +394,6 @@ const drawElementOnCanvas = (
} }
} }
} }
context.globalAlpha = 1;
}; };
export const elementWithCanvasCache = new WeakMap< export const elementWithCanvasCache = new WeakMap<
@ -595,6 +616,12 @@ export const renderElement = (
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element),
renderConfig.elementsPendingErasure,
);
switch (element.type) { switch (element.type) {
case "magicframe": case "magicframe":
case "frame": { case "frame": {
@ -831,6 +858,8 @@ export const renderElement = (
throw new Error(`Unimplemented type ${element.type}`); throw new Error(`Unimplemented type ${element.type}`);
} }
} }
context.globalAlpha = 1;
}; };
const roughSVGDrawWithPrecision = ( const roughSVGDrawWithPrecision = (

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

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

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

Loading…
Cancel
Save