From dff69e91912507bbfcc68b35277cc6031ce5b437 Mon Sep 17 00:00:00 2001 From: jhanma17dev Date: Wed, 9 Apr 2025 10:04:51 -0500 Subject: [PATCH 1/5] chore: Element center point util (#9298) --- packages/common/src/utils.ts | 17 ++++++++- packages/element/src/binding.ts | 46 ++++++------------------- packages/element/src/collision.ts | 21 +++-------- packages/element/src/cropElement.ts | 4 ++- packages/element/src/distance.ts | 18 +++------- packages/element/src/shapes.ts | 3 +- packages/element/src/utils.ts | 12 +++---- packages/excalidraw/tests/helpers/ui.ts | 6 ++-- 8 files changed, 49 insertions(+), 78 deletions(-) diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 7fa98eb2d..54eaa67cc 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,9 +1,10 @@ -import { average } from "@excalidraw/math"; +import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import type { ExcalidrawBindableElement, FontFamilyValues, FontString, + ExcalidrawElement, } from "@excalidraw/element/types"; import type { @@ -1201,3 +1202,17 @@ export const escapeDoubleQuotes = (str: string) => { export const castArray = (value: T | T[]): T[] => Array.isArray(value) ? value : [value]; + +export const elementCenterPoint = ( + element: ExcalidrawElement, + xOffset: number = 0, + yOffset: number = 0, +) => { + const { x, y, width, height } = element; + + const centerXPoint = x + width / 2 + xOffset; + + const centerYPoint = y + height / 2 + yOffset; + + return pointFrom(centerXPoint, centerYPoint); +}; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 7a67cf0a1..5c32e8c81 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -6,6 +6,7 @@ import { invariant, isDevEnv, isTestEnv, + elementCenterPoint, } from "@excalidraw/common"; import { @@ -904,13 +905,7 @@ export const getHeadingForElbowArrowSnap = ( if (!distance) { return vectorToHeading( - vectorFromPoint( - p, - pointFrom( - bindableElement.x + bindableElement.width / 2, - bindableElement.y + bindableElement.height / 2, - ), - ), + vectorFromPoint(p, elementCenterPoint(bindableElement)), ); } @@ -1040,10 +1035,7 @@ export const avoidRectangularCorner = ( element: ExcalidrawBindableElement, p: GlobalPoint, ): GlobalPoint => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { @@ -1140,10 +1132,9 @@ export const snapToMid = ( tolerance: number = 0.05, ): GlobalPoint => { const { x, y, width, height, angle } = element; - const center = pointFrom( - x + width / 2 - 0.1, - y + height / 2 - 0.1, - ); + + const center = elementCenterPoint(element, -0.1, -0.1); + const nonRotated = pointRotateRads(p, center, -angle as Radians); // snap-to-center point is adaptive to element size, but we don't want to go @@ -1228,10 +1219,7 @@ const updateBoundPoint = ( startOrEnd === "startBinding" ? "start" : "end", elementsMap, ).fixedPoint; - const globalMidPoint = pointFrom( - bindableElement.x + bindableElement.width / 2, - bindableElement.y + bindableElement.height / 2, - ); + const globalMidPoint = elementCenterPoint(bindableElement); const global = pointFrom( bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.y + fixedPoint[1] * bindableElement.height, @@ -1275,10 +1263,7 @@ const updateBoundPoint = ( elementsMap, ); - const center = pointFrom( - bindableElement.x + bindableElement.width / 2, - bindableElement.y + bindableElement.height / 2, - ); + const center = elementCenterPoint(bindableElement); const interceptorLength = pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, center) + @@ -1771,10 +1756,7 @@ const determineFocusDistance = ( // Another point on the line, in absolute coordinates (closer to element) b: GlobalPoint, ): number => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); if (pointsEqual(a, b)) { return 0; @@ -1904,10 +1886,7 @@ const determineFocusPoint = ( focus: number, adjacentPoint: GlobalPoint, ): GlobalPoint => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); if (focus === 0) { return center; @@ -2338,10 +2317,7 @@ export const getGlobalFixedPointForBindableElement = ( element.x + element.width * fixedX, element.y + element.height * fixedY, ), - pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ), + elementCenterPoint(element), element.angle, ); }; diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 0fabe9839..07b17bfde 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -1,4 +1,4 @@ -import { isTransparent } from "@excalidraw/common"; +import { isTransparent, elementCenterPoint } from "@excalidraw/common"; import { curveIntersectLineSegment, isPointWithinBounds, @@ -16,7 +16,7 @@ import { } from "@excalidraw/math/ellipse"; import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; -import { getPolygonShape } from "@excalidraw/utils/shape"; +import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape"; import type { GlobalPoint, @@ -26,8 +26,6 @@ import type { Radians, } from "@excalidraw/math"; -import type { GeometricShape } from "@excalidraw/utils/shape"; - import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import { getBoundTextShape, isPathALoop } from "./shapes"; @@ -191,10 +189,7 @@ const intersectRectanguloidWithLineSegment = ( l: LineSegment, offset: number = 0, ): GlobalPoint[] => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); // To emulate a rotated rectangle we rotate the point in the inverse angle // instead. It's all the same distance-wise. const rotatedA = pointRotateRads( @@ -253,10 +248,7 @@ const intersectDiamondWithLineSegment = ( l: LineSegment, offset: number = 0, ): GlobalPoint[] => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); // Rotate the point to the inverse direction to simulate the rotated diamond // points. It's all the same distance-wise. @@ -304,10 +296,7 @@ const intersectEllipseWithLineSegment = ( l: LineSegment, offset: number = 0, ): GlobalPoint[] => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); diff --git a/packages/element/src/cropElement.ts b/packages/element/src/cropElement.ts index dd75f9360..2bc930d66 100644 --- a/packages/element/src/cropElement.ts +++ b/packages/element/src/cropElement.ts @@ -14,6 +14,8 @@ import { } from "@excalidraw/math"; import { type Point } from "points-on-curve"; +import { elementCenterPoint } from "@excalidraw/common"; + import { getElementAbsoluteCoords, getResizedElementAbsoluteCoords, @@ -61,7 +63,7 @@ export const cropElement = ( const rotatedPointer = pointRotateRads( pointFrom(pointerX, pointerY), - pointFrom(element.x + element.width / 2, element.y + element.height / 2), + elementCenterPoint(element), -element.angle as Radians, ); diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index d9db939e4..d261faf7d 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -1,12 +1,13 @@ import { curvePointDistance, distanceToLineSegment, - pointFrom, pointRotateRads, } from "@excalidraw/math"; import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; +import { elementCenterPoint } from "@excalidraw/common"; + import type { GlobalPoint, Radians } from "@excalidraw/math"; import { @@ -53,10 +54,7 @@ const distanceToRectanguloidElement = ( element: ExcalidrawRectanguloidElement, p: GlobalPoint, ) => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); // To emulate a rotated rectangle we rotate the point in the inverse angle // instead. It's all the same distance-wise. const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); @@ -84,10 +82,7 @@ const distanceToDiamondElement = ( element: ExcalidrawDiamondElement, p: GlobalPoint, ): number => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); // Rotate the point to the inverse direction to simulate the rotated diamond // points. It's all the same distance-wise. @@ -115,10 +110,7 @@ const distanceToEllipseElement = ( element: ExcalidrawEllipseElement, p: GlobalPoint, ): number => { - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); return ellipseDistanceFromPoint( // Instead of rotating the ellipse, rotate the point to the inverse angle pointRotateRads(p, center, -element.angle as Radians), diff --git a/packages/element/src/shapes.ts b/packages/element/src/shapes.ts index 1d6e13340..96542c538 100644 --- a/packages/element/src/shapes.ts +++ b/packages/element/src/shapes.ts @@ -4,6 +4,7 @@ import { LINE_CONFIRM_THRESHOLD, ROUNDNESS, invariant, + elementCenterPoint, } from "@excalidraw/common"; import { isPoint, @@ -297,7 +298,7 @@ export const aabbForElement = ( midY: element.y + element.height / 2, }; - const center = pointFrom(bbox.midX, bbox.midY); + const center = elementCenterPoint(element); const [topLeftX, topLeftY] = pointRotateRads( pointFrom(bbox.minX, bbox.minY), center, diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 7042b5d8f..57b1e4346 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -10,6 +10,8 @@ import { type GlobalPoint, } from "@excalidraw/math"; +import { elementCenterPoint } from "@excalidraw/common"; + import type { Curve, LineSegment } from "@excalidraw/math"; import { getCornerRadius } from "./shapes"; @@ -68,10 +70,7 @@ export function deconstructRectanguloidElement( return [sides, []]; } - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); const r = rectangle( pointFrom(element.x, element.y), @@ -254,10 +253,7 @@ export function deconstructDiamondElement( return [[topRight, bottomRight, bottomLeft, topLeft], []]; } - const center = pointFrom( - element.x + element.width / 2, - element.y + element.height / 2, - ); + const center = elementCenterPoint(element); const [top, right, bottom, left]: GlobalPoint[] = [ pointFrom(element.x + topX, element.y + topY), diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 0e5e43367..32de489f1 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -20,7 +20,7 @@ import { isTextElement, isFrameLikeElement, } from "@excalidraw/element/typeChecks"; -import { KEYS, arrayToMap } from "@excalidraw/common"; +import { KEYS, arrayToMap, elementCenterPoint } from "@excalidraw/common"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -151,7 +151,7 @@ export class Keyboard { const getElementPointForSelection = ( element: ExcalidrawElement, ): GlobalPoint => { - const { x, y, width, height, angle } = element; + const { x, y, width, angle } = element; const target = pointFrom( x + (isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2), @@ -166,7 +166,7 @@ const getElementPointForSelection = ( (bounds[1] + bounds[3]) / 2, ); } else { - center = pointFrom(x + width / 2, y + height / 2); + center = elementCenterPoint(element); } if (isTextElement(element)) { From 01304aac498f9b653bb7019cafc7f8a6eb5936c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Sun, 13 Apr 2025 21:21:49 +0200 Subject: [PATCH 2/5] feat: Keep text label horizontal (#9364) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 1 + packages/common/src/utils.ts | 2 + packages/element/src/textElement.ts | 23 ++++- .../excalidraw/actions/actionBoundText.tsx | 4 + packages/excalidraw/components/App.tsx | 62 ++++++------ packages/excalidraw/data/restore.ts | 6 +- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 97 +++++++++++++++++++ 7 files changed, 161 insertions(+), 34 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 7e8c49ea1..7eb36d5d9 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -112,6 +112,7 @@ export const YOUTUBE_STATES = { export const ENV = { TEST: "test", DEVELOPMENT: "development", + PRODUCTION: "production", }; export const CLASSES = { diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 54eaa67cc..b6e9fdd78 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -739,6 +739,8 @@ export const isTestEnv = () => import.meta.env.MODE === ENV.TEST; export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; +export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION; + export const isServerEnv = () => typeof process !== "undefined" && !!process?.env?.NODE_ENV; diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index ea27c318f..55c3f692c 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -6,6 +6,8 @@ import { TEXT_ALIGN, VERTICAL_ALIGN, getFontString, + isProdEnv, + invariant, } from "@excalidraw/common"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -26,6 +28,8 @@ import { isTextElement, } from "./typeChecks"; +import type { Radians } from "../../math/src"; + import type { MaybeTransformHandleType } from "./transformHandles"; import type { ElementsMap, @@ -44,13 +48,25 @@ export const redrawTextBoundingBox = ( informMutation = true, ) => { let maxWidth = undefined; + + if (!isProdEnv()) { + invariant( + !container || !isArrowElement(container) || textElement.angle === 0, + "text element angle must be 0 if bound to arrow container", + ); + } + const boundTextUpdates = { x: textElement.x, y: textElement.y, text: textElement.text, width: textElement.width, height: textElement.height, - angle: container?.angle ?? textElement.angle, + angle: (container + ? isArrowElement(container) + ? 0 + : container.angle + : textElement.angle) as Radians, }; boundTextUpdates.text = textElement.text; @@ -335,7 +351,10 @@ export const getTextElementAngle = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, ) => { - if (!container || isArrowElement(container)) { + if (isArrowElement(container)) { + return 0; + } + if (!container) { return textElement.angle; } return container.angle; diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index ae18c0d98..d08ad341e 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -21,6 +21,7 @@ import { import { hasBoundTextElement, + isArrowElement, isTextBindableContainer, isTextElement, isUsingAdaptiveRadius, @@ -46,6 +47,8 @@ import { CaptureUpdateAction } from "../store"; import { register } from "./register"; +import type { Radians } from "../../math/src"; + import type { AppState } from "../types"; export const actionUnbindText = register({ @@ -155,6 +158,7 @@ export const actionBindText = register({ verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, autoResize: true, + angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians, }); mutateElement(container, { boundElements: (container.boundElements || []).concat({ diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 276cde027..976abfd76 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5352,37 +5352,37 @@ class App extends React.Component { y: sceneY, }); - const element = existingTextElement - ? existingTextElement - : newTextElement({ - x: parentCenterPosition - ? parentCenterPosition.elementCenterX - : sceneX, - y: parentCenterPosition - ? parentCenterPosition.elementCenterY - : sceneY, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - strokeStyle: this.state.currentItemStrokeStyle, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, - text: "", - fontSize, - fontFamily, - textAlign: parentCenterPosition - ? "center" - : this.state.currentItemTextAlign, - verticalAlign: parentCenterPosition - ? VERTICAL_ALIGN.MIDDLE - : DEFAULT_VERTICAL_ALIGN, - containerId: shouldBindToContainer ? container?.id : undefined, - groupIds: container?.groupIds ?? [], - lineHeight, - angle: container?.angle ?? (0 as Radians), - frameId: topLayerFrame ? topLayerFrame.id : null, - }); + const element = + existingTextElement || + newTextElement({ + x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX, + y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + text: "", + fontSize, + fontFamily, + textAlign: parentCenterPosition + ? "center" + : this.state.currentItemTextAlign, + verticalAlign: parentCenterPosition + ? VERTICAL_ALIGN.MIDDLE + : DEFAULT_VERTICAL_ALIGN, + containerId: shouldBindToContainer ? container?.id : undefined, + groupIds: container?.groupIds ?? [], + lineHeight, + angle: container + ? isArrowElement(container) + ? (0 as Radians) + : container.angle + : (0 as Radians), + frameId: topLayerFrame ? topLayerFrame.id : null, + }); if (!existingTextElement && shouldBindToContainer && container) { mutateElement(container, { diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 4f050c922..1811cbb57 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -439,7 +439,7 @@ const repairContainerElement = ( // if defined, lest boundElements is stale !boundElement.containerId ) { - (boundElement as Mutable).containerId = + (boundElement as Mutable).containerId = container.id; } } @@ -464,6 +464,10 @@ const repairBoundElement = ( ? elementsMap.get(boundElement.containerId) : null; + (boundElement as Mutable).angle = ( + isArrowElement(container) ? 0 : container?.angle ?? 0 + ) as Radians; + if (!container) { boundElement.containerId = null; return; diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 959c5a012..0ba1960d6 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -31,6 +31,7 @@ import { mockBoundingClientRect, restoreOriginalGetBoundingClientRect, } from "../tests/test-utils"; +import { actionBindText } from "../actions"; unmountComponent(); @@ -1568,5 +1569,101 @@ describe("textWysiwyg", () => { expect(text.containerId).toBe(null); expect(text.text).toBe("Excalidraw"); }); + + it("should reset the text element angle to the container's when binding to rotated non-arrow container", async () => { + const text = API.createElement({ + type: "text", + text: "Hello World!", + angle: 45, + }); + const rectangle = API.createElement({ + type: "rectangle", + width: 90, + height: 75, + angle: 30, + }); + + API.setElements([rectangle, text]); + + API.setSelectedElements([rectangle, text]); + + h.app.actionManager.executeAction(actionBindText); + + expect(text.angle).toBe(30); + expect(rectangle.angle).toBe(30); + }); + + it("should reset the text element angle to 0 when binding to rotated arrow container", async () => { + const text = API.createElement({ + type: "text", + text: "Hello World!", + angle: 45, + }); + const arrow = API.createElement({ + type: "arrow", + width: 90, + height: 75, + angle: 30, + }); + + API.setElements([arrow, text]); + + API.setSelectedElements([arrow, text]); + + h.app.actionManager.executeAction(actionBindText); + + expect(text.angle).toBe(0); + expect(arrow.angle).toBe(30); + }); + + it("should keep the text label at 0 degrees when used as an arrow label", async () => { + const arrow = API.createElement({ + type: "arrow", + width: 90, + height: 75, + angle: 30, + }); + + API.setElements([arrow]); + API.setSelectedElements([arrow]); + + mouse.doubleClickAt( + arrow.x + arrow.width / 2, + arrow.y + arrow.height / 2, + ); + + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + + expect(h.elements[1].angle).toBe(0); + }); + + it("should keep the text label at the same degrees when used as a non-arrow label", async () => { + const rectangle = API.createElement({ + type: "rectangle", + width: 90, + height: 75, + angle: 30, + }); + + API.setElements([rectangle]); + API.setSelectedElements([rectangle]); + + mouse.doubleClickAt( + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ); + + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + + expect(h.elements[1].angle).toBe(30); + }); }); }); From 6fe7de802014d0c967e3b839ccda7711b1028029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 14 Apr 2025 20:25:18 +0100 Subject: [PATCH 3/5] fix: Add DOCTYPE and XML preamble in exported SVG documents (#9386) * Add DOCTYPE and XML preamble in exported SVG documents * Update packages/excalidraw/data/index.ts --------- Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 3 +++ packages/excalidraw/data/index.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 7eb36d5d9..cd3bd7a15 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -319,6 +319,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; export const SVG_NS = "http://www.w3.org/2000/svg"; +export const SVG_DOCUMENT_PREAMBLE = ` + +`; export const ENCRYPTION_KEY_BITS = 128; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index ac8147e85..93d5f5677 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -5,6 +5,7 @@ import { isFirefox, MIME_TYPES, cloneJSON, + SVG_DOCUMENT_PREAMBLE, } from "@excalidraw/common"; import { getNonDeletedElements } from "@excalidraw/element"; @@ -134,7 +135,11 @@ export const exportCanvas = async ( if (type === "svg") { return fileSave( svgPromise.then((svg) => { - return new Blob([svg.outerHTML], { type: MIME_TYPES.svg }); + // adding SVG preamble so that older software parse the SVG file + // properly + return new Blob([SVG_DOCUMENT_PREAMBLE + svg.outerHTML], { + type: MIME_TYPES.svg, + }); }), { description: "Export to SVG", From 58f7d33d80198a2a80c6d3eb38b3432e062dfbea Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 16 Apr 2025 00:58:45 +1000 Subject: [PATCH 4/5] perf: make eraser great again (#9352) * perf: make eraser great again * lint * refactor and improve perf * lint --- packages/excalidraw/components/App.tsx | 127 ++---------- packages/excalidraw/eraser/index.ts | 239 +++++++++++++++++++++++ packages/excalidraw/lasso/utils.ts | 8 +- packages/excalidraw/tests/lasso.test.tsx | 3 +- packages/math/src/types.ts | 2 + 5 files changed, 259 insertions(+), 120 deletions(-) create mode 100644 packages/excalidraw/eraser/index.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 976abfd76..242fd8e46 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -454,7 +454,6 @@ import { import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { Store, CaptureUpdateAction } from "../store"; -import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; @@ -464,6 +463,8 @@ import { isMaybeMermaidDefinition } from "../mermaid"; import { LassoTrail } from "../lasso"; +import { EraserTrail } from "../eraser"; + import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; @@ -675,26 +676,7 @@ class App extends React.Component { animationFrameHandler = new AnimationFrameHandler(); laserTrails = new LaserTrails(this.animationFrameHandler, this); - eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, { - streamline: 0.2, - size: 5, - keepHead: true, - sizeMapping: (c) => { - const DECAY_TIME = 200; - const DECAY_LENGTH = 10; - const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME); - const l = - (DECAY_LENGTH - - Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / - DECAY_LENGTH; - - return Math.min(easeOut(l), easeOut(t)); - }, - fill: () => - this.state.theme === THEME.LIGHT - ? "rgba(0, 0, 0, 0.2)" - : "rgba(255, 255, 255, 0.2)", - }); + eraserTrail = new EraserTrail(this.animationFrameHandler, this); lassoTrail = new LassoTrail(this.animationFrameHandler, this); onChangeEmitter = new Emitter< @@ -1676,8 +1658,8 @@ class App extends React.Component { {selectedElements.length === 1 && @@ -5163,7 +5145,7 @@ class App extends React.Component { return elements; } - private getElementHitThreshold() { + getElementHitThreshold() { return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value; } @@ -6219,101 +6201,16 @@ class App extends React.Component { private handleEraser = ( event: PointerEvent, - pointerDownState: PointerDownState, scenePointer: { x: number; y: number }, ) => { - this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y); - - let didChange = false; - - const processedGroups = new Set(); - const nonDeletedElements = this.scene.getNonDeletedElements(); - - const processElements = (elements: ExcalidrawElement[]) => { - for (const element of elements) { - if (element.locked) { - return; - } - - if (event.altKey) { - if (this.elementsPendingErasure.delete(element.id)) { - didChange = true; - } - } else if (!this.elementsPendingErasure.has(element.id)) { - didChange = true; - this.elementsPendingErasure.add(element.id); - } - - // (un)erase groups atomically - if (didChange && element.groupIds?.length) { - const shallowestGroupId = element.groupIds.at(-1)!; - if (!processedGroups.has(shallowestGroupId)) { - processedGroups.add(shallowestGroupId); - const elems = getElementsInGroup( - nonDeletedElements, - shallowestGroupId, - ); - for (const elem of elems) { - if (event.altKey) { - this.elementsPendingErasure.delete(elem.id); - } else { - this.elementsPendingErasure.add(elem.id); - } - } - } - } - } - }; - - const distance = pointDistance( - pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y), - pointFrom(scenePointer.x, scenePointer.y), + const elementsToErase = this.eraserTrail.addPointToPath( + scenePointer.x, + scenePointer.y, + event.altKey, ); - const threshold = this.getElementHitThreshold(); - const p = { ...pointerDownState.lastCoords }; - let samplingInterval = 0; - while (samplingInterval <= distance) { - const hitElements = this.getElementsAtPosition(p.x, p.y); - processElements(hitElements); - // Exit since we reached current point - if (samplingInterval === distance) { - break; - } - - // Calculate next point in the line at a distance of sampling interval - samplingInterval = Math.min(samplingInterval + threshold, distance); - - const distanceRatio = samplingInterval / distance; - const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x; - const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y; - p.x = nextX; - p.y = nextY; - } - - 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.triggerRender(); - } + this.elementsPendingErasure = new Set(elementsToErase); + this.triggerRender(); }; // set touch moving for mobile context menu @@ -8159,7 +8056,7 @@ class App extends React.Component { } if (isEraserActive(this.state)) { - this.handleEraser(event, pointerDownState, pointerCoords); + this.handleEraser(event, pointerCoords); return; } diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts new file mode 100644 index 000000000..a9f9103f5 --- /dev/null +++ b/packages/excalidraw/eraser/index.ts @@ -0,0 +1,239 @@ +import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; +import { getElementLineSegments } from "@excalidraw/element/bounds"; +import { + lineSegment, + lineSegmentIntersectionPoints, + pointFrom, +} from "@excalidraw/math"; + +import { getElementsInGroup } from "@excalidraw/element/groups"; + +import { getElementShape } from "@excalidraw/element/shapes"; +import { shouldTestInside } from "@excalidraw/element/collision"; +import { isPointInShape } from "@excalidraw/utils/collision"; +import { + hasBoundTextElement, + isBoundToContainer, +} from "@excalidraw/element/typeChecks"; +import { getBoundTextElementId } from "@excalidraw/element/textElement"; + +import type { GeometricShape } from "@excalidraw/utils/shape"; +import type { + ElementsSegmentsMap, + GlobalPoint, + LineSegment, +} from "@excalidraw/math/types"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; + +import { AnimatedTrail } from "../animated-trail"; + +import type { AnimationFrameHandler } from "../animation-frame-handler"; + +import type App from "../components/App"; + +// just enough to form a segment; this is sufficient for eraser +const POINTS_ON_TRAIL = 2; + +export class EraserTrail extends AnimatedTrail { + private elementsToErase: Set = new Set(); + private groupsToErase: Set = new Set(); + private segmentsCache: Map[]> = new Map(); + private geometricShapesCache: Map> = + new Map(); + + constructor(animationFrameHandler: AnimationFrameHandler, app: App) { + super(animationFrameHandler, app, { + streamline: 0.2, + size: 5, + keepHead: true, + sizeMapping: (c) => { + const DECAY_TIME = 200; + const DECAY_LENGTH = 10; + const t = Math.max( + 0, + 1 - (performance.now() - c.pressure) / DECAY_TIME, + ); + const l = + (DECAY_LENGTH - + Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / + DECAY_LENGTH; + + return Math.min(easeOut(l), easeOut(t)); + }, + fill: () => + app.state.theme === THEME.LIGHT + ? "rgba(0, 0, 0, 0.2)" + : "rgba(255, 255, 255, 0.2)", + }); + } + + startPath(x: number, y: number): void { + this.endPath(); + super.startPath(x, y); + this.elementsToErase.clear(); + } + + addPointToPath(x: number, y: number, restore = false) { + super.addPointToPath(x, y); + + const elementsToEraser = this.updateElementsToBeErased(restore); + + return elementsToEraser; + } + + private updateElementsToBeErased(restoreToErase?: boolean) { + let eraserPath: GlobalPoint[] = + super + .getCurrentTrail() + ?.originalPoints?.map((p) => pointFrom(p[0], p[1])) || []; + + // for efficiency and avoid unnecessary calculations, + // take only POINTS_ON_TRAIL points to form some number of segments + eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); + + const visibleElementsMap = arrayToMap(this.app.visibleElements); + + const pathSegments = eraserPath.reduce((acc, point, index) => { + if (index === 0) { + return acc; + } + acc.push(lineSegment(eraserPath[index - 1], point)); + return acc; + }, [] as LineSegment[]); + + if (pathSegments.length === 0) { + return []; + } + + for (const element of this.app.visibleElements) { + // restore only if already added to the to-be-erased set + if (restoreToErase && this.elementsToErase.has(element.id)) { + const intersects = eraserTest( + pathSegments, + element, + this.segmentsCache, + this.geometricShapesCache, + visibleElementsMap, + this.app, + ); + + if (intersects) { + const shallowestGroupId = element.groupIds.at(-1)!; + + if (this.groupsToErase.has(shallowestGroupId)) { + const elementsInGroup = getElementsInGroup( + this.app.scene.getNonDeletedElementsMap(), + shallowestGroupId, + ); + for (const elementInGroup of elementsInGroup) { + this.elementsToErase.delete(elementInGroup.id); + } + this.groupsToErase.delete(shallowestGroupId); + } + + if (isBoundToContainer(element)) { + this.elementsToErase.delete(element.containerId); + } + + if (hasBoundTextElement(element)) { + const boundText = getBoundTextElementId(element); + + if (boundText) { + this.elementsToErase.delete(boundText); + } + } + + this.elementsToErase.delete(element.id); + } + } else if (!restoreToErase && !this.elementsToErase.has(element.id)) { + const intersects = eraserTest( + pathSegments, + element, + this.segmentsCache, + this.geometricShapesCache, + visibleElementsMap, + this.app, + ); + + if (intersects) { + const shallowestGroupId = element.groupIds.at(-1)!; + + if (!this.groupsToErase.has(shallowestGroupId)) { + const elementsInGroup = getElementsInGroup( + this.app.scene.getNonDeletedElementsMap(), + shallowestGroupId, + ); + + for (const elementInGroup of elementsInGroup) { + this.elementsToErase.add(elementInGroup.id); + } + this.groupsToErase.add(shallowestGroupId); + } + + if (hasBoundTextElement(element)) { + const boundText = getBoundTextElementId(element); + + if (boundText) { + this.elementsToErase.add(boundText); + } + } + + if (isBoundToContainer(element)) { + this.elementsToErase.add(element.containerId); + } + + this.elementsToErase.add(element.id); + } + } + } + + return Array.from(this.elementsToErase); + } + + endPath(): void { + super.endPath(); + super.clearTrails(); + this.elementsToErase.clear(); + this.groupsToErase.clear(); + this.segmentsCache.clear(); + } +} + +const eraserTest = ( + pathSegments: LineSegment[], + element: ExcalidrawElement, + elementsSegments: ElementsSegmentsMap, + shapesCache = new Map>(), + visibleElementsMap = new Map(), + app: App, +): boolean => { + let shape = shapesCache.get(element.id); + + if (!shape) { + shape = getElementShape(element, visibleElementsMap); + shapesCache.set(element.id, shape); + } + + const lastPoint = pathSegments[pathSegments.length - 1][1]; + if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) { + return true; + } + + let elementSegments = elementsSegments.get(element.id); + + if (!elementSegments) { + elementSegments = getElementLineSegments(element, visibleElementsMap); + elementsSegments.set(element.id, elementSegments); + } + + return pathSegments.some((pathSegment) => + elementSegments?.some( + (elementSegment) => + lineSegmentIntersectionPoints( + pathSegment, + elementSegment, + app.getElementHitThreshold(), + ) !== null, + ), + ); +}; diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts index f5a7eefdc..d05f39998 100644 --- a/packages/excalidraw/lasso/utils.ts +++ b/packages/excalidraw/lasso/utils.ts @@ -7,11 +7,13 @@ import { polygonIncludesPointNonZero, } from "@excalidraw/math"; -import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; +import type { + ElementsSegmentsMap, + GlobalPoint, + LineSegment, +} from "@excalidraw/math/types"; import type { ExcalidrawElement } from "@excalidraw/element/types"; -export type ElementsSegmentsMap = Map[]>; - export const getLassoSelectedElementIds = (input: { lassoPath: GlobalPoint[]; elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index 00e0ec2b4..aa32b13d6 100644 --- a/packages/excalidraw/tests/lasso.test.tsx +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -19,6 +19,7 @@ import { type LocalPoint, pointFrom, type Radians, + type ElementsSegmentsMap, } from "@excalidraw/math"; import { getElementLineSegments } from "@excalidraw/element/bounds"; @@ -33,8 +34,6 @@ import { getLassoSelectedElementIds } from "../lasso/utils"; import { act, render } from "./test-utils"; -import type { ElementsSegmentsMap } from "../lasso/utils"; - const { h } = window; beforeEach(async () => { diff --git a/packages/math/src/types.ts b/packages/math/src/types.ts index a2a575bd7..da7d5d6ab 100644 --- a/packages/math/src/types.ts +++ b/packages/math/src/types.ts @@ -138,3 +138,5 @@ export type Ellipse = { } & { _brand: "excalimath_ellipse"; }; + +export type ElementsSegmentsMap = Map[]>; From 0cf36d6b30fc84085aa903460a04d0d568518f30 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:28:56 +0200 Subject: [PATCH 5/5] fix: erasing locked elements (#9400) * fix: erasing locked elements * signature tweaks --- packages/excalidraw/eraser/index.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts index a9f9103f5..2ea668aef 100644 --- a/packages/excalidraw/eraser/index.ts +++ b/packages/excalidraw/eraser/index.ts @@ -23,7 +23,7 @@ import type { GlobalPoint, LineSegment, } from "@excalidraw/math/types"; -import type { ExcalidrawElement } from "@excalidraw/element/types"; +import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import { AnimatedTrail } from "../animated-trail"; @@ -91,7 +91,11 @@ export class EraserTrail extends AnimatedTrail { // take only POINTS_ON_TRAIL points to form some number of segments eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); - const visibleElementsMap = arrayToMap(this.app.visibleElements); + const candidateElements = this.app.visibleElements.filter( + (el) => !el.locked, + ); + + const candidateElementsMap = arrayToMap(candidateElements); const pathSegments = eraserPath.reduce((acc, point, index) => { if (index === 0) { @@ -105,7 +109,7 @@ export class EraserTrail extends AnimatedTrail { return []; } - for (const element of this.app.visibleElements) { + for (const element of candidateElements) { // restore only if already added to the to-be-erased set if (restoreToErase && this.elementsToErase.has(element.id)) { const intersects = eraserTest( @@ -113,7 +117,7 @@ export class EraserTrail extends AnimatedTrail { element, this.segmentsCache, this.geometricShapesCache, - visibleElementsMap, + candidateElementsMap, this.app, ); @@ -151,7 +155,7 @@ export class EraserTrail extends AnimatedTrail { element, this.segmentsCache, this.geometricShapesCache, - visibleElementsMap, + candidateElementsMap, this.app, ); @@ -203,14 +207,14 @@ const eraserTest = ( pathSegments: LineSegment[], element: ExcalidrawElement, elementsSegments: ElementsSegmentsMap, - shapesCache = new Map>(), - visibleElementsMap = new Map(), + shapesCache: Map>, + elementsMap: ElementsMap, app: App, ): boolean => { let shape = shapesCache.get(element.id); if (!shape) { - shape = getElementShape(element, visibleElementsMap); + shape = getElementShape(element, elementsMap); shapesCache.set(element.id, shape); } @@ -222,7 +226,7 @@ const eraserTest = ( let elementSegments = elementsSegments.get(element.id); if (!elementSegments) { - elementSegments = getElementLineSegments(element, visibleElementsMap); + elementSegments = getElementLineSegments(element, elementsMap); elementsSegments.set(element.id, elementSegments); }