diff --git a/.env.development b/.env.development index 5f69de146..387ab6204 100644 --- a/.env.development +++ b/.env.development @@ -48,3 +48,6 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS HQIDAQAB' + +# set to true in .env.development.local to disable the prevent unload dialog +VITE_APP_DISABLE_PREVENT_UNLOAD= diff --git a/examples/with-script-in-browser/package.json b/examples/with-script-in-browser/package.json index 3d61f1a1b..2b4311711 100644 --- a/examples/with-script-in-browser/package.json +++ b/examples/with-script-in-browser/package.json @@ -15,7 +15,8 @@ "scripts": { "start": "vite", "build": "vite build", - "build:preview": "yarn build && vite preview --port 5002", + "preview": "vite preview --port 5002", + "build:preview": "yarn build && yarn preview", "build:package": "yarn workspace @excalidraw/excalidraw run build:esm" } } diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 60e9b3008..bb62a0e96 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -608,7 +608,13 @@ const ExcalidrawWrapper = () => { excalidrawAPI.getSceneElements(), ) ) { - preventUnload(event); + if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") { + preventUnload(event); + } else { + console.warn( + "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)", + ); + } } }; window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler); diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index f6f763041..98c66e425 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -301,7 +301,13 @@ class Collab extends PureComponent { // the purpose is to run in immediately after user decides to stay this.saveCollabRoomToFirebase(syncableElements); - preventUnload(event); + if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") { + preventUnload(event); + } else { + console.warn( + "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)", + ); + } } }); diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index f10e742f5..29e5c0430 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -25,7 +25,10 @@ export default defineConfig(({ mode }) => { alias: [ { find: /^@excalidraw\/common$/, - replacement: path.resolve(__dirname, "../packages/common/src/index.ts"), + replacement: path.resolve( + __dirname, + "../packages/common/src/index.ts", + ), }, { find: /^@excalidraw\/common\/(.*?)/, @@ -33,7 +36,10 @@ export default defineConfig(({ mode }) => { }, { find: /^@excalidraw\/element$/, - replacement: path.resolve(__dirname, "../packages/element/src/index.ts"), + replacement: path.resolve( + __dirname, + "../packages/element/src/index.ts", + ), }, { find: /^@excalidraw\/element\/(.*?)/, @@ -41,7 +47,10 @@ export default defineConfig(({ mode }) => { }, { find: /^@excalidraw\/excalidraw$/, - replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"), + replacement: path.resolve( + __dirname, + "../packages/excalidraw/index.tsx", + ), }, { find: /^@excalidraw\/excalidraw\/(.*?)/, @@ -57,7 +66,10 @@ export default defineConfig(({ mode }) => { }, { find: /^@excalidraw\/utils$/, - replacement: path.resolve(__dirname, "../packages/utils/src/index.ts"), + replacement: path.resolve( + __dirname, + "../packages/utils/src/index.ts", + ), }, { find: /^@excalidraw\/utils\/(.*?)/, @@ -213,7 +225,7 @@ export default defineConfig(({ mode }) => { }, ], start_url: "/", - id:"excalidraw", + id: "excalidraw", display: "standalone", theme_color: "#121212", background_color: "#ffffff", diff --git a/packages/common/src/colors.ts b/packages/common/src/colors.ts index 84d04bcf4..4dc45616f 100644 --- a/packages/common/src/colors.ts +++ b/packages/common/src/colors.ts @@ -2,6 +2,8 @@ import oc from "open-color"; import type { Merge } from "./utility-types"; +export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240; + // FIXME can't put to utils.ts rn because of circular dependency const pick = , K extends readonly (keyof R)[]>( source: R, diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 31046f1ef..7e8c49ea1 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -419,6 +419,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([ // use these constants to easily identify reference sites export const TOOL_TYPE = { selection: "selection", + lasso: "lasso", rectangle: "rectangle", diamond: "diamond", ellipse: "ellipse", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 10837a02f..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 { @@ -385,7 +386,7 @@ export const updateActiveTool = ( type: ToolType; } | { type: "custom"; customType: string } - ) & { locked?: boolean }) & { + ) & { locked?: boolean; fromSelection?: boolean }) & { lastActiveToolBeforeEraser?: ActiveTool | null; }, ): AppState["activeTool"] => { @@ -407,6 +408,7 @@ export const updateActiveTool = ( type: data.type, customType: null, locked: data.locked ?? appState.activeTool.locked, + fromSelection: data.fromSelection ?? false, }; }; @@ -1200,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 c84d80577..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 { @@ -55,6 +56,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { isArrowElement, isBindableElement, + isBindingElement, isBoundToContainer, isElbowArrow, isFixedPointBinding, @@ -903,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)), ); } @@ -1039,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) { @@ -1139,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 @@ -1227,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, @@ -1274,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) + @@ -1422,7 +1408,7 @@ const getLinearElementEdgeCoors = ( ); }; -export const fixBindingsAfterDuplication = ( +export const fixDuplicatedBindingsAfterDuplication = ( newElements: ExcalidrawElement[], oldIdToDuplicatedId: Map, duplicatedElementsMap: NonDeletedSceneElementsMap, @@ -1493,6 +1479,196 @@ export const fixBindingsAfterDuplication = ( } }; +const fixReversedBindingsForBindables = ( + original: ExcalidrawBindableElement, + duplicate: ExcalidrawBindableElement, + originalElements: Map, + elementsWithClones: ExcalidrawElement[], + oldIdToDuplicatedId: Map, +) => { + original.boundElements?.forEach((binding, idx) => { + if (binding.type !== "arrow") { + return; + } + + const oldArrow = elementsWithClones.find((el) => el.id === binding.id); + + if (!isBindingElement(oldArrow)) { + return; + } + + if (originalElements.has(binding.id)) { + // Linked arrow is in the selection, so find the duplicate pair + const newArrowId = oldIdToDuplicatedId.get(binding.id) ?? binding.id; + const newArrow = elementsWithClones.find( + (el) => el.id === newArrowId, + )! as ExcalidrawArrowElement; + + mutateElement(newArrow, { + startBinding: + oldArrow.startBinding?.elementId === binding.id + ? { + ...oldArrow.startBinding, + elementId: duplicate.id, + } + : newArrow.startBinding, + endBinding: + oldArrow.endBinding?.elementId === binding.id + ? { + ...oldArrow.endBinding, + elementId: duplicate.id, + } + : newArrow.endBinding, + }); + mutateElement(duplicate, { + boundElements: [ + ...(duplicate.boundElements ?? []).filter( + (el) => el.id !== binding.id && el.id !== newArrowId, + ), + { + type: "arrow", + id: newArrowId, + }, + ], + }); + } else { + // Linked arrow is outside the selection, + // so we move the binding to the duplicate + mutateElement(oldArrow, { + startBinding: + oldArrow.startBinding?.elementId === original.id + ? { + ...oldArrow.startBinding, + elementId: duplicate.id, + } + : oldArrow.startBinding, + endBinding: + oldArrow.endBinding?.elementId === original.id + ? { + ...oldArrow.endBinding, + elementId: duplicate.id, + } + : oldArrow.endBinding, + }); + mutateElement(duplicate, { + boundElements: [ + ...(duplicate.boundElements ?? []), + { + type: "arrow", + id: oldArrow.id, + }, + ], + }); + mutateElement(original, { + boundElements: + original.boundElements?.filter((_, i) => i !== idx) ?? null, + }); + } + }); +}; + +const fixReversedBindingsForArrows = ( + original: ExcalidrawArrowElement, + duplicate: ExcalidrawArrowElement, + originalElements: Map, + bindingProp: "startBinding" | "endBinding", + oldIdToDuplicatedId: Map, + elementsWithClones: ExcalidrawElement[], +) => { + const oldBindableId = original[bindingProp]?.elementId; + + if (oldBindableId) { + if (originalElements.has(oldBindableId)) { + // Linked element is in the selection + const newBindableId = + oldIdToDuplicatedId.get(oldBindableId) ?? oldBindableId; + const newBindable = elementsWithClones.find( + (el) => el.id === newBindableId, + ) as ExcalidrawBindableElement; + mutateElement(duplicate, { + [bindingProp]: { + ...original[bindingProp], + elementId: newBindableId, + }, + }); + mutateElement(newBindable, { + boundElements: [ + ...(newBindable.boundElements ?? []).filter( + (el) => el.id !== original.id && el.id !== duplicate.id, + ), + { + id: duplicate.id, + type: "arrow", + }, + ], + }); + } else { + // Linked element is outside the selection + const originalBindable = elementsWithClones.find( + (el) => el.id === oldBindableId, + ); + if (originalBindable) { + mutateElement(duplicate, { + [bindingProp]: original[bindingProp], + }); + mutateElement(original, { + [bindingProp]: null, + }); + mutateElement(originalBindable, { + boundElements: [ + ...(originalBindable.boundElements?.filter( + (el) => el.id !== original.id, + ) ?? []), + { + id: duplicate.id, + type: "arrow", + }, + ], + }); + } + } + } +}; + +export const fixReversedBindings = ( + originalElements: Map, + elementsWithClones: ExcalidrawElement[], + oldIdToDuplicatedId: Map, +) => { + for (const original of originalElements.values()) { + const duplicate = elementsWithClones.find( + (el) => el.id === oldIdToDuplicatedId.get(original.id), + )!; + + if (isBindableElement(original) && isBindableElement(duplicate)) { + fixReversedBindingsForBindables( + original, + duplicate, + originalElements, + elementsWithClones, + oldIdToDuplicatedId, + ); + } else if (isArrowElement(original) && isArrowElement(duplicate)) { + fixReversedBindingsForArrows( + original, + duplicate, + originalElements, + "startBinding", + oldIdToDuplicatedId, + elementsWithClones, + ); + fixReversedBindingsForArrows( + original, + duplicate, + originalElements, + "endBinding", + oldIdToDuplicatedId, + elementsWithClones, + ); + } + } +}; + export const fixBindingsAfterDeletion = ( sceneElements: readonly ExcalidrawElement[], deletedElements: readonly ExcalidrawElement[], @@ -1580,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; @@ -1713,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; @@ -2147,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/bounds.ts b/packages/element/src/bounds.ts index bab1a1871..9e723c214 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -13,7 +13,10 @@ import { import { getCurvePathOps } from "@excalidraw/utils/shape"; +import { pointsOnBezierCurves } from "points-on-curve"; + import type { + Curve, Degrees, GlobalPoint, LineSegment, @@ -37,6 +40,13 @@ import { isTextElement, } from "./typeChecks"; +import { getElementShape } from "./shapes"; + +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; + import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -45,6 +55,8 @@ import type { NonDeleted, ExcalidrawTextElementWithContainer, ElementsMap, + ExcalidrawRectanguloidElement, + ExcalidrawEllipseElement, } from "./types"; import type { Drawable, Op } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; @@ -254,50 +266,82 @@ export const getElementAbsoluteCoords = ( * that can be used for visual collision detection (useful for frames) * as opposed to bounding box collision detection */ +/** + * Given an element, return the line segments that make up the element. + * + * Uses helpers from /math + */ export const getElementLineSegments = ( element: ExcalidrawElement, elementsMap: ElementsMap, ): LineSegment[] => { + const shape = getElementShape(element, elementsMap); const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( element, elementsMap, ); + const center = pointFrom(cx, cy); - const center: GlobalPoint = pointFrom(cx, cy); - - if (isLinearElement(element) || isFreeDrawElement(element)) { - const segments: LineSegment[] = []; - + if (shape.type === "polycurve") { + const curves = shape.data; + const points = curves + .map((curve) => pointsOnBezierCurves(curve, 10)) + .flat(); let i = 0; - - while (i < element.points.length - 1) { + const segments: LineSegment[] = []; + while (i < points.length - 1) { segments.push( lineSegment( - pointRotateRads( - pointFrom( - element.points[i][0] + element.x, - element.points[i][1] + element.y, - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - element.points[i + 1][0] + element.x, - element.points[i + 1][1] + element.y, - ), - center, - element.angle, - ), + pointFrom(points[i][0], points[i][1]), + pointFrom(points[i + 1][0], points[i + 1][1]), ), ); i++; } return segments; + } else if (shape.type === "polyline") { + return shape.data as LineSegment[]; + } else if (_isRectanguloidElement(element)) { + const [sides, corners] = deconstructRectanguloidElement(element); + const cornerSegments: LineSegment[] = corners + .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) + .flat(); + const rotatedSides = getRotatedSides(sides, center, element.angle); + return [...rotatedSides, ...cornerSegments]; + } else if (element.type === "diamond") { + const [sides, corners] = deconstructDiamondElement(element); + const cornerSegments = corners + .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) + .flat(); + const rotatedSides = getRotatedSides(sides, center, element.angle); + + return [...rotatedSides, ...cornerSegments]; + } else if (shape.type === "polygon") { + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (container && isLinearElement(container)) { + const segments: LineSegment[] = [ + lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)), + lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)), + lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)), + lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)), + ]; + return segments; + } + } + + const points = shape.data as GlobalPoint[]; + const segments: LineSegment[] = []; + for (let i = 0; i < points.length - 1; i++) { + segments.push(lineSegment(points[i], points[i + 1])); + } + return segments; + } else if (shape.type === "ellipse") { + return getSegmentsOnEllipse(element as ExcalidrawEllipseElement); } - const [nw, ne, sw, se, n, s, w, e] = ( + const [nw, ne, sw, se, , , w, e] = ( [ [x1, y1], [x2, y1], @@ -310,28 +354,6 @@ export const getElementLineSegments = ( ] as GlobalPoint[] ).map((point) => pointRotateRads(point, center, element.angle)); - if (element.type === "diamond") { - return [ - lineSegment(n, w), - lineSegment(n, e), - lineSegment(s, w), - lineSegment(s, e), - ]; - } - - if (element.type === "ellipse") { - return [ - lineSegment(n, w), - lineSegment(n, e), - lineSegment(s, w), - lineSegment(s, e), - lineSegment(n, w), - lineSegment(n, e), - lineSegment(s, w), - lineSegment(s, e), - ]; - } - return [ lineSegment(nw, ne), lineSegment(sw, se), @@ -344,6 +366,94 @@ export const getElementLineSegments = ( ]; }; +const _isRectanguloidElement = ( + element: ExcalidrawElement, +): element is ExcalidrawRectanguloidElement => { + return ( + element != null && + (element.type === "rectangle" || + element.type === "image" || + element.type === "iframe" || + element.type === "embeddable" || + element.type === "frame" || + element.type === "magicframe" || + (element.type === "text" && !element.containerId)) + ); +}; + +const getRotatedSides = ( + sides: LineSegment[], + center: GlobalPoint, + angle: Radians, +) => { + return sides.map((side) => { + return lineSegment( + pointRotateRads(side[0], center, angle), + pointRotateRads(side[1], center, angle), + ); + }); +}; + +const getSegmentsOnCurve = ( + curve: Curve, + center: GlobalPoint, + angle: Radians, +): LineSegment[] => { + const points = pointsOnBezierCurves(curve, 10); + let i = 0; + const segments: LineSegment[] = []; + while (i < points.length - 1) { + segments.push( + lineSegment( + pointRotateRads( + pointFrom(points[i][0], points[i][1]), + center, + angle, + ), + pointRotateRads( + pointFrom(points[i + 1][0], points[i + 1][1]), + center, + angle, + ), + ), + ); + i++; + } + + return segments; +}; + +const getSegmentsOnEllipse = ( + ellipse: ExcalidrawEllipseElement, +): LineSegment[] => { + const center = pointFrom( + ellipse.x + ellipse.width / 2, + ellipse.y + ellipse.height / 2, + ); + + const a = ellipse.width / 2; + const b = ellipse.height / 2; + + const segments: LineSegment[] = []; + const points: GlobalPoint[] = []; + const n = 90; + const deltaT = (Math.PI * 2) / n; + + for (let i = 0; i < n; i++) { + const t = i * deltaT; + const x = center[0] + a * Math.cos(t); + const y = center[1] + b * Math.sin(t); + points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle)); + } + + for (let i = 0; i < points.length - 1; i++) { + segments.push(lineSegment(points[i], points[i + 1])); + } + + segments.push(lineSegment(points[points.length - 1], points[0])); + return segments; +}; + /** * Scene -> Scene coords, but in x1,x2,y1,y2 format. * 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/duplicate.ts b/packages/element/src/duplicate.ts index 5b95f9085..ded1fd26c 100644 --- a/packages/element/src/duplicate.ts +++ b/packages/element/src/duplicate.ts @@ -36,7 +36,10 @@ import { import { getBoundTextElement, getContainerElement } from "./textElement"; -import { fixBindingsAfterDuplication } from "./binding"; +import { + fixDuplicatedBindingsAfterDuplication, + fixReversedBindings, +} from "./binding"; import type { ElementsMap, @@ -381,12 +384,20 @@ export const duplicateElements = ( // --------------------------------------------------------------------------- - fixBindingsAfterDuplication( + fixDuplicatedBindingsAfterDuplication( newElements, oldIdToDuplicatedId, duplicatedElementsMap as NonDeletedSceneElementsMap, ); + if (reverseOrder) { + fixReversedBindings( + _idsOfElementsToDuplicate, + elementsWithClones, + oldIdToDuplicatedId, + ); + } + bindElementsToFramesAfterDuplication( elementsWithClones, oldElements, diff --git a/packages/element/src/heading.ts b/packages/element/src/heading.ts index 474923515..1e9ab3713 100644 --- a/packages/element/src/heading.ts +++ b/packages/element/src/heading.ts @@ -1,11 +1,15 @@ +import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common"; + import { - normalizeRadians, pointFrom, + pointFromVector, pointRotateRads, pointScaleFromOrigin, - radiansToDegrees, + pointsEqual, triangleIncludesPoint, + vectorCross, vectorFromPoint, + vectorScale, } from "@excalidraw/math"; import type { @@ -13,7 +17,6 @@ import type { GlobalPoint, Triangle, Vector, - Radians, } from "@excalidraw/math"; import { getCenterForBounds, type Bounds } from "./bounds"; @@ -26,24 +29,6 @@ export const HEADING_LEFT = [-1, 0] as Heading; export const HEADING_UP = [0, -1] as Heading; export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; -export const headingForDiamond = ( - a: Point, - b: Point, -) => { - const angle = radiansToDegrees( - normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians), - ); - - if (angle >= 315 || angle < 45) { - return HEADING_UP; - } else if (angle >= 45 && angle < 135) { - return HEADING_RIGHT; - } else if (angle >= 135 && angle < 225) { - return HEADING_DOWN; - } - return HEADING_LEFT; -}; - export const vectorToHeading = (vec: Vector): Heading => { const [x, y] = vec; const absX = Math.abs(x); @@ -76,6 +61,165 @@ export const headingIsHorizontal = (a: Heading) => export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a); +const headingForPointFromDiamondElement = ( + element: Readonly, + aabb: Readonly, + point: Readonly, +): Heading => { + const midPoint = getCenterForBounds(aabb); + + if (isDevEnv() || isTestEnv()) { + invariant( + element.width > 0 && element.height > 0, + "Diamond element has no width or height", + ); + invariant( + !pointsEqual(midPoint, point), + "The point is too close to the element mid point to determine heading", + ); + } + + const SHRINK = 0.95; // Rounded elements tolerance + const top = pointFromVector( + vectorScale( + vectorFromPoint( + pointRotateRads( + pointFrom(element.x + element.width / 2, element.y), + midPoint, + element.angle, + ), + midPoint, + ), + SHRINK, + ), + midPoint, + ); + const right = pointFromVector( + vectorScale( + vectorFromPoint( + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + midPoint, + element.angle, + ), + midPoint, + ), + SHRINK, + ), + midPoint, + ); + const bottom = pointFromVector( + vectorScale( + vectorFromPoint( + pointRotateRads( + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + midPoint, + element.angle, + ), + midPoint, + ), + SHRINK, + ), + midPoint, + ); + const left = pointFromVector( + vectorScale( + vectorFromPoint( + pointRotateRads( + pointFrom(element.x, element.y + element.height / 2), + midPoint, + element.angle, + ), + midPoint, + ), + SHRINK, + ), + midPoint, + ); + + // Corners + if ( + vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <= + 0 && + vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0 + ) { + return headingForPoint(top, midPoint); + } else if ( + vectorCross( + vectorFromPoint(point, right), + vectorFromPoint(right, bottom), + ) <= 0 && + vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0 + ) { + return headingForPoint(right, midPoint); + } else if ( + vectorCross( + vectorFromPoint(point, bottom), + vectorFromPoint(bottom, left), + ) <= 0 && + vectorCross( + vectorFromPoint(point, bottom), + vectorFromPoint(bottom, right), + ) > 0 + ) { + return headingForPoint(bottom, midPoint); + } else if ( + vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <= + 0 && + vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0 + ) { + return headingForPoint(left, midPoint); + } + + // Sides + if ( + vectorCross( + vectorFromPoint(point, midPoint), + vectorFromPoint(top, midPoint), + ) <= 0 && + vectorCross( + vectorFromPoint(point, midPoint), + vectorFromPoint(right, midPoint), + ) > 0 + ) { + const p = element.width > element.height ? top : right; + return headingForPoint(p, midPoint); + } else if ( + vectorCross( + vectorFromPoint(point, midPoint), + vectorFromPoint(right, midPoint), + ) <= 0 && + vectorCross( + vectorFromPoint(point, midPoint), + vectorFromPoint(bottom, midPoint), + ) > 0 + ) { + const p = element.width > element.height ? bottom : right; + return headingForPoint(p, midPoint); + } else if ( + vectorCross( + vectorFromPoint(point, midPoint), + vectorFromPoint(bottom, midPoint), + ) <= 0 && + vectorCross( + vectorFromPoint(point, midPoint), + vectorFromPoint(left, midPoint), + ) > 0 + ) { + const p = element.width > element.height ? bottom : left; + return headingForPoint(p, midPoint); + } + + const p = element.width > element.height ? top : left; + return headingForPoint(p, midPoint); +}; + // Gets the heading for the point by creating a bounding box around the rotated // close fitting bounding box, then creating 4 search cones around the center of // the external bbox. @@ -89,74 +233,7 @@ export const headingForPointFromElement = ( const midPoint = getCenterForBounds(aabb); if (element.type === "diamond") { - if (p[0] < element.x) { - return HEADING_LEFT; - } else if (p[1] < element.y) { - return HEADING_UP; - } else if (p[0] > element.x + element.width) { - return HEADING_RIGHT; - } else if (p[1] > element.y + element.height) { - return HEADING_DOWN; - } - - const top = pointRotateRads( - pointScaleFromOrigin( - pointFrom(element.x + element.width / 2, element.y), - midPoint, - SEARCH_CONE_MULTIPLIER, - ), - midPoint, - element.angle, - ); - const right = pointRotateRads( - pointScaleFromOrigin( - pointFrom(element.x + element.width, element.y + element.height / 2), - midPoint, - SEARCH_CONE_MULTIPLIER, - ), - midPoint, - element.angle, - ); - const bottom = pointRotateRads( - pointScaleFromOrigin( - pointFrom(element.x + element.width / 2, element.y + element.height), - midPoint, - SEARCH_CONE_MULTIPLIER, - ), - midPoint, - element.angle, - ); - const left = pointRotateRads( - pointScaleFromOrigin( - pointFrom(element.x, element.y + element.height / 2), - midPoint, - SEARCH_CONE_MULTIPLIER, - ), - midPoint, - element.angle, - ); - - if ( - triangleIncludesPoint([top, right, midPoint] as Triangle, p) - ) { - return headingForDiamond(top, right); - } else if ( - triangleIncludesPoint( - [right, bottom, midPoint] as Triangle, - p, - ) - ) { - return headingForDiamond(right, bottom); - } else if ( - triangleIncludesPoint( - [bottom, left, midPoint] as Triangle, - p, - ) - ) { - return headingForDiamond(bottom, left); - } - - return headingForDiamond(left, top); + return headingForPointFromDiamondElement(element, aabb, p); } const topLeft = pointScaleFromOrigin( diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index bd4e740b2..8a9117bf8 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -133,6 +133,7 @@ export class LinearElementEditor { }; if (!pointsEqual(element.points[0], pointFrom(0, 0))) { console.error("Linear element is not normalized", Error().stack); + LinearElementEditor.normalizePoints(element); } this.selectedPointsIndices = null; 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/showSelectedShapeActions.ts b/packages/element/src/showSelectedShapeActions.ts index efcf83894..6f918cfbd 100644 --- a/packages/element/src/showSelectedShapeActions.ts +++ b/packages/element/src/showSelectedShapeActions.ts @@ -14,6 +14,7 @@ export const showSelectedShapeActions = ( ((appState.activeTool.type !== "custom" && (appState.editingTextElement || (appState.activeTool.type !== "selection" && + appState.activeTool.type !== "lasso" && appState.activeTool.type !== "eraser" && appState.activeTool.type !== "hand" && appState.activeTool.type !== "laser"))) || 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/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx index d3b5cee96..7492bcc58 100644 --- a/packages/element/tests/duplicate.test.tsx +++ b/packages/element/tests/duplicate.test.tsx @@ -14,7 +14,7 @@ import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; -import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; +import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { act, @@ -699,4 +699,34 @@ describe("duplication z-order", () => { { id: text.id, containerId: arrow.id, selected: true }, ]); }); + + it("reverse-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => { + const rect = UI.createElement("rectangle", { + x: 0, + y: 0, + width: 100, + height: 100, + }); + + const arrow = UI.createElement("arrow", { + x: -100, + y: 50, + width: 95, + height: 0, + }); + + expect(arrow.endBinding?.elementId).toBe(rect.id); + + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.down(5, 5); + mouse.up(15, 15); + }); + + expect(window.h.elements).toHaveLength(3); + + const newRect = window.h.elements[0]; + + expect(arrow.endBinding?.elementId).toBe(newRect.id); + expect(newRect.boundElements?.[0]?.id).toBe(arrow.id); + }); }); diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 7cb10c045..ae18c0d98 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -226,8 +226,8 @@ export const actionWrapTextInContainer = register({ trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); - const areTextElements = selectedElements.every((el) => isTextElement(el)); - return selectedElements.length > 0 && areTextElements; + const someTextElements = selectedElements.some((el) => isTextElement(el)); + return selectedElements.length > 0 && someTextElements; }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 0aa83944f..a8bd56e82 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -29,6 +29,7 @@ import { ToolButton } from "../components/ToolButton"; import { Tooltip } from "../components/Tooltip"; import { handIcon, + LassoIcon, MoonIcon, SunIcon, TrashIcon, @@ -52,7 +53,6 @@ import type { AppState, Offsets } from "../types"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", label: "labels.canvasBackground", - paletteName: "Change canvas background color", trackEvent: false, predicate: (elements, appState, props, app) => { return ( @@ -90,7 +90,6 @@ export const actionChangeViewBackgroundColor = register({ export const actionClearCanvas = register({ name: "clearCanvas", label: "labels.clearCanvas", - paletteName: "Clear canvas", icon: TrashIcon, trackEvent: { category: "canvas" }, predicate: (elements, appState, props, app) => { @@ -525,10 +524,42 @@ export const actionToggleEraserTool = register({ keyTest: (event) => event.key === KEYS.E, }); +export const actionToggleLassoTool = register({ + name: "toggleLassoTool", + label: "toolBar.lasso", + icon: LassoIcon, + trackEvent: { category: "toolbar" }, + perform: (elements, appState, _, app) => { + let activeTool: AppState["activeTool"]; + + if (appState.activeTool.type !== "lasso") { + activeTool = updateActiveTool(appState, { + type: "lasso", + fromSelection: false, + }); + setCursor(app.interactiveCanvas, CURSOR_TYPE.CROSSHAIR); + } else { + activeTool = updateActiveTool(appState, { + type: "selection", + }); + } + + return { + appState: { + ...appState, + selectedElementIds: {}, + selectedGroupIds: {}, + activeEmbeddable: null, + activeTool, + }, + captureUpdate: CaptureUpdateAction.NEVER, + }; + }, +}); + export const actionToggleHandTool = register({ name: "toggleHandTool", label: "toolBar.hand", - paletteName: "Toggle hand tool", trackEvent: { category: "toolbar" }, icon: handIcon, viewMode: false, diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 79d13c63e..6bc238a59 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -90,7 +90,6 @@ export const actionToggleElementLock = register({ export const actionUnlockAllElements = register({ name: "unlockAllElements", - paletteName: "Unlock all elements", trackEvent: { category: "canvas" }, viewMode: false, icon: UnlockedIcon, diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 93fd6395b..ffa812e96 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -9,7 +9,6 @@ export const actionToggleStats = register({ name: "stats", label: "stats.fullTitle", icon: abacusIcon, - paletteName: "Toggle stats", viewMode: true, trackEvent: { category: "menu" }, keywords: ["edit", "attributes", "customize"], diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index 81faa9ee6..e42a7a102 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -8,7 +8,6 @@ import { register } from "./register"; export const actionToggleViewMode = register({ name: "viewMode", label: "labels.viewMode", - paletteName: "Toggle view mode", icon: eyeIcon, viewMode: true, trackEvent: { diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index 4d45b3204..e56e02ca7 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -9,7 +9,6 @@ export const actionToggleZenMode = register({ name: "zenMode", label: "buttons.zenMode", icon: coffeeIcon, - paletteName: "Toggle zen mode", viewMode: true, trackEvent: { category: "canvas", diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 152b9a0c7..c63a122e0 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -139,7 +139,8 @@ export type ActionName = | "copyElementLink" | "linkToElement" | "cropEditor" - | "wrapSelectionInFrame"; + | "wrapSelectionInFrame" + | "toggleLassoTool"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts index 286eff6f3..af6162e99 100644 --- a/packages/excalidraw/animated-trail.ts +++ b/packages/excalidraw/animated-trail.ts @@ -23,6 +23,8 @@ export interface Trail { export interface AnimatedTrailOptions { fill: (trail: AnimatedTrail) => string; + stroke?: (trail: AnimatedTrail) => string; + animateTrail?: boolean; } export class AnimatedTrail implements Trail { @@ -31,16 +33,28 @@ export class AnimatedTrail implements Trail { private container?: SVGSVGElement; private trailElement: SVGPathElement; + private trailAnimation?: SVGAnimateElement; constructor( private animationFrameHandler: AnimationFrameHandler, - private app: App, + protected app: App, private options: Partial & Partial, ) { this.animationFrameHandler.register(this, this.onFrame.bind(this)); this.trailElement = document.createElementNS(SVG_NS, "path"); + if (this.options.animateTrail) { + this.trailAnimation = document.createElementNS(SVG_NS, "animate"); + // TODO: make this configurable + this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset"); + this.trailElement.setAttribute("stroke-dasharray", "7 7"); + this.trailElement.setAttribute("stroke-dashoffset", "10"); + this.trailAnimation.setAttribute("from", "0"); + this.trailAnimation.setAttribute("to", `-14`); + this.trailAnimation.setAttribute("dur", "0.3s"); + this.trailElement.appendChild(this.trailAnimation); + } } get hasCurrentTrail() { @@ -104,8 +118,23 @@ export class AnimatedTrail implements Trail { } } + getCurrentTrail() { + return this.currentTrail; + } + + clearTrails() { + this.pastTrails = []; + this.currentTrail = undefined; + this.update(); + } + private update() { + this.pastTrails = []; this.start(); + if (this.trailAnimation) { + this.trailAnimation.setAttribute("begin", "indefinite"); + this.trailAnimation.setAttribute("repeatCount", "indefinite"); + } } private onFrame() { @@ -132,14 +161,25 @@ export class AnimatedTrail implements Trail { const svgPaths = paths.join(" ").trim(); this.trailElement.setAttribute("d", svgPaths); - this.trailElement.setAttribute( - "fill", - (this.options.fill ?? (() => "black"))(this), - ); + if (this.trailAnimation) { + this.trailElement.setAttribute( + "fill", + (this.options.fill ?? (() => "black"))(this), + ); + this.trailElement.setAttribute( + "stroke", + (this.options.stroke ?? (() => "black"))(this), + ); + } else { + this.trailElement.setAttribute( + "fill", + (this.options.fill ?? (() => "black"))(this), + ); + } } private drawTrail(trail: LaserPointer, state: AppState): string { - const stroke = trail + const _stroke = trail .getStrokeOutline(trail.options.size / state.zoom.value) .map(([x, y]) => { const result = sceneCoordsToViewportCoords( @@ -150,6 +190,10 @@ export class AnimatedTrail implements Trail { return [result.x, result.y]; }); + const stroke = this.trailAnimation + ? _stroke.slice(0, _stroke.length / 2) + : _stroke; + return getSvgPathFromStroke(stroke, true); } } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 434392ce7..a75745f2a 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -52,6 +52,7 @@ export const getDefaultAppState = (): Omit< type: "selection", customType: null, locked: DEFAULT_ELEMENT_PROPS.locked, + fromSelection: false, lastActiveTool: null, }, penMode: false, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index b69204867..3a7df37a8 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -62,6 +62,7 @@ import { mermaidLogoIcon, laserPointerToolIcon, MagicIcon, + LassoIcon, } from "./icons"; import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; @@ -83,7 +84,6 @@ export const canChangeStrokeColor = ( return ( (hasStrokeColor(appState.activeTool.type) && - appState.activeTool.type !== "image" && commonSelectedType !== "image" && commonSelectedType !== "frame" && commonSelectedType !== "magicframe") || @@ -295,6 +295,8 @@ export const ShapesSwitcher = ({ const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; + const lassoToolSelected = activeTool.type === "lasso"; + const embeddableToolSelected = activeTool.type === "embeddable"; const { TTDDialogTriggerTunnel } = useTunnels(); @@ -316,6 +318,7 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + return ( { if (appState.activeTool.type !== value) { @@ -358,6 +369,7 @@ export const ShapesSwitcher = ({ "App-toolbar__extra-tools-trigger--selected": frameToolSelected || embeddableToolSelected || + lassoToolSelected || // in collab we're already highlighting the laser button // outside toolbar, so let's not highlight extra-tools button // on top of it @@ -366,7 +378,15 @@ export const ShapesSwitcher = ({ onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} title={t("toolBar.extraTools")} > - {extraToolsIcon} + {frameToolSelected + ? frameToolIcon + : embeddableToolSelected + ? EmbedIcon + : laserToolSelected && !app.props.isCollaborating + ? laserPointerToolIcon + : lassoToolSelected + ? LassoIcon + : extraToolsIcon} setIsExtraToolsMenuOpen(false)} @@ -399,6 +419,14 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} + app.setActiveTool({ type: "lasso" })} + icon={LassoIcon} + data-testid="toolbar-lasso" + selected={lassoToolSelected} + > + {t("toolBar.lasso")} +
Generate
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d5fb55e1d..276cde027 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -99,6 +99,7 @@ import { isShallowEqual, arrayToMap, type EXPORT_IMAGE_TYPES, + randomInteger, } from "@excalidraw/common"; import { @@ -461,6 +462,8 @@ import { isOverScrollBars } from "../scene/scrollbars"; import { isMaybeMermaidDefinition } from "../mermaid"; +import { LassoTrail } from "../lasso"; + import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; @@ -692,6 +695,7 @@ class App extends React.Component { ? "rgba(0, 0, 0, 0.2)" : "rgba(255, 255, 255, 0.2)", }); + lassoTrail = new LassoTrail(this.animationFrameHandler, this); onChangeEmitter = new Emitter< [ @@ -1670,7 +1674,11 @@ class App extends React.Component {
{selectedElements.length === 1 && this.state.openDialog?.name !== @@ -4630,7 +4638,10 @@ class App extends React.Component { this.state.openDialog?.name === "elementLinkSelector" ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); - } else if (this.state.activeTool.type === "selection") { + } else if ( + this.state.activeTool.type === "selection" || + this.state.activeTool.type === "lasso" + ) { resetCursor(this.interactiveCanvas); } else { setCursorForShape(this.interactiveCanvas, this.state); @@ -4738,7 +4749,8 @@ class App extends React.Component { } ) | { type: "custom"; customType: string } - ) & { locked?: boolean }, + ) & { locked?: boolean; fromSelection?: boolean }, + keepSelection = false, ) => { if (!this.isToolSupported(tool.type)) { console.warn( @@ -4780,7 +4792,21 @@ class App extends React.Component { this.store.shouldCaptureIncrement(); } - if (nextActiveTool.type !== "selection") { + if (nextActiveTool.type === "lasso") { + return { + ...prevState, + activeTool: nextActiveTool, + ...(keepSelection + ? {} + : { + selectedElementIds: makeNextSelectedElementIds({}, prevState), + selectedGroupIds: makeNextSelectedElementIds({}, prevState), + editingGroupId: null, + multiElement: null, + }), + ...commonResets, + }; + } else if (nextActiveTool.type !== "selection") { return { ...prevState, activeTool: nextActiveTool, @@ -6603,6 +6629,7 @@ class App extends React.Component { !this.state.penMode || event.pointerType !== "touch" || this.state.activeTool.type === "selection" || + this.state.activeTool.type === "lasso" || this.state.activeTool.type === "text" || this.state.activeTool.type === "image"; @@ -6610,7 +6637,13 @@ class App extends React.Component { return; } - if (this.state.activeTool.type === "text") { + if (this.state.activeTool.type === "lasso") { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + } else if (this.state.activeTool.type === "text") { this.handleTextOnPointerDown(event, pointerDownState); } else if ( this.state.activeTool.type === "arrow" || @@ -7067,7 +7100,10 @@ class App extends React.Component { } private clearSelectionIfNotUsingSelection = (): void => { - if (this.state.activeTool.type !== "selection") { + if ( + this.state.activeTool.type !== "selection" && + this.state.activeTool.type !== "lasso" + ) { this.setState({ selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, @@ -8267,7 +8303,8 @@ class App extends React.Component { if ( (hasHitASelectedElement || pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && - !isSelectingPointsInLineEditor + !isSelectingPointsInLineEditor && + this.state.activeTool.type !== "lasso" ) { const selectedElements = this.scene.getSelectedElements(this.state); @@ -8485,20 +8522,26 @@ class App extends React.Component { }); if ( hitElement && + // hit element may not end up being selected + // if we're alt-dragging a common bounding box + // over the hit element + pointerDownState.hit.wasAddedToSelection && !selectedElements.find((el) => el.id === hitElement.id) ) { selectedElements.push(hitElement); } + const idsOfElementsToDuplicate = new Map( + selectedElements.map((el) => [el.id, el]), + ); + const { newElements: clonedElements, elementsWithClones } = duplicateElements({ type: "in-place", elements, appState: this.state, randomizeSeed: true, - idsOfElementsToDuplicate: new Map( - selectedElements.map((el) => [el.id, el]), - ), + idsOfElementsToDuplicate, overrides: (el) => { const origEl = pointerDownState.originalElements.get(el.id); @@ -8506,6 +8549,7 @@ class App extends React.Component { return { x: origEl.x, y: origEl.y, + seed: origEl.seed, }; } @@ -8525,7 +8569,14 @@ class App extends React.Component { const nextSceneElements = syncMovedIndices( mappedNewSceneElements || elementsWithClones, arrayToMap(clonedElements), - ); + ).map((el) => { + if (idsOfElementsToDuplicate.has(el.id)) { + return newElementWith(el, { + seed: randomInteger(), + }); + } + return el; + }); this.scene.replaceAllElements(nextSceneElements); this.maybeCacheVisibleGaps(event, selectedElements, true); @@ -8539,7 +8590,37 @@ class App extends React.Component { if (this.state.selectionElement) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; - this.maybeDragNewGenericElement(pointerDownState, event); + if (event.altKey) { + this.setActiveTool( + { type: "lasso", fromSelection: true }, + event.shiftKey, + ); + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + this.setAppState({ + selectionElement: null, + }); + } else { + this.maybeDragNewGenericElement(pointerDownState, event); + } + } else if (this.state.activeTool.type === "lasso") { + if (!event.altKey && this.state.activeTool.fromSelection) { + this.setActiveTool({ type: "selection" }); + this.createGenericElementOnPointerDown("selection", pointerDownState); + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; + this.maybeDragNewGenericElement(pointerDownState, event); + this.lassoTrail.endPath(); + } else { + this.lassoTrail.addPointToPath( + pointerCoords.x, + pointerCoords.y, + event.shiftKey, + ); + } } else { // It is very important to read this.state within each move event, // otherwise we would read a stale one! @@ -8794,6 +8875,8 @@ class App extends React.Component { originSnapOffset: null, })); + // just in case, tool changes mid drag, always clean up + this.lassoTrail.endPath(); this.lastPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); @@ -9510,6 +9593,8 @@ class App extends React.Component { } if ( + // do not clear selection if lasso is active + this.state.activeTool.type !== "lasso" && // not elbow midpoint dragged !(hitElement && isElbowArrow(hitElement)) && // not dragged @@ -9608,7 +9693,13 @@ class App extends React.Component { return; } - if (!activeTool.locked && activeTool.type !== "freedraw") { + if ( + !activeTool.locked && + activeTool.type !== "freedraw" && + (activeTool.type !== "lasso" || + // if lasso is turned on but from selection => reset to selection + (activeTool.type === "lasso" && activeTool.fromSelection)) + ) { resetCursor(this.interactiveCanvas); this.setState({ newElement: null, @@ -10463,7 +10554,7 @@ class App extends React.Component { width: distance(pointerDownState.origin.x, pointerCoords.x), height: distance(pointerDownState.origin.y, pointerCoords.y), shouldMaintainAspectRatio: shouldMaintainAspectRatio(event), - shouldResizeFromCenter: shouldResizeFromCenter(event), + shouldResizeFromCenter: false, zoom: this.state.zoom.value, informMutation, }); diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index 39e1845c3..56b40869b 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -27,16 +27,22 @@ .color-picker__top-picks { display: flex; justify-content: space-between; + align-items: center; } .color-picker__button { - --radius: 0.25rem; + --radius: 4px; + --size: 1.375rem; + + &.has-outline { + box-shadow: inset 0 0 0 1px #d9d9d9; + } padding: 0; margin: 0; - width: 1.35rem; - height: 1.35rem; - border: 1px solid var(--color-gray-30); + width: var(--size); + height: var(--size); + border: 0; border-radius: var(--radius); filter: var(--theme-filter); background-color: var(--swatch-color); @@ -45,16 +51,20 @@ font-family: inherit; box-sizing: border-box; - &:hover { + &:hover:not(.active):not(.color-picker__button--large) { + transform: scale(1.075); + } + + &:hover:not(.active).color-picker__button--large { &::after { content: ""; position: absolute; - top: -2px; - left: -2px; - right: -2px; - bottom: -2px; + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; box-shadow: 0 0 0 1px var(--color-gray-30); - border-radius: calc(var(--radius) + 1px); + border-radius: var(--radius); filter: var(--theme-filter); } } @@ -62,13 +72,14 @@ &.active { .color-picker__button-outline { position: absolute; - top: -2px; - left: -2px; - right: -2px; - bottom: -2px; + --offset: -1px; + top: var(--offset); + left: var(--offset); + right: var(--offset); + bottom: var(--offset); box-shadow: 0 0 0 1px var(--color-primary-darkest); z-index: 1; // due hover state so this has preference - border-radius: calc(var(--radius) + 1px); + border-radius: var(--radius); filter: var(--theme-filter); } } @@ -123,10 +134,11 @@ .color-picker__button__hotkey-label { position: absolute; - right: 4px; - bottom: 4px; + right: 5px; + bottom: 3px; filter: none; font-size: 11px; + font-weight: 500; } .color-picker { diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 34f6c6d12..eb6d82d9e 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -2,7 +2,11 @@ import * as Popover from "@radix-ui/react-popover"; import clsx from "clsx"; import { useRef } from "react"; -import { COLOR_PALETTE, isTransparent } from "@excalidraw/common"; +import { + COLOR_OUTLINE_CONTRAST_THRESHOLD, + COLOR_PALETTE, + isTransparent, +} from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; @@ -19,7 +23,7 @@ import { ColorInput } from "./ColorInput"; import { Picker } from "./Picker"; import PickerHeading from "./PickerHeading"; import { TopPicks } from "./TopPicks"; -import { activeColorPickerSectionAtom } from "./colorPickerUtils"; +import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils"; import "./ColorPicker.scss"; @@ -190,6 +194,7 @@ const ColorPickerTrigger = ({ type="button" className={clsx("color-picker__button active-color properties-trigger", { "is-transparent": color === "transparent" || !color, + "has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), })} aria-label={label} style={color ? { "--swatch-color": color } : undefined} diff --git a/packages/excalidraw/components/ColorPicker/CustomColorList.tsx b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx index 2c735102a..45d5db84c 100644 --- a/packages/excalidraw/components/ColorPicker/CustomColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx @@ -40,7 +40,7 @@ export const CustomColorList = ({ tabIndex={-1} type="button" className={clsx( - "color-picker__button color-picker__button--large", + "color-picker__button color-picker__button--large has-outline", { active: color === c, "is-transparent": c === "transparent" || !c, @@ -56,7 +56,7 @@ export const CustomColorList = ({ key={i} >
- + ); })} diff --git a/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx index 6e4d5e39c..898a28970 100644 --- a/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx +++ b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx @@ -1,24 +1,22 @@ import React from "react"; -import { getContrastYIQ } from "./colorPickerUtils"; +import { isColorDark } from "./colorPickerUtils"; interface HotkeyLabelProps { color: string; keyLabel: string | number; - isCustomColor?: boolean; isShade?: boolean; } const HotkeyLabel = ({ color, keyLabel, - isCustomColor = false, isShade = false, }: HotkeyLabelProps) => { return (
{isShade && "⇧"} diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx index 50594a59e..38e5cf8c5 100644 --- a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx @@ -65,7 +65,7 @@ const PickerColorList = ({ tabIndex={-1} type="button" className={clsx( - "color-picker__button color-picker__button--large", + "color-picker__button color-picker__button--large has-outline", { active: colorObj?.colorName === key, "is-transparent": color === "transparent" || !color, diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx index aa2c25ea0..1c8e4c4eb 100644 --- a/packages/excalidraw/components/ColorPicker/ShadeList.tsx +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -55,7 +55,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => { key={i} type="button" className={clsx( - "color-picker__button color-picker__button--large", + "color-picker__button color-picker__button--large has-outline", { active: i === shade }, )} aria-label="Shade" diff --git a/packages/excalidraw/components/ColorPicker/TopPicks.tsx b/packages/excalidraw/components/ColorPicker/TopPicks.tsx index 6d18a9587..8531172fb 100644 --- a/packages/excalidraw/components/ColorPicker/TopPicks.tsx +++ b/packages/excalidraw/components/ColorPicker/TopPicks.tsx @@ -1,11 +1,14 @@ import clsx from "clsx"; import { + COLOR_OUTLINE_CONTRAST_THRESHOLD, DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_STROKE_PICKS, } from "@excalidraw/common"; +import { isColorDark } from "./colorPickerUtils"; + import type { ColorPickerType } from "./colorPickerUtils"; interface TopPicksProps { @@ -51,6 +54,10 @@ export const TopPicks = ({ className={clsx("color-picker__button", { active: color === activeColor, "is-transparent": color === "transparent" || !color, + "has-outline": !isColorDark( + color, + COLOR_OUTLINE_CONTRAST_THRESHOLD, + ), })} style={{ "--swatch-color": color }} key={color} diff --git a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts index 4925a3145..f572bd49f 100644 --- a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts +++ b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts @@ -93,19 +93,42 @@ export type ActiveColorPickerSectionAtomType = export const activeColorPickerSectionAtom = atom(null); -const calculateContrast = (r: number, g: number, b: number) => { +const calculateContrast = (r: number, g: number, b: number): number => { const yiq = (r * 299 + g * 587 + b * 114) / 1000; - return yiq >= 160 ? "black" : "white"; + return yiq; }; -// inspiration from https://stackoverflow.com/a/11868398 -export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => { - if (isCustomColor) { - const style = new Option().style; - style.color = bgHex; +// YIQ algo, inspiration from https://stackoverflow.com/a/11868398 +export const isColorDark = (color: string, threshold = 160): boolean => { + // no color ("") -> assume it default to black + if (!color) { + return true; + } - if (style.color) { - const rgb = style.color + if (color === "transparent") { + return false; + } + + // a string color (white etc) or any other format -> convert to rgb by way + // of creating a DOM node and retrieving the computeStyle + if (!color.startsWith("#")) { + const node = document.createElement("div"); + node.style.color = color; + + if (node.style.color) { + // making invisible so document doesn't reflow (hopefully). + // display=none works too, but supposedly not in all browsers + node.style.position = "absolute"; + node.style.visibility = "hidden"; + node.style.width = "0"; + node.style.height = "0"; + + // needs to be in DOM else browser won't compute the style + document.body.appendChild(node); + const computedColor = getComputedStyle(node).color; + document.body.removeChild(node); + // computed style is in rgb() format + const rgb = computedColor .replace(/^(rgb|rgba)\(/, "") .replace(/\)$/, "") .replace(/\s/g, "") @@ -114,20 +137,17 @@ export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => { const g = parseInt(rgb[1]); const b = parseInt(rgb[2]); - return calculateContrast(r, g, b); + return calculateContrast(r, g, b) < threshold; } + // invalid color -> assume it default to black + return true; } - // TODO: ? is this wanted? - if (bgHex === "transparent") { - return "black"; - } + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); - const r = parseInt(bgHex.substring(1, 3), 16); - const g = parseInt(bgHex.substring(3, 5), 16); - const b = parseInt(bgHex.substring(5, 7), 16); - - return calculateContrast(r, g, b); + return calculateContrast(r, g, b) < threshold; }; export type ColorPickerType = diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 4391759d9..8b45e3377 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -315,6 +315,7 @@ function CommandPaletteInner({ const toolCommands: CommandPaletteItem[] = [ actionManager.actions.toggleHandTool, actionManager.actions.setFrameAsActiveTool, + actionManager.actions.toggleLassoTool, ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools)); const editorCommands: CommandPaletteItem[] = [ diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 6eb1a2186..5072e4471 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -120,7 +120,7 @@ const getHints = ({ !appState.editingTextElement && !appState.editingLinearElement ) { - return t("hints.deepBoxSelect"); + return [t("hints.deepBoxSelect")]; } if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { @@ -128,7 +128,7 @@ const getHints = ({ } if (!selectedElements.length && !isMobile) { - return t("hints.canvasPanning"); + return [t("hints.canvasPanning")]; } if (selectedElements.length === 1) { diff --git a/packages/excalidraw/components/Range.scss b/packages/excalidraw/components/Range.scss index 01cb91689..8dcc705fe 100644 --- a/packages/excalidraw/components/Range.scss +++ b/packages/excalidraw/components/Range.scss @@ -6,7 +6,7 @@ .range-wrapper { position: relative; padding-top: 10px; - padding-bottom: 30px; + padding-bottom: 25px; } .range-input { diff --git a/packages/excalidraw/components/Stats/DragInput.scss b/packages/excalidraw/components/Stats/DragInput.scss index 76b9d147b..f31616d94 100644 --- a/packages/excalidraw/components/Stats/DragInput.scss +++ b/packages/excalidraw/components/Stats/DragInput.scss @@ -2,10 +2,12 @@ .drag-input-container { display: flex; width: 100%; + border-radius: var(--border-radius-lg); &:focus-within { box-shadow: 0 0 0 1px var(--color-primary-darkest); border-radius: var(--border-radius-md); + background: transparent; } } @@ -16,24 +18,14 @@ .drag-input-label { flex-shrink: 0; - border: 1px solid var(--default-border-color); - border-right: 0; - padding: 0 0.5rem 0 0.75rem; + border: 0; + padding: 0 0.5rem 0 0.25rem; min-width: 1rem; + width: 1.5rem; height: 2rem; - box-sizing: border-box; + box-sizing: content-box; color: var(--popup-text-color); - :root[dir="ltr"] & { - border-radius: var(--border-radius-md) 0 0 var(--border-radius-md); - } - - :root[dir="rtl"] & { - border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; - border-right: 1px solid var(--default-border-color); - border-left: 0; - } - display: flex; align-items: center; justify-content: center; @@ -51,20 +43,8 @@ border: 0; outline: none; height: 2rem; - border: 1px solid var(--default-border-color); - border-left: 0; letter-spacing: 0.4px; - :root[dir="ltr"] & { - border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; - } - - :root[dir="rtl"] & { - border-radius: var(--border-radius-md) 0 0 var(--border-radius-md); - border-left: 1px solid var(--default-border-color); - border-right: 0; - } - padding: 0.5rem; padding-left: 0.25rem; appearance: none; diff --git a/packages/excalidraw/components/Stats/Stats.scss b/packages/excalidraw/components/Stats/Stats.scss index 106ecf303..384e0fd3c 100644 --- a/packages/excalidraw/components/Stats/Stats.scss +++ b/packages/excalidraw/components/Stats/Stats.scss @@ -41,6 +41,10 @@ div + div { text-align: right; } + + &:empty { + display: none; + } } &__row--heading { diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index 889f78971..11a5d6b5d 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -289,7 +289,11 @@ export const StatsInner = memo( )} - + {appState.croppingElementId ? t("labels.imageCropping") : t(`element.${singleElement.type}`)} diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index b70c8ace6..5a498ebac 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -87,34 +87,36 @@ const StaticCanvas = (props: StaticCanvasProps) => { return
; }; -const getRelevantAppStateProps = ( - appState: AppState, -): StaticCanvasAppState => ({ - zoom: appState.zoom, - scrollX: appState.scrollX, - scrollY: appState.scrollY, - width: appState.width, - height: appState.height, - viewModeEnabled: appState.viewModeEnabled, - openDialog: appState.openDialog, - hoveredElementIds: appState.hoveredElementIds, - offsetLeft: appState.offsetLeft, - offsetTop: appState.offsetTop, - theme: appState.theme, - pendingImageElementId: appState.pendingImageElementId, - shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, - viewBackgroundColor: appState.viewBackgroundColor, - exportScale: appState.exportScale, - selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, - gridSize: appState.gridSize, - gridStep: appState.gridStep, - frameRendering: appState.frameRendering, - selectedElementIds: appState.selectedElementIds, - frameToHighlight: appState.frameToHighlight, - editingGroupId: appState.editingGroupId, - currentHoveredFontFamily: appState.currentHoveredFontFamily, - croppingElementId: appState.croppingElementId, -}); +const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => { + const relevantAppStateProps = { + zoom: appState.zoom, + scrollX: appState.scrollX, + scrollY: appState.scrollY, + width: appState.width, + height: appState.height, + viewModeEnabled: appState.viewModeEnabled, + openDialog: appState.openDialog, + hoveredElementIds: appState.hoveredElementIds, + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + theme: appState.theme, + pendingImageElementId: appState.pendingImageElementId, + shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, + viewBackgroundColor: appState.viewBackgroundColor, + exportScale: appState.exportScale, + selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, + gridSize: appState.gridSize, + gridStep: appState.gridStep, + frameRendering: appState.frameRendering, + selectedElementIds: appState.selectedElementIds, + frameToHighlight: appState.frameToHighlight, + editingGroupId: appState.editingGroupId, + currentHoveredFontFamily: appState.currentHoveredFontFamily, + croppingElementId: appState.croppingElementId, + }; + + return relevantAppStateProps; +}; const areEqual = ( prevProps: StaticCanvasProps, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index c6d7f9473..f3808a69d 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -274,6 +274,21 @@ export const SelectionIcon = createIcon( { fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, ); +export const LassoIcon = createIcon( + + + + + , + + { fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, +); + // tabler-icons: square export const RectangleIcon = createIcon( @@ -406,7 +421,7 @@ export const TrashIcon = createIcon( ); export const EmbedIcon = createIcon( - + , diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index fd6a8dacb..1d6a56966 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -148,7 +148,7 @@ --border-radius-lg: 0.5rem; --color-surface-high: #f1f0ff; - --color-surface-mid: #f2f2f7; + --color-surface-mid: #f6f6f9; --color-surface-low: #ececf4; --color-surface-lowest: #ffffff; --color-on-surface: #1b1b1f; @@ -252,7 +252,7 @@ --color-logo-text: #e2dfff; - --color-surface-high: hsl(245, 10%, 21%); + --color-surface-high: #2e2d39; --color-surface-low: hsl(240, 8%, 15%); --color-surface-mid: hsl(240 6% 10%); --color-surface-lowest: hsl(0, 0%, 7%); diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 917f3d95e..70f8daa31 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -104,12 +104,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "opacity": 100, "points": [ [ - 0.5, - 0.5, + 0, + 0, ], [ - 394.5, - 34.5, + 394, + 34, ], ], "roughness": 1, @@ -129,8 +129,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "version": 4, "versionNonce": Any, "width": 395, - "x": 247, - "y": 420, + "x": 247.5, + "y": 420.5, } `; @@ -160,11 +160,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 399.5, + 399, 0, ], ], @@ -185,7 +185,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "version": 4, "versionNonce": Any, "width": 400, - "x": 227, + "x": 227.5, "y": 450, } `; @@ -350,11 +350,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -375,7 +375,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "version": 4, "versionNonce": Any, "width": 100, - "x": 255, + "x": 255.5, "y": 239, } `; @@ -452,11 +452,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -477,7 +477,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "version": 4, "versionNonce": Any, "width": 100, - "x": 255, + "x": 255.5, "y": 239, } `; @@ -628,11 +628,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -653,7 +653,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "version": 4, "versionNonce": Any, "width": 100, - "x": 255, + "x": 255.5, "y": 239, } `; @@ -845,11 +845,11 @@ exports[`Test Transform > should transform linear elements 1`] = ` "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "version": 2, "versionNonce": Any, "width": 100, - "x": 100, + "x": 100.5, "y": 20, } `; @@ -893,11 +893,11 @@ exports[`Test Transform > should transform linear elements 2`] = ` "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -914,7 +914,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "version": 2, "versionNonce": Any, "width": 100, - "x": 450, + "x": 450.5, "y": 20, } `; @@ -1490,11 +1490,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 272.485, + 271.985, 0, ], ], @@ -1517,7 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "version": 4, "versionNonce": Any, "width": 272.985, - "x": 111.262, + "x": 111.762, "y": 57, } `; @@ -1862,11 +1862,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -1883,7 +1883,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "version": 2, "versionNonce": Any, "width": 100, - "x": 100, + "x": 100.5, "y": 100, } `; @@ -1915,11 +1915,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -1936,7 +1936,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "version": 2, "versionNonce": Any, "width": 100, - "x": 100, + "x": 100.5, "y": 200, } `; @@ -1968,11 +1968,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -1989,7 +1989,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "version": 2, "versionNonce": Any, "width": 100, - "x": 100, + "x": 100.5, "y": 300, } `; @@ -2021,11 +2021,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide "opacity": 100, "points": [ [ - 0.5, + 0, 0, ], [ - 99.5, + 99, 0, ], ], @@ -2042,7 +2042,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "version": 2, "versionNonce": Any, "width": 100, - "x": 100, + "x": 100.5, "y": 400, } `; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index d1002e61f..4f050c922 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -86,6 +86,7 @@ export const AllowedExcalidrawActiveTools: Record< boolean > = { selection: true, + lasso: true, text: true, rectangle: true, diamond: true, @@ -221,7 +222,7 @@ const restoreElementWithProperties = < "customData" in extra ? extra.customData : element.customData; } - return { + const ret = { // spread the original element properties to not lose unknown ones // for forward-compatibility ...element, @@ -230,6 +231,12 @@ const restoreElementWithProperties = < ...getNormalizedDimensions(base), ...extra, } as unknown as T; + + // strip legacy props (migrated in previous steps) + delete ret.strokeSharpness; + delete ret.boundElementIds; + + return ret; }; const restoreElement = ( diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 27643e7e1..0b0718e8e 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -427,7 +427,7 @@ describe("Test Transform", () => { const [arrow, text, rectangle, ellipse] = excalidrawElements; expect(arrow).toMatchObject({ type: "arrow", - x: 255, + x: 255.5, y: 239, boundElements: [{ id: text.id, type: "text" }], startBinding: { @@ -512,7 +512,7 @@ describe("Test Transform", () => { expect(arrow).toMatchObject({ type: "arrow", - x: 255, + x: 255.5, y: 239, boundElements: [{ id: text1.id, type: "text" }], startBinding: { @@ -730,7 +730,7 @@ describe("Test Transform", () => { const [, , arrow, text] = excalidrawElements; expect(arrow).toMatchObject({ type: "arrow", - x: 255, + x: 255.5, y: 239, boundElements: [ { diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 9def9f5fc..15ad1ffde 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -36,6 +36,8 @@ import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex"; import { redrawTextBoundingBox } from "@excalidraw/element/textElement"; +import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; + import type { ElementConstructorOpts } from "@excalidraw/element/newElement"; import type { @@ -463,7 +465,13 @@ const bindLinearElementToElement = ( newPoints[endPointIndex][1] += delta; } - Object.assign(linearElement, { points: newPoints }); + Object.assign( + linearElement, + LinearElementEditor.getNormalizedPoints({ + ...linearElement, + points: newPoints, + }), + ); return { linearElement, diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts new file mode 100644 index 000000000..d59b2d743 --- /dev/null +++ b/packages/excalidraw/lasso/index.ts @@ -0,0 +1,201 @@ +import { + type GlobalPoint, + type LineSegment, + pointFrom, +} from "@excalidraw/math"; + +import { getElementLineSegments } from "@excalidraw/element/bounds"; +import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; +import { + isFrameLikeElement, + isLinearElement, + isTextElement, +} from "@excalidraw/element/typeChecks"; + +import { getFrameChildren } from "@excalidraw/element/frame"; +import { selectGroupsForSelectedElements } from "@excalidraw/element/groups"; + +import { getContainerElement } from "@excalidraw/element/textElement"; + +import { arrayToMap, easeOut } from "@excalidraw/common"; + +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + NonDeleted, +} from "@excalidraw/element/types"; + +import { type AnimationFrameHandler } from "../animation-frame-handler"; + +import { AnimatedTrail } from "../animated-trail"; + +import { getLassoSelectedElementIds } from "./utils"; + +import type App from "../components/App"; + +export class LassoTrail extends AnimatedTrail { + private intersectedElements: Set = new Set(); + private enclosedElements: Set = new Set(); + private elementsSegments: Map[]> | null = + null; + private keepPreviousSelection: boolean = false; + + constructor(animationFrameHandler: AnimationFrameHandler, app: App) { + super(animationFrameHandler, app, { + animateTrail: true, + streamline: 0.4, + sizeMapping: (c) => { + const DECAY_TIME = Infinity; + const DECAY_LENGTH = 5000; + 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: () => "rgba(105,101,219,0.05)", + stroke: () => "rgba(105,101,219)", + }); + } + + startPath(x: number, y: number, keepPreviousSelection = false) { + // clear any existing trails just in case + this.endPath(); + + super.startPath(x, y); + this.intersectedElements.clear(); + this.enclosedElements.clear(); + + this.keepPreviousSelection = keepPreviousSelection; + + if (!this.keepPreviousSelection) { + this.app.setState({ + selectedElementIds: {}, + selectedGroupIds: {}, + selectedLinearElement: null, + }); + } + } + + selectElementsFromIds = (ids: string[]) => { + this.app.setState((prevState) => { + const nextSelectedElementIds = ids.reduce((acc, id) => { + acc[id] = true; + return acc; + }, {} as Record); + + if (this.keepPreviousSelection) { + for (const id of Object.keys(prevState.selectedElementIds)) { + nextSelectedElementIds[id] = true; + } + } + + for (const [id] of Object.entries(nextSelectedElementIds)) { + const element = this.app.scene.getNonDeletedElement(id); + + if (element && isTextElement(element)) { + const container = getContainerElement( + element, + this.app.scene.getNonDeletedElementsMap(), + ); + if (container) { + nextSelectedElementIds[container.id] = true; + delete nextSelectedElementIds[element.id]; + } + } + } + + // remove all children of selected frames + for (const [id] of Object.entries(nextSelectedElementIds)) { + const element = this.app.scene.getNonDeletedElement(id); + + if (element && isFrameLikeElement(element)) { + const elementsInFrame = getFrameChildren( + this.app.scene.getNonDeletedElementsMap(), + element.id, + ); + for (const child of elementsInFrame) { + delete nextSelectedElementIds[child.id]; + } + } + } + + const nextSelection = selectGroupsForSelectedElements( + { + editingGroupId: prevState.editingGroupId, + selectedElementIds: nextSelectedElementIds, + }, + this.app.scene.getNonDeletedElements(), + prevState, + this.app, + ); + + const selectedIds = [...Object.keys(nextSelection.selectedElementIds)]; + const selectedGroupIds = [...Object.keys(nextSelection.selectedGroupIds)]; + + return { + selectedElementIds: nextSelection.selectedElementIds, + selectedGroupIds: nextSelection.selectedGroupIds, + selectedLinearElement: + selectedIds.length === 1 && + !selectedGroupIds.length && + isLinearElement(this.app.scene.getNonDeletedElement(selectedIds[0])) + ? new LinearElementEditor( + this.app.scene.getNonDeletedElement( + selectedIds[0], + ) as NonDeleted, + ) + : null, + }; + }); + }; + + addPointToPath = (x: number, y: number, keepPreviousSelection = false) => { + super.addPointToPath(x, y); + + this.keepPreviousSelection = keepPreviousSelection; + + this.updateSelection(); + }; + + private updateSelection = () => { + const lassoPath = super + .getCurrentTrail() + ?.originalPoints?.map((p) => pointFrom(p[0], p[1])); + + if (!this.elementsSegments) { + this.elementsSegments = new Map(); + const visibleElementsMap = arrayToMap(this.app.visibleElements); + for (const element of this.app.visibleElements) { + const segments = getElementLineSegments(element, visibleElementsMap); + this.elementsSegments.set(element.id, segments); + } + } + + if (lassoPath) { + const { selectedElementIds } = getLassoSelectedElementIds({ + lassoPath, + elements: this.app.visibleElements, + elementsSegments: this.elementsSegments, + intersectedElements: this.intersectedElements, + enclosedElements: this.enclosedElements, + simplifyDistance: 5 / this.app.state.zoom.value, + }); + + this.selectElementsFromIds(selectedElementIds); + } + }; + + endPath(): void { + super.endPath(); + super.clearTrails(); + this.intersectedElements.clear(); + this.enclosedElements.clear(); + this.elementsSegments = null; + } +} diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts new file mode 100644 index 000000000..f5a7eefdc --- /dev/null +++ b/packages/excalidraw/lasso/utils.ts @@ -0,0 +1,107 @@ +import { simplify } from "points-on-curve"; + +import { + polygonFromPoints, + lineSegment, + lineSegmentIntersectionPoints, + polygonIncludesPointNonZero, +} from "@excalidraw/math"; + +import type { 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[]; + elementsSegments: ElementsSegmentsMap; + intersectedElements: Set; + enclosedElements: Set; + simplifyDistance?: number; +}): { + selectedElementIds: string[]; +} => { + const { + lassoPath, + elements, + elementsSegments, + intersectedElements, + enclosedElements, + simplifyDistance, + } = input; + // simplify the path to reduce the number of points + let path: GlobalPoint[] = lassoPath; + if (simplifyDistance) { + path = simplify(lassoPath, simplifyDistance) as GlobalPoint[]; + } + // as the path might not enclose a shape anymore, clear before checking + enclosedElements.clear(); + for (const element of elements) { + if ( + !intersectedElements.has(element.id) && + !enclosedElements.has(element.id) + ) { + const enclosed = enclosureTest(path, element, elementsSegments); + if (enclosed) { + enclosedElements.add(element.id); + } else { + const intersects = intersectionTest(path, element, elementsSegments); + if (intersects) { + intersectedElements.add(element.id); + } + } + } + } + + const results = [...intersectedElements, ...enclosedElements]; + + return { + selectedElementIds: results, + }; +}; + +const enclosureTest = ( + lassoPath: GlobalPoint[], + element: ExcalidrawElement, + elementsSegments: ElementsSegmentsMap, +): boolean => { + const lassoPolygon = polygonFromPoints(lassoPath); + const segments = elementsSegments.get(element.id); + if (!segments) { + return false; + } + + return segments.some((segment) => { + return segment.some((point) => + polygonIncludesPointNonZero(point, lassoPolygon), + ); + }); +}; + +const intersectionTest = ( + lassoPath: GlobalPoint[], + element: ExcalidrawElement, + elementsSegments: ElementsSegmentsMap, +): boolean => { + const elementSegments = elementsSegments.get(element.id); + if (!elementSegments) { + return false; + } + + const lassoSegments = lassoPath.reduce((acc, point, index) => { + if (index === 0) { + return acc; + } + acc.push(lineSegment(lassoPath[index - 1], point)); + return acc; + }, [] as LineSegment[]); + + return lassoSegments.some((lassoSegment) => + elementSegments.some( + (elementSegment) => + // introduce a bit of tolerance to account for roughness and simplification of paths + lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, + ), + ); +}; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index f14b79705..381f2b67f 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -276,6 +276,7 @@ }, "toolBar": { "selection": "Selection", + "lasso": "Lasso selection", "image": "Insert image", "rectangle": "Rectangle", "diamond": "Diamond", diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 3780565ac..1b162faff 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -76,7 +76,7 @@ "@excalidraw/mermaid-to-excalidraw": "1.1.2", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", - "@radix-ui/react-tabs": "1.0.2", + "@radix-ui/react-tabs": "1.1.3", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 89629b93e..349dd9e64 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -5,6 +5,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -1088,6 +1089,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -1307,6 +1309,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -1641,6 +1644,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -1975,6 +1979,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -2194,6 +2199,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -2437,6 +2443,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -2741,6 +2748,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -3113,6 +3121,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -3591,6 +3600,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -3917,6 +3927,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -4243,6 +4254,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -4649,6 +4661,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -5870,6 +5883,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -7137,6 +7151,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -7408,7 +7423,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app , "label": "labels.elementLock.unlockAll", "name": "unlockAllElements", - "paletteName": "Unlock all elements", "perform": [Function], "predicate": [Function], "trackEvent": { @@ -7559,7 +7573,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "keyTest": [Function], "label": "buttons.zenMode", "name": "zenMode", - "paletteName": "Toggle zen mode", "perform": [Function], "predicate": [Function], "trackEvent": { @@ -7603,7 +7616,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "keyTest": [Function], "label": "labels.viewMode", "name": "viewMode", - "paletteName": "Toggle view mode", "perform": [Function], "predicate": [Function], "trackEvent": { @@ -7677,7 +7689,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app ], "label": "stats.fullTitle", "name": "stats", - "paletteName": "Toggle stats", "perform": [Function], "trackEvent": { "category": "menu", @@ -7814,6 +7825,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", @@ -8802,6 +8814,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "activeEmbeddable": null, "activeTool": { "customType": null, + "fromSelection": false, "lastActiveTool": null, "locked": false, "type": "selection", diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index e5e431dfc..bbcc8d7e0 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -572,7 +572,7 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende class="color-picker__top-picks" >