diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 792db29f78..2d8967a4cb 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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(null!); const AppPropsContext = React.createContext(null!); @@ -527,6 +528,8 @@ class App extends React.Component { private iFrameRefs = new Map(); private initializedEmbeds = new Set(); + private elementsPendingErasure: ElementsPendingErasure = new Set(); + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = @@ -1075,7 +1078,11 @@ class App extends React.Component { }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 { renderGrid: true, canvasBackgroundColor: this.state.viewBackgroundColor, + elementsPendingErasure: this.elementsPendingErasure, }} /> { 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 = []; - const distance = distance2d( pointerDownState.lastCoords.x, pointerDownState.lastCoords.y, @@ -5098,7 +5100,7 @@ class App extends React.Component { 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 { 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, - }); + 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); } - } 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; + this.elementsPendingErasure = new Set(this.elementsPendingErasure); + this.onSceneUpdated(); + } }; + // set touch moving for mobile context menu private handleTouchMove = (event: React.TouchEvent) => { invalidateContextMenu = true; @@ -5831,7 +5829,6 @@ class App extends React.Component { boxSelection: { hasOccurred: false, }, - elementIdsToErase: {}, }; } @@ -7815,18 +7812,14 @@ class App extends React.Component { 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 { }); } - 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 ({ diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 2617d46947..94eda49f93 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -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 = ( diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 9bfab7e77d..6220c59da7 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -266,6 +266,7 @@ export const exportToCanvas = async ( imageCache, renderGrid: false, isExporting: true, + elementsPendingErasure: new Set(), }, }); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index b4320866c5..401ab86d53 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -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 = { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2ba9bd68db..3da06bec4b 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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;