diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 7e8c49ea1..cd3bd7a15 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 = { @@ -318,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/common/src/utils.ts b/packages/common/src/utils.ts index 1e6bb9081..935ec549f 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 { @@ -738,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; @@ -1201,3 +1204,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 a1c61e4ba..6917dfea0 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 { @@ -887,13 +888,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)), ); } @@ -1022,10 +1017,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) { @@ -1122,10 +1114,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 @@ -1209,10 +1200,7 @@ const updateBoundPoint = ( bindableElement, startOrEnd === "startBinding" ? "start" : "end", ).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, @@ -1256,10 +1244,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) + @@ -1755,10 +1740,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; @@ -1888,10 +1870,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; @@ -2322,10 +2301,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/textElement.ts b/packages/element/src/textElement.ts index 4a905ce5d..8ec0ef426 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -6,12 +6,16 @@ import { TEXT_ALIGN, VERTICAL_ALIGN, getFontString, + isProdEnv, + invariant, } from "@excalidraw/common"; import type { AppState } from "@excalidraw/excalidraw/types"; import type { ExtractSetType } from "@excalidraw/common/utility-types"; +import type { Radians } from "@excalidraw/math"; + import { resetOriginalContainerCache, updateOriginalContainerCache, @@ -47,13 +51,25 @@ export const redrawTextBoundingBox = ( const elementsMap = scene.getNonDeletedElementsMap(); 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; @@ -343,7 +359,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/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/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index d556745f9..c7843656c 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -21,6 +21,7 @@ import { import { hasBoundTextElement, + isArrowElement, isTextBindableContainer, isTextElement, isUsingAdaptiveRadius, @@ -41,6 +42,8 @@ import type { import type { Mutable } from "@excalidraw/common/utility-types"; +import type { Radians } from "@excalidraw/math"; + import { CaptureUpdateAction } from "../store"; import { register } from "./register"; @@ -154,6 +157,7 @@ export const actionBindText = register({ verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, autoResize: true, + angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians, }); app.scene.mutateElement(container, { boundElements: (container.boundElements || []).concat({ diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c660234cb..83f56c8a6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -455,7 +455,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"; @@ -465,6 +464,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"; @@ -676,26 +677,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< @@ -1678,8 +1660,8 @@ class App extends React.Component { {selectedElements.length === 1 && @@ -5178,7 +5160,7 @@ class App extends React.Component { return elements; } - private getElementHitThreshold() { + getElementHitThreshold() { return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value; } @@ -5370,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) { this.scene.mutateElement(container, { @@ -6244,101 +6226,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 @@ -8184,7 +8081,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/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", 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/eraser/index.ts b/packages/excalidraw/eraser/index.ts new file mode 100644 index 000000000..2ea668aef --- /dev/null +++ b/packages/excalidraw/eraser/index.ts @@ -0,0 +1,243 @@ +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 { ElementsMap, 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 candidateElements = this.app.visibleElements.filter( + (el) => !el.locked, + ); + + const candidateElementsMap = arrayToMap(candidateElements); + + 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 candidateElements) { + // 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, + candidateElementsMap, + 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, + candidateElementsMap, + 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: Map>, + elementsMap: ElementsMap, + app: App, +): boolean => { + let shape = shapesCache.get(element.id); + + if (!shape) { + shape = getElementShape(element, elementsMap); + 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, elementsMap); + 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/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 377c5fa8c..6ef670aa2 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -19,7 +19,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"; @@ -150,7 +150,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), @@ -165,7 +165,7 @@ const getElementPointForSelection = ( (bounds[1] + bounds[3]) / 2, ); } else { - center = pointFrom(x + width / 2, y + height / 2); + center = elementCenterPoint(element); } if (isTextElement(element)) { 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/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); + }); }); }); 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[]>;