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 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..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; } @@ -5352,37 +5334,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, { @@ -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/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/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[]>;