diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index 5ff5690eb..6e2c0d00e 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -24,4 +24,4 @@ jobs: - name: Auto release run: | yarn add @actions/core -W - yarn autorelease + yarn release --tag=next --non-interactive diff --git a/.github/workflows/autorelease-preview.yml b/.github/workflows/autorelease-preview.yml deleted file mode 100644 index a40ed3c43..000000000 --- a/.github/workflows/autorelease-preview.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Auto release excalidraw preview -on: - issue_comment: - types: [created, edited] - -jobs: - Auto-release-excalidraw-preview: - name: Auto release preview - if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request - runs-on: ubuntu-latest - steps: - - name: React to release comment - uses: peter-evans/create-or-update-comment@v1 - with: - token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} - comment-id: ${{ github.event.comment.id }} - reactions: "+1" - - name: Get PR SHA - id: sha - uses: actions/github-script@v4 - with: - result-encoding: string - script: | - const { owner, repo, number } = context.issue; - const pr = await github.pulls.get({ - owner, - repo, - pull_number: number, - }); - return pr.data.head.sha - - uses: actions/checkout@v2 - with: - ref: ${{ steps.sha.outputs.result }} - fetch-depth: 2 - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 - with: - node-version: 18.x - - name: Set up publish access - run: | - npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Auto release preview - id: "autorelease" - run: | - yarn add @actions/core -W - yarn autorelease preview ${{ github.event.issue.number }} - - name: Post comment post release - if: always() - uses: peter-evans/create-or-update-comment@v1 - with: - token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} - issue-number: ${{ github.event.issue.number }} - body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}" diff --git a/dev-docs/docs/@excalidraw/excalidraw/development.mdx b/dev-docs/docs/@excalidraw/excalidraw/development.mdx index 60700758f..0c6c1f9c4 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/development.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/development.mdx @@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the ## Releasing -### Create a test release - -You can create a test release by posting the below comment in your pull request: - -```bash -@excalibot trigger release -``` - -Once the version is released `@excalibot` will post a comment with the release version. - ### Creating a production release To release the next stable version follow the below steps: ```bash -yarn prerelease:excalidraw +yarn release --tag=latest --version=0.19.0 ``` -You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more. - -The next step is to run the `release` script: - -```bash -yarn release:excalidraw -``` - -This will publish the package. - -Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done. +You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more. diff --git a/dev-docs/src/theme/ReactLiveScope/index.js b/dev-docs/src/theme/ReactLiveScope/index.js index ca5a902e8..a1b0b33a1 100644 --- a/dev-docs/src/theme/ReactLiveScope/index.js +++ b/dev-docs/src/theme/ReactLiveScope/index.js @@ -33,6 +33,7 @@ const ExcalidrawScope = { initialData, useI18n: ExcalidrawComp.useI18n, convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements, + CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction, }; export default ExcalidrawScope; diff --git a/examples/with-nextjs/package.json b/examples/with-nextjs/package.json index ee8e55581..f23ff6f04 100644 --- a/examples/with-nextjs/package.json +++ b/examples/with-nextjs/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "build:packages": "yarn --cwd ../../ build:packages", + "build:workspace": "yarn build:packages && yarn copy:assets", "copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public", "dev": "yarn build:workspace && next dev -p 3005", "build": "yarn build:workspace && next build", diff --git a/examples/with-script-in-browser/package.json b/examples/with-script-in-browser/package.json index 2b4311711..653c2be40 100644 --- a/examples/with-script-in-browser/package.json +++ b/examples/with-script-in-browser/package.json @@ -17,6 +17,6 @@ "build": "vite build", "preview": "vite preview --port 5002", "build:preview": "yarn build && yarn preview", - "build:package": "yarn workspace @excalidraw/excalidraw run build:esm" + "build:packages": "yarn --cwd ../../ build:packages" } } diff --git a/examples/with-script-in-browser/vercel.json b/examples/with-script-in-browser/vercel.json index 99a5811c3..15014b37c 100644 --- a/examples/with-script-in-browser/vercel.json +++ b/examples/with-script-in-browser/vercel.json @@ -1,5 +1,5 @@ { "outputDirectory": "dist", "installCommand": "yarn install", - "buildCommand": "yarn build:package && yarn build" + "buildCommand": "yarn build:packages && yarn build" } diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 385b9b140..71e3885b1 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -9,7 +9,7 @@ import { } from "@excalidraw/excalidraw/renderer/helpers"; import { type AppState } from "@excalidraw/excalidraw/types"; import { throttleRAF } from "@excalidraw/common"; -import { useCallback, useImperativeHandle, useRef } from "react"; +import { useCallback } from "react"; import { isLineSegment, @@ -18,6 +18,8 @@ import { } from "@excalidraw/math"; import { isCurve } from "@excalidraw/math/curve"; +import React from "react"; + import type { Curve } from "@excalidraw/math"; import type { DebugElement } from "@excalidraw/utils/visualdebug"; @@ -113,10 +115,6 @@ const _debugRenderer = ( scale, ); - if (appState.height !== canvas.height || appState.width !== canvas.width) { - refresh(); - } - const context = bootstrapCanvas({ canvas, scale, @@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => { interface DebugCanvasProps { appState: AppState; scale: number; - ref?: React.Ref; } -const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => { - const { width, height } = appState; +const DebugCanvas = React.forwardRef( + ({ appState, scale }, ref) => { + const { width, height } = appState; - const canvasRef = useRef(null); - useImperativeHandle( - ref, - () => canvasRef.current, - [canvasRef], - ); - - return ( - - Debug Canvas - - ); -}; + return ( + + Debug Canvas + + ); + }, +); export default DebugCanvas; diff --git a/package.json b/package.json index 02d989cd2..9397d0040 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,17 @@ "build-node": "node ./scripts/build-node.js", "build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker", "build:app": "yarn --cwd ./excalidraw-app build:app", - "build:package": "yarn --cwd ./packages/excalidraw build:esm", + "build:common": "yarn --cwd ./packages/common build:esm", + "build:element": "yarn --cwd ./packages/element build:esm", + "build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm", + "build:math": "yarn --cwd ./packages/math build:esm", + "build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw", "build:version": "yarn --cwd ./excalidraw-app build:version", "build": "yarn --cwd ./excalidraw-app build", "build:preview": "yarn --cwd ./excalidraw-app build:preview", "start": "yarn --cwd ./excalidraw-app start", "start:production": "yarn --cwd ./excalidraw-app start:production", - "start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start", + "start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:app": "vitest", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", @@ -76,9 +80,10 @@ "locales-coverage:description": "node scripts/locales-coverage-description.js", "prepare": "husky install", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", - "autorelease": "node scripts/autorelease.js", - "prerelease:excalidraw": "node scripts/prerelease.js", - "release:excalidraw": "node scripts/release.js", + "release": "node scripts/release.js", + "release:test": "node scripts/release.js --tag=test", + "release:next": "node scripts/release.js --tag=next", + "release:latest": "node scripts/release.js --tag=latest", "rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist", "rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules", "clean-install": "yarn rm:node_modules && yarn install" diff --git a/packages/common/package.json b/packages/common/package.json index 8fedd6742..cf566ad98 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@excalidraw/common", - "version": "0.1.0", + "version": "0.18.0", "type": "module", "types": "./dist/types/common/src/index.d.ts", "main": "./dist/prod/index.js", @@ -13,7 +13,10 @@ "default": "./dist/prod/index.js" }, "./*": { - "types": "./dist/types/common/src/*.d.ts" + "types": "./dist/types/common/src/*.d.ts", + "development": "./dist/dev/index.js", + "production": "./dist/prod/index.js", + "default": "./dist/prod/index.js" } }, "files": [ diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index c3c348ceb..c797c6e8c 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -36,6 +36,7 @@ export const APP_NAME = "Excalidraw"; // (happens a lot with fast clicks with the text tool) export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const DRAGGING_THRESHOLD = 10; // px +export const MINIMUM_ARROW_SIZE = 20; // px export const LINE_CONFIRM_THRESHOLD = 8; // px export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; diff --git a/packages/element/package.json b/packages/element/package.json index 16b9a49e7..88e3ffaaa 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -1,6 +1,6 @@ { "name": "@excalidraw/element", - "version": "0.1.0", + "version": "0.18.0", "type": "module", "types": "./dist/types/element/src/index.d.ts", "main": "./dist/prod/index.js", @@ -13,7 +13,10 @@ "default": "./dist/prod/index.js" }, "./*": { - "types": "./dist/types/element/src/*.d.ts" + "types": "./dist/types/element/src/*.d.ts", + "development": "./dist/dev/index.js", + "production": "./dist/prod/index.js", + "default": "./dist/prod/index.js" } }, "files": [ @@ -52,5 +55,9 @@ "scripts": { "gen:types": "rimraf types && tsc", "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" + }, + "dependencies": { + "@excalidraw/common": "0.18.0", + "@excalidraw/math": "0.18.0" } } diff --git a/packages/element/src/align.ts b/packages/element/src/align.ts index 546bbbfa4..3068aee8d 100644 --- a/packages/element/src/align.ts +++ b/packages/element/src/align.ts @@ -1,6 +1,8 @@ +import type { AppState } from "@excalidraw/excalidraw/types"; + import { updateBoundElements } from "./binding"; import { getCommonBoundingBox } from "./bounds"; -import { getMaximumGroups } from "./groups"; +import { getSelectedElementsByGroup } from "./groups"; import type { Scene } from "./Scene"; @@ -16,11 +18,12 @@ export const alignElements = ( selectedElements: ExcalidrawElement[], alignment: Alignment, scene: Scene, + appState: Readonly, ): ExcalidrawElement[] => { - const elementsMap = scene.getNonDeletedElementsMap(); - const groups: ExcalidrawElement[][] = getMaximumGroups( + const groups: ExcalidrawElement[][] = getSelectedElementsByGroup( selectedElements, - elementsMap, + scene.getNonDeletedElementsMap(), + appState, ); const selectionBoundingBox = getCommonBoundingBox(selectedElements); diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 9504237b5..bd428d856 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -2,6 +2,7 @@ import { arrayToMap, arrayToObject, assertNever, + invariant, isDevEnv, isShallowEqual, isTestEnv, @@ -548,7 +549,7 @@ export class AppStateDelta implements DeltaContainer { selectedElementIds: addedSelectedElementIds = {}, selectedGroupIds: addedSelectedGroupIds = {}, selectedLinearElementId, - editingLinearElementId, + selectedLinearElementIsEditing, ...directlyApplicablePartial } = this.delta.inserted; @@ -564,39 +565,46 @@ export class AppStateDelta implements DeltaContainer { removedSelectedGroupIds, ); - const selectedLinearElement = - selectedLinearElementId && nextElements.has(selectedLinearElementId) - ? new LinearElementEditor( - nextElements.get( - selectedLinearElementId, - ) as NonDeleted, - nextElements, - ) - : null; + let selectedLinearElement = appState.selectedLinearElement; - const editingLinearElement = - editingLinearElementId && nextElements.has(editingLinearElementId) - ? new LinearElementEditor( - nextElements.get( - editingLinearElementId, - ) as NonDeleted, - nextElements, - ) - : null; + if (selectedLinearElementId === null) { + // Unselect linear element (visible change) + selectedLinearElement = null; + } else if ( + selectedLinearElementId && + nextElements.has(selectedLinearElementId) + ) { + selectedLinearElement = new LinearElementEditor( + nextElements.get( + selectedLinearElementId, + ) as NonDeleted, + nextElements, + selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false + ); + } + + if ( + // Value being 'null' is equivaluent to unknown in this case because it only gets set + // to null when 'selectedLinearElementId' is set to null + selectedLinearElementIsEditing != null + ) { + invariant( + selectedLinearElement, + `selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`, + ); + + selectedLinearElement = { + ...selectedLinearElement, + isEditing: selectedLinearElementIsEditing, + }; + } const nextAppState = { ...appState, ...directlyApplicablePartial, selectedElementIds: mergedSelectedElementIds, selectedGroupIds: mergedSelectedGroupIds, - selectedLinearElement: - typeof selectedLinearElementId !== "undefined" - ? selectedLinearElement // element was either inserted or deleted - : appState.selectedLinearElement, // otherwise assign what we had before - editingLinearElement: - typeof editingLinearElementId !== "undefined" - ? editingLinearElement // element was either inserted or deleted - : appState.editingLinearElement, // otherwise assign what we had before + selectedLinearElement, }; const constainsVisibleChanges = this.filterInvisibleChanges( @@ -725,8 +733,7 @@ export class AppStateDelta implements DeltaContainer { } break; - case "selectedLinearElementId": - case "editingLinearElementId": + case "selectedLinearElementId": { const appStateKey = AppStateDelta.convertToAppStateKey(key); const linearElement = nextAppState[appStateKey]; @@ -746,6 +753,19 @@ export class AppStateDelta implements DeltaContainer { } break; + } + case "selectedLinearElementIsEditing": { + // Changes in editing state are always visible + const prevIsEditing = + prevAppState.selectedLinearElement?.isEditing ?? false; + const nextIsEditing = + nextAppState.selectedLinearElement?.isEditing ?? false; + + if (prevIsEditing !== nextIsEditing) { + visibleDifferenceFlag.value = true; + } + break; + } case "lockedMultiSelections": { const prevLockedUnits = prevAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {}; @@ -779,16 +799,11 @@ export class AppStateDelta implements DeltaContainer { } private static convertToAppStateKey( - key: keyof Pick< - ObservedElementsAppState, - "selectedLinearElementId" | "editingLinearElementId" - >, - ): keyof Pick { + key: keyof Pick, + ): keyof Pick { switch (key) { case "selectedLinearElementId": return "selectedLinearElement"; - case "editingLinearElementId": - return "editingLinearElement"; } } @@ -856,8 +871,8 @@ export class AppStateDelta implements DeltaContainer { editingGroupId, selectedGroupIds, selectedElementIds, - editingLinearElementId, selectedLinearElementId, + selectedLinearElementIsEditing, croppingElementId, lockedMultiSelections, activeLockedId, diff --git a/packages/element/src/distribute.ts b/packages/element/src/distribute.ts index da79837da..add3522ac 100644 --- a/packages/element/src/distribute.ts +++ b/packages/element/src/distribute.ts @@ -1,7 +1,9 @@ +import type { AppState } from "@excalidraw/excalidraw/types"; + import { getCommonBoundingBox } from "./bounds"; import { newElementWith } from "./mutateElement"; -import { getMaximumGroups } from "./groups"; +import { getSelectedElementsByGroup } from "./groups"; import type { ElementsMap, ExcalidrawElement } from "./types"; @@ -14,6 +16,7 @@ export const distributeElements = ( selectedElements: ExcalidrawElement[], elementsMap: ElementsMap, distribution: Distribution, + appState: Readonly, ): ExcalidrawElement[] => { const [start, mid, end, extent] = distribution.axis === "x" @@ -21,7 +24,11 @@ export const distributeElements = ( : (["minY", "midY", "maxY", "height"] as const); const bounds = getCommonBoundingBox(selectedElements); - const groups = getMaximumGroups(selectedElements, elementsMap) + const groups = getSelectedElementsByGroup( + selectedElements, + elementsMap, + appState, + ) .map((group) => [group, getCommonBoundingBox(group)] as const) .sort((a, b) => a[1][mid] - b[1][mid]); diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 78dc26fe2..71c75cc23 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired; const embeddedLinkCache = new Map(); const RE_YOUTUBE = - /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; + /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/; const RE_VIMEO = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; @@ -56,6 +56,35 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^ { + let timeParam: string | null | undefined; + + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + timeParam = + urlObj.searchParams.get("t") || urlObj.searchParams.get("start"); + } catch (error) { + const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/); + timeParam = timeMatch?.[1]; + } + + if (!timeParam) { + return 0; + } + + if (/^\d+$/.test(timeParam)) { + return parseInt(timeParam, 10); + } + + const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); + if (!timeMatch) { + return 0; + } + + const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch; + return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", @@ -113,7 +142,8 @@ export const getEmbedLink = ( let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); if (ytLink?.[2]) { - const time = ytLink[3] ? `&start=${ytLink[3]}` : ``; + const startTime = parseYouTubeTimestamp(originalLink); + const time = startTime > 0 ? `&start=${startTime}` : ``; const isPortrait = link.includes("shorts"); type = "video"; switch (ytLink[1]) { diff --git a/packages/element/src/groups.ts b/packages/element/src/groups.ts index 1cd1536e1..40f787a01 100644 --- a/packages/element/src/groups.ts +++ b/packages/element/src/groups.ts @@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import { getBoundTextElement } from "./textElement"; +import { isBoundToContainer } from "./typeChecks"; + import { makeNextSelectedElementIds, getSelectedElements } from "./selection"; import type { @@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = ( return copy; }; + +// given a list of selected elements, return the element grouped by their immediate group selected state +// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order +export const getSelectedElementsByGroup = ( + selectedElements: ExcalidrawElement[], + elementsMap: ElementsMap, + appState: Readonly, +): ExcalidrawElement[][] => { + const selectedGroupIds = getSelectedGroupIds(appState); + const unboundElements = selectedElements.filter( + (element) => !isBoundToContainer(element), + ); + const groups: Map = new Map(); + const elements: Map = new Map(); + + // helper function to add an element to the elements map + const addToElementsMap = (element: ExcalidrawElement) => { + // elements + const currentElementMembers = elements.get(element.id) || []; + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (boundTextElement) { + currentElementMembers.push(boundTextElement); + } + elements.set(element.id, [...currentElementMembers, element]); + }; + + // helper function to add an element to the groups map + const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => { + // groups + const currentGroupMembers = groups.get(groupId) || []; + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (boundTextElement) { + currentGroupMembers.push(boundTextElement); + } + groups.set(groupId, [...currentGroupMembers, element]); + }; + + // helper function to handle the case where a single group is selected + // and all elements selected are within the group, it will respect group hierarchy in accordance to + // their nested grouping order + const handleSingleSelectedGroupCase = ( + element: ExcalidrawElement, + selectedGroupId: GroupId, + ) => { + const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0); + const nestedGroupCount = element.groupIds.slice( + 0, + indexOfSelectedGroupId, + ).length; + return nestedGroupCount > 0 + ? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1]) + : addToElementsMap(element); + }; + + const isAllInSameGroup = selectedElements.every((element) => + isSelectedViaGroup(appState, element), + ); + + unboundElements.forEach((element) => { + const selectedGroupId = getSelectedGroupIdForElement( + element, + appState.selectedGroupIds, + ); + if (!selectedGroupId) { + addToElementsMap(element); + } else if (selectedGroupIds.length === 1 && isAllInSameGroup) { + handleSingleSelectedGroupCase(element, selectedGroupId); + } else { + addToGroupsMap(element, selectedGroupId); + } + }); + return Array.from(groups.values()).concat(Array.from(elements.values())); +}; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index dc869a4dc..6242404ad 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -153,10 +153,12 @@ export class LinearElementEditor { public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; public readonly customLineAngle: number | null; + public readonly isEditing: boolean; constructor( element: NonDeleted, elementsMap: ElementsMap, + isEditing: boolean = false, ) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; @@ -191,6 +193,7 @@ export class LinearElementEditor { this.segmentMidPointHoveredCoords = null; this.elbowed = isElbowArrow(element) && element.elbowed; this.customLineAngle = null; + this.isEditing = isEditing; } // --------------------------------------------------------------------------- @@ -198,6 +201,7 @@ export class LinearElementEditor { // --------------------------------------------------------------------------- static POINT_HANDLE_SIZE = 10; + /** * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) @@ -219,11 +223,14 @@ export class LinearElementEditor { setState: React.Component["setState"], elementsMap: NonDeletedSceneElementsMap, ) { - if (!appState.editingLinearElement || !appState.selectionElement) { + if ( + !appState.selectedLinearElement?.isEditing || + !appState.selectionElement + ) { return false; } - const { editingLinearElement } = appState; - const { selectedPointsIndices, elementId } = editingLinearElement; + const { selectedLinearElement } = appState; + const { selectedPointsIndices, elementId } = selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { @@ -264,8 +271,8 @@ export class LinearElementEditor { }); setState({ - editingLinearElement: { - ...editingLinearElement, + selectedLinearElement: { + ...selectedLinearElement, selectedPointsIndices: nextSelectedPoints.length ? nextSelectedPoints : null, @@ -597,9 +604,6 @@ export class LinearElementEditor { return { ...app.state, - editingLinearElement: app.state.editingLinearElement - ? newLinearElementEditor - : null, selectedLinearElement: newLinearElementEditor, suggestedBindings, snapLines: _snapLines, @@ -737,7 +741,7 @@ export class LinearElementEditor { // Since its not needed outside editor unless 2 pointer lines or bound text if ( !isElbowArrow(element) && - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && element.points.length > 2 && !boundText ) { @@ -803,7 +807,7 @@ export class LinearElementEditor { ); if ( points.length >= 3 && - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && !isElbowArrow(element) ) { return null; @@ -1000,7 +1004,7 @@ export class LinearElementEditor { segmentMidpoint, elementsMap, ); - } else if (event.altKey && appState.editingLinearElement) { + } else if (event.altKey && appState.selectedLinearElement?.isEditing) { if (linearElementEditor.lastUncommittedPoint == null) { scene.mutateElement(element, { points: [ @@ -1141,19 +1145,19 @@ export class LinearElementEditor { scenePointerY: number, app: AppClassProperties, ): { - linearElementEditor: LinearElementEditor; + editingLinearElement: LinearElementEditor; snapLines: readonly SnapLine[]; } | null { const appState = app.state; - if (!appState.editingLinearElement) { + if (!appState.selectedLinearElement?.isEditing) { return null; } - const { elementId, lastUncommittedPoint } = appState.editingLinearElement; + const { elementId, lastUncommittedPoint } = appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return { - linearElementEditor: appState.editingLinearElement, + editingLinearElement: appState.selectedLinearElement, snapLines: appState.snapLines, }; } @@ -1166,8 +1170,8 @@ export class LinearElementEditor { LinearElementEditor.deletePoints(element, app, [points.length - 1]); } return { - linearElementEditor: { - ...appState.editingLinearElement, + editingLinearElement: { + ...appState.selectedLinearElement, lastUncommittedPoint: null, isDragging: false, pointerOffset: { x: 0, y: 0 }, @@ -1298,9 +1302,9 @@ export class LinearElementEditor { ); } else { const originalPointerX = - scenePointerX - appState.editingLinearElement.pointerOffset.x; + scenePointerX - appState.selectedLinearElement.pointerOffset.x; const originalPointerY = - scenePointerY - appState.editingLinearElement.pointerOffset.y; + scenePointerY - appState.selectedLinearElement.pointerOffset.y; const { snapOffset, snapLines: snappingLines } = snapLinearElementPoint( app.scene.getNonDeletedElements(), @@ -1347,8 +1351,8 @@ export class LinearElementEditor { } return { - linearElementEditor: { - ...appState.editingLinearElement, + editingLinearElement: { + ...appState.selectedLinearElement, lastUncommittedPoint: element.points[element.points.length - 1], }, snapLines, @@ -1509,12 +1513,12 @@ export class LinearElementEditor { // --------------------------------------------------------------------------- static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { invariant( - appState.editingLinearElement, + appState.selectedLinearElement?.isEditing, "Not currently editing a linear element", ); const elementsMap = scene.getNonDeletedElementsMap(); - const { selectedPointsIndices, elementId } = appState.editingLinearElement; + const { selectedPointsIndices, elementId } = appState.selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); invariant( @@ -1576,8 +1580,8 @@ export class LinearElementEditor { return { ...appState, - editingLinearElement: { - ...appState.editingLinearElement, + selectedLinearElement: { + ...appState.selectedLinearElement, selectedPointsIndices: nextSelectedIndices, }, }; @@ -1589,8 +1593,9 @@ export class LinearElementEditor { pointIndices: readonly number[], ) { const isUncommittedPoint = - app.state.editingLinearElement?.lastUncommittedPoint === - element.points[element.points.length - 1]; + app.state.selectedLinearElement?.isEditing && + app.state.selectedLinearElement?.lastUncommittedPoint === + element.points[element.points.length - 1]; const nextPoints = element.points.filter((_, idx) => { return !pointIndices.includes(idx); @@ -1763,7 +1768,7 @@ export class LinearElementEditor { pointFrom(pointerCoords.x, pointerCoords.y), ); if ( - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && dist < DRAGGING_THRESHOLD / appState.zoom.value ) { return false; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index e870d977f..008d6afc4 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => { return element.strokeWidth * 12; case "text": return element.fontSize / 2; + case "arrow": + if (element.endArrowhead || element.endArrowhead) { + return 40; + } + return 20; default: return 20; } diff --git a/packages/element/src/snapping.ts b/packages/element/src/snapping.ts index 5c4e21741..d27b3023b 100644 --- a/packages/element/src/snapping.ts +++ b/packages/element/src/snapping.ts @@ -14,11 +14,7 @@ import { getDraggedElementsBounds, getElementAbsoluteCoords, } from "@excalidraw/element"; -import { - isBoundToContainer, - isFrameLikeElement, - isElbowArrow, -} from "@excalidraw/element"; +import { isBoundToContainer, isElbowArrow } from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element"; @@ -379,20 +375,13 @@ const getReferenceElements = ( selectedElements: NonDeletedExcalidrawElement[], appState: AppState, elementsMap: ElementsMap, -) => { - const selectedFrames = selectedElements - .filter((element) => isFrameLikeElement(element)) - .map((frame) => frame.id); - - return getVisibleAndNonSelectedElements( +) => + getVisibleAndNonSelectedElements( elements, selectedElements, appState, elementsMap, - ).filter( - (element) => !(element.frameId && selectedFrames.includes(element.frameId)), ); -}; export const getVisibleGaps = ( elements: readonly NonDeletedExcalidrawElement[], diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 0f5933422..2bf70f581 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -27,6 +27,8 @@ import { isImageElement, } from "./index"; +import type { ApplyToOptions } from "./delta"; + import type { ExcalidrawElement, OrderedExcalidrawElement, @@ -570,9 +572,15 @@ export class StoreDelta { delta: StoreDelta, elements: SceneElementsMap, appState: AppState, + options: ApplyToOptions = { + excludedProperties: new Set(), + }, ): [SceneElementsMap, AppState, boolean] { - const [nextElements, elementsContainVisibleChange] = - delta.elements.applyTo(elements); + const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( + elements, + StoreSnapshot.empty().elements, + options, + ); const [nextAppState, appStateContainsVisibleChange] = delta.appState.applyTo(appState, nextElements); @@ -970,8 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => { viewBackgroundColor: COLOR_PALETTE.white, selectedElementIds: {}, selectedGroupIds: {}, - editingLinearElementId: null, selectedLinearElementId: null, + selectedLinearElementIsEditing: null, croppingElementId: null, activeLockedId: null, lockedMultiSelections: {}, @@ -990,14 +998,14 @@ export const getObservedAppState = ( croppingElementId: appState.croppingElementId, activeLockedId: appState.activeLockedId, lockedMultiSelections: appState.lockedMultiSelections, - editingLinearElementId: - (appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer - (appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot - null, selectedLinearElementId: (appState as AppState).selectedLinearElement?.elementId ?? (appState as ObservedAppState).selectedLinearElementId ?? null, + selectedLinearElementIsEditing: + (appState as AppState).selectedLinearElement?.isEditing ?? + (appState as ObservedAppState).selectedLinearElementIsEditing ?? + null, }; Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { diff --git a/packages/element/src/transformHandles.ts b/packages/element/src/transformHandles.ts index f2b0cd278..679937d4a 100644 --- a/packages/element/src/transformHandles.ts +++ b/packages/element/src/transformHandles.ts @@ -330,7 +330,7 @@ export const shouldShowBoundingBox = ( elements: readonly NonDeletedExcalidrawElement[], appState: InteractiveCanvasAppState, ) => { - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { return false; } if (elements.length > 1) { diff --git a/packages/element/tests/align.test.tsx b/packages/element/tests/align.test.tsx index afffb72cb..b79679369 100644 --- a/packages/element/tests/align.test.tsx +++ b/packages/element/tests/align.test.tsx @@ -589,4 +589,424 @@ describe("aligning", () => { expect(API.getSelectedElements()[2].x).toEqual(250); expect(API.getSelectedElements()[3].x).toEqual(150); }); + + const createGroupAndSelectInEditGroupMode = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + mouse.reset(); + mouse.moveTo(10, 0); + mouse.doubleClick(); + + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(); + mouse.moveTo(100, 100); + mouse.click(); + }); + }; + + it("aligns elements within a group while in group edit mode correctly to the top", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(0); + }); + it("aligns elements within a group while in group edit mode correctly to the bottom", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(100); + }); + it("aligns elements within a group while in group edit mode correctly to the left", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(0); + }); + it("aligns elements within a group while in group edit mode correctly to the right", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(100); + }); + it("aligns elements within a group while in group edit mode correctly to the vertical center", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(50); + }); + it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(50); + }); + + const createNestedGroupAndSelectInEditGroupMode = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // Select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + + mouse.reset(); + mouse.moveTo(200, 200); + // create third element + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // third element is already selected, select the initial group and group together + mouse.reset(); + + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + + // double click to enter edit mode + mouse.doubleClick(); + + // select nested group and other element within the group + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(200, 200); + mouse.click(); + }); + }; + + it("aligns element and nested group while in group edit mode correctly to the top", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(0); + }); + it("aligns element and nested group while in group edit mode correctly to the bottom", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(200); + expect(API.getSelectedElements()[2].y).toEqual(200); + }); + it("aligns element and nested group while in group edit mode correctly to the left", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(0); + }); + it("aligns element and nested group while in group edit mode correctly to the right", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(200); + expect(API.getSelectedElements()[2].x).toEqual(200); + }); + it("aligns element and nested group while in group edit mode correctly to the vertical center", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(150); + expect(API.getSelectedElements()[2].y).toEqual(100); + }); + it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(150); + expect(API.getSelectedElements()[2].x).toEqual(100); + }); + + const createAndSelectSingleGroup = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // Select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + }; + + it("aligns elements within a single-selected group correctly to the top", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(0); + }); + it("aligns elements within a single-selected group correctly to the bottom", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(100); + }); + it("aligns elements within a single-selected group correctly to the left", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(0); + }); + it("aligns elements within a single-selected group correctly to the right", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(100); + }); + it("aligns elements within a single-selected group correctly to the vertical center", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(50); + }); + it("aligns elements within a single-selected group correctly to the horizontal center", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(50); + }); + + const createAndSelectSingleGroupWithNestedGroup = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // Select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + + mouse.reset(); + UI.clickTool("rectangle"); + mouse.down(200, 200); + mouse.up(100, 100); + + // Add group to current selection + mouse.restorePosition(10, 0); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(); + }); + + // Create the nested group + API.executeAction(actionGroup); + }; + it("aligns elements within a single-selected group containing a nested group correctly to the top", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(0); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(200); + expect(API.getSelectedElements()[2].y).toEqual(200); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the left", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(0); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the right", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(200); + expect(API.getSelectedElements()[2].x).toEqual(200); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(150); + expect(API.getSelectedElements()[2].y).toEqual(100); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(150); + expect(API.getSelectedElements()[2].x).toEqual(100); + }); }); diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index 69f4e6dde..a3da1c66d 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -155,10 +155,10 @@ describe("element binding", () => { // NOTE this mouse down/up + await needs to be done in order to repro // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 mouse.reset(); - expect(h.state.editingLinearElement).not.toBe(null); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); mouse.down(0, 0); await new Promise((r) => setTimeout(r, 100)); - expect(h.state.editingLinearElement).toBe(null); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(API.getSelectedElement().type).toBe("rectangle"); mouse.up(); expect(API.getSelectedElement().type).toBe("rectangle"); diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 9c416f6ef..4d56aac83 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -16,6 +16,7 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, editingLinearElementId: null, + selectedLinearElementIsEditing: null, lockedMultiSelections: {}, activeLockedId: null, }; @@ -58,6 +59,7 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, selectedLinearElementId: null, + selectedLinearElementIsEditing: null, editingLinearElementId: null, activeLockedId: null, lockedMultiSelections: {}, @@ -105,6 +107,7 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, selectedLinearElementId: null, + selectedLinearElementIsEditing: null, editingLinearElementId: null, activeLockedId: null, lockedMultiSelections: {}, diff --git a/packages/element/tests/distribute.test.tsx b/packages/element/tests/distribute.test.tsx new file mode 100644 index 000000000..b59567e42 --- /dev/null +++ b/packages/element/tests/distribute.test.tsx @@ -0,0 +1,128 @@ +import { + distributeHorizontally, + distributeVertically, +} from "@excalidraw/excalidraw/actions"; +import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n"; +import { Excalidraw } from "@excalidraw/excalidraw"; + +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; +import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; +import { + act, + unmountComponent, + render, +} from "@excalidraw/excalidraw/tests/test-utils"; + +const mouse = new Pointer("mouse"); + +// Scenario: three rectangles that will be distributed with gaps +const createAndSelectThreeRectanglesWithGap = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(10, 10); + mouse.up(100, 100); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(300, 300); + mouse.up(100, 100); + mouse.reset(); + + // Last rectangle is selected by default + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(0, 10); + mouse.click(10, 0); + }); +}; + +// Scenario: three rectangles that will be distributed by their centers +const createAndSelectThreeRectanglesWithoutGap = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(10, 10); + mouse.up(200, 200); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(200, 200); + mouse.up(100, 100); + mouse.reset(); + + // Last rectangle is selected by default + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(0, 10); + mouse.click(10, 0); + }); +}; + +describe("distributing", () => { + beforeEach(async () => { + unmountComponent(); + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should distribute selected elements horizontally", async () => { + createAndSelectThreeRectanglesWithGap(); + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(10); + expect(API.getSelectedElements()[2].x).toEqual(300); + + API.executeAction(distributeHorizontally); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(150); + expect(API.getSelectedElements()[2].x).toEqual(300); + }); + + it("should distribute selected elements vertically", async () => { + createAndSelectThreeRectanglesWithGap(); + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(10); + expect(API.getSelectedElements()[2].y).toEqual(300); + + API.executeAction(distributeVertically); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(150); + expect(API.getSelectedElements()[2].y).toEqual(300); + }); + + it("should distribute selected elements horizontally based on their centers", async () => { + createAndSelectThreeRectanglesWithoutGap(); + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(10); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(distributeHorizontally); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(50); + expect(API.getSelectedElements()[2].x).toEqual(200); + }); + + it("should distribute selected elements vertically with based on their centers", async () => { + createAndSelectThreeRectanglesWithoutGap(); + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(10); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(distributeVertically); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(50); + expect(API.getSelectedElements()[2].y).toEqual(200); + }); +}); diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts new file mode 100644 index 000000000..7f585e866 --- /dev/null +++ b/packages/element/tests/embeddable.test.ts @@ -0,0 +1,153 @@ +import { getEmbedLink } from "../src/embeddable"; + +describe("YouTube timestamp parsing", () => { + it("should parse YouTube URLs with timestamp in seconds", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90", + expectedStart: 90, + }, + { + url: "https://youtu.be/dQw4w9WgXcQ?t=120", + expectedStart: 120, + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150", + expectedStart: 150, + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain(`start=${expectedStart}`); + } + }); + }); + + it("should parse YouTube URLs with timestamp in time format", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s", + expectedStart: 90, // 1*60 + 30 + }, + { + url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s", + expectedStart: 165, // 2*60 + 45 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s", + expectedStart: 3723, // 1*3600 + 2*60 + 3 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s", + expectedStart: 45, + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m", + expectedStart: 300, // 5*60 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h", + expectedStart: 7200, // 2*3600 + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain(`start=${expectedStart}`); + } + }); + }); + + it("should handle YouTube URLs without timestamps", () => { + const testCases = [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + ]; + + testCases.forEach((url) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).not.toContain("start="); + } + }); + }); + + it("should handle YouTube shorts URLs with timestamps", () => { + const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=30"); + } + // Shorts should have portrait aspect ratio + expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 }); + }); + + it("should handle playlist URLs with timestamps", () => { + const url = + "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=60"); + expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ"); + } + }); + + it("should handle malformed or edge case timestamps", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc", + expectedStart: 0, // Invalid timestamp should default to 0 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=", + expectedStart: 0, // Empty timestamp should default to 0 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0", + expectedStart: 0, // Zero timestamp should be handled + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + if (expectedStart === 0) { + expect(result.link).not.toContain("start="); + } else { + expect(result.link).toContain(`start=${expectedStart}`); + } + } + }); + }); + + it("should preserve other URL parameters", () => { + const url = + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=90"); + expect(result.link).toContain("enablejsapi=1"); + } + }); +}); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 4b957022c..f1306b872 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -136,7 +136,8 @@ describe("Test Linear Elements", () => { Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); - expect(h.state.editingLinearElement?.elementId).toEqual(line.id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(line.id); }; const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { @@ -253,75 +254,82 @@ describe("Test Linear Elements", () => { }); fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor via enter (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.clickAt(midpoint[0], midpoint[1]); Keyboard.keyPress(KEYS.ENTER); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); // ctrl+enter alias (to align with arrows) it("should enter line editor via ctrl+enter (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.clickAt(midpoint[0], midpoint[1]); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor via ctrl+enter (arrow)", () => { createTwoPointerLinearElement("arrow"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.clickAt(midpoint[0], midpoint[1]); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor on ctrl+dblclick (simple arrow)", () => { createTwoPointerLinearElement("arrow"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.doubleClick(); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor on ctrl+dblclick (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.doubleClick(); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor on dblclick (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.doubleClick(); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should not enter line editor on dblclick (arrow)", async () => { createTwoPointerLinearElement("arrow"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.doubleClick(); - expect(h.state.editingLinearElement).toEqual(null); + expect(h.state.selectedLinearElement).toBe(null); await getTextEditor(); }); @@ -330,10 +338,12 @@ describe("Test Linear Elements", () => { const arrow = h.elements[0] as ExcalidrawLinearElement; enterLineEditingMode(arrow); - expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id); mouse.doubleClick(); - expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id); expect(h.elements.length).toEqual(1); expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); @@ -367,7 +377,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -469,7 +479,7 @@ describe("Test Linear Elements", () => { drag(startPoint, endPoint); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -537,7 +547,7 @@ describe("Test Linear Elements", () => { ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `16`, + `14`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -588,7 +598,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -629,7 +639,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -677,7 +687,7 @@ describe("Test Linear Elements", () => { deletePoint(points[2]); expect(line.points.length).toEqual(3); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `18`, + `17`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -735,7 +745,7 @@ describe("Test Linear Elements", () => { ), ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `16`, + `14`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(5); @@ -833,7 +843,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index de5cd2c1e..63a887635 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; +import { getSelectedElementsByGroup } from "@excalidraw/element"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Alignment } from "@excalidraw/element"; @@ -38,7 +40,11 @@ export const alignActionsPredicate = ( ) => { const selectedElements = app.scene.getSelectedElements(appState); return ( - selectedElements.length > 1 && + getSelectedElementsByGroup( + selectedElements, + app.scene.getNonDeletedElementsMap(), + appState as Readonly, + ).length > 1 && // TODO enable aligning frames when implemented properly !selectedElements.some((el) => isFrameLikeElement(el)) ); @@ -52,7 +58,12 @@ const alignSelectedElements = ( ) => { const selectedElements = app.scene.getSelectedElements(appState); - const updatedElements = alignElements(selectedElements, alignment, app.scene); + const updatedElements = alignElements( + selectedElements, + alignment, + app.scene, + appState, + ); const updatedElementsMap = arrayToMap(updatedElements); diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 20d7d129f..a9281ce84 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -205,16 +205,19 @@ export const actionDeleteSelected = register({ icon: TrashIcon, trackEvent: { category: "element", action: "delete" }, perform: (elements, appState, formData, app) => { - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { const { elementId, selectedPointsIndices, startBindingElement, endBindingElement, - } = appState.editingLinearElement; + } = appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); - const element = LinearElementEditor.getElement(elementId, elementsMap); - if (!element) { + const linearElement = LinearElementEditor.getElement( + elementId, + elementsMap, + ); + if (!linearElement) { return false; } // case: no point selected → do nothing, as deleting the whole element @@ -225,10 +228,10 @@ export const actionDeleteSelected = register({ return false; } - // case: deleting last remaining point - if (element.points.length < 2) { + // case: deleting all points + if (selectedPointsIndices.length >= linearElement.points.length) { const nextElements = elements.map((el) => { - if (el.id === element.id) { + if (el.id === linearElement.id) { return newElementWith(el, { isDeleted: true }); } return el; @@ -239,7 +242,7 @@ export const actionDeleteSelected = register({ elements: nextElements, appState: { ...nextAppState, - editingLinearElement: null, + selectedLinearElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; @@ -252,20 +255,24 @@ export const actionDeleteSelected = register({ ? null : startBindingElement, endBindingElement: selectedPointsIndices?.includes( - element.points.length - 1, + linearElement.points.length - 1, ) ? null : endBindingElement, }; - LinearElementEditor.deletePoints(element, app, selectedPointsIndices); + LinearElementEditor.deletePoints( + linearElement, + app, + selectedPointsIndices, + ); return { elements, appState: { ...appState, - editingLinearElement: { - ...appState.editingLinearElement, + selectedLinearElement: { + ...appState.selectedLinearElement, ...binding, selectedPointsIndices: selectedPointsIndices?.[0] > 0 diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index bd823ec01..f02906741 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; +import { getSelectedElementsByGroup } from "@excalidraw/element"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Distribution } from "@excalidraw/element"; @@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types"; const enableActionGroup = (appState: AppState, app: AppClassProperties) => { const selectedElements = app.scene.getSelectedElements(appState); return ( - selectedElements.length > 1 && + getSelectedElementsByGroup( + selectedElements, + app.scene.getNonDeletedElementsMap(), + appState as Readonly, + ).length > 2 && // TODO enable distributing frames when implemented properly !selectedElements.some((el) => isFrameLikeElement(el)) ); @@ -49,6 +55,7 @@ const distributeSelectedElements = ( selectedElements, app.scene.getNonDeletedElementsMap(), distribution, + appState, ); const updatedElementsMap = arrayToMap(updatedElements); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index b6363a730..c1b2a9da4 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({ } // duplicate selected point(s) if editing a line - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { // TODO: Invariants should be checked here instead of duplicateSelectedPoints() try { const newAppState = LinearElementEditor.duplicateSelectedPoints( diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 7a4511e05..9baeb0b6f 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -94,9 +94,9 @@ export const actionFinalize = register({ } } - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { const { elementId, startBindingElement, endBindingElement } = - appState.editingLinearElement; + appState.selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { @@ -122,7 +122,11 @@ export const actionFinalize = register({ appState: { ...appState, cursorButton: "up", - editingLinearElement: null, + selectedLinearElement: new LinearElementEditor( + element, + arrayToMap(elementsMap), + false, // exit editing mode + ), }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; @@ -154,11 +158,7 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if ( - appState.multiElement && - element.type !== "freedraw" && - appState.lastPointerDownWith !== "touch" - ) { + if (appState.multiElement && element.type !== "freedraw") { const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || @@ -289,7 +289,7 @@ export const actionFinalize = register({ }, keyTest: (event, appState) => (event.key === KEYS.ESCAPE && - (appState.editingLinearElement !== null || + (appState.selectedLinearElement?.isEditing || (!appState.newElement && appState.multiElement === null))) || ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && appState.multiElement !== null), diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 28295d939..9b18c64de 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -1,10 +1,9 @@ -import { LinearElementEditor } from "@excalidraw/element"; import { isElbowArrow, isLinearElement, isLineElement, } from "@excalidraw/element"; -import { arrayToMap } from "@excalidraw/common"; +import { arrayToMap, invariant } from "@excalidraw/common"; import { toggleLinePolygonState, @@ -46,7 +45,7 @@ export const actionToggleLinearEditor = register({ predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); if ( - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && selectedElements.length === 1 && isLinearElement(selectedElements[0]) && !isElbowArrow(selectedElements[0]) @@ -61,14 +60,25 @@ export const actionToggleLinearEditor = register({ includeBoundTextElement: true, })[0] as ExcalidrawLinearElement; - const editingLinearElement = - appState.editingLinearElement?.elementId === selectedElement.id - ? null - : new LinearElementEditor(selectedElement, arrayToMap(elements)); + invariant(selectedElement, "No selected element found"); + invariant( + appState.selectedLinearElement, + "No selected linear element found", + ); + invariant( + selectedElement.id === appState.selectedLinearElement.elementId, + "Selected element ID and linear editor elementId does not match", + ); + + const selectedLinearElement = { + ...appState.selectedLinearElement, + isEditing: !appState.selectedLinearElement.isEditing, + }; + return { appState: { ...appState, - editingLinearElement, + selectedLinearElement, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index a58052858..7157d7617 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -21,7 +21,7 @@ export const actionSelectAll = register({ trackEvent: { category: "canvas" }, viewMode: false, perform: (elements, appState, value, app) => { - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { return false; } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index dcc3fba11..6c4a97116 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit< newElement: null, editingTextElement: null, editingGroupId: null, - editingLinearElement: null, activeTool: { type: "selection", customType: null, @@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (< newElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false }, - editingLinearElement: { browser: false, export: false, server: false }, activeTool: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 919e9c688..5c9d59ada 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -140,7 +140,7 @@ export const SelectedShapeActions = ({ targetElements.length === 1 || isSingleElementBoundContainer; const showLineEditorAction = - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && targetElements.length === 1 && isLinearElement(targetElements[0]) && !isElbowArrow(targetElements[0]); @@ -505,15 +505,3 @@ export const ExitZenModeAction = ({ {t("buttons.exitZenMode")} ); - -export const FinalizeAction = ({ - renderAction, - className, -}: { - renderAction: ActionManager["renderAction"]; - className?: string; -}) => ( -
- {renderAction("finalize", { size: "small" })} -
-); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d744f2fea..585efa132 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -102,6 +102,7 @@ import { randomInteger, CLASSES, Emitter, + MINIMUM_ARROW_SIZE, } from "@excalidraw/common"; import { @@ -594,6 +595,10 @@ class App extends React.Component { * insert to DOM before user initially scrolls to them) */ private initializedEmbeds = new Set(); + private handleToastClose = () => { + this.setToast(null); + }; + private elementsPendingErasure: ElementsPendingErasure = new Set(); public flowChartCreator: FlowChartCreator = new FlowChartCreator(); @@ -1708,14 +1713,16 @@ class App extends React.Component { /> )} + {this.state.toast !== null && ( this.setToast(null)} + onClose={this.handleToastClose} duration={this.state.toast.duration} closable={this.state.toast.closable} /> )} + {this.state.contextMenu && ( { public dismissLinearEditor = () => { setTimeout(() => { - this.setState({ - editingLinearElement: null, - }); + if (this.state.selectedLinearElement?.isEditing) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isEditing: false, + }, + }); + } }); }; @@ -2850,15 +2862,15 @@ class App extends React.Component { ); if ( - this.state.editingLinearElement && - !this.state.selectedElementIds[this.state.editingLinearElement.elementId] + this.state.selectedLinearElement?.isEditing && + !this.state.selectedElementIds[this.state.selectedLinearElement.elementId] ) { // defer so that the scheduleCapture flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how // many times the component renders) - this.state.editingLinearElement && + this.state.selectedLinearElement?.isEditing && this.actionManager.executeAction(actionFinalize); }); } @@ -4413,17 +4425,13 @@ class App extends React.Component { if (event[KEYS.CTRL_OR_CMD] || isLineElement(selectedElement)) { if (isLinearElement(selectedElement)) { if ( - !this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== selectedElement.id + !this.state.selectedLinearElement?.isEditing || + this.state.selectedLinearElement.elementId !== + selectedElement.id ) { this.store.scheduleCapture(); if (!isElbowArrow(selectedElement)) { - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElement, - this.scene.getNonDeletedElementsMap(), - ), - }); + this.actionManager.executeAction(actionToggleLinearEditor); } } } @@ -4920,7 +4928,17 @@ class App extends React.Component { }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { const isDeleted = !nextOriginalText.trim(); - updateElement(nextOriginalText, isDeleted); + + if (isDeleted && !isExistingElement) { + // let's just remove the element from the scene, as it's an empty just created text element + this.scene.replaceAllElements( + this.scene + .getElementsIncludingDeleted() + .filter((x) => x.id !== element.id), + ); + } else { + updateElement(nextOriginalText, isDeleted); + } // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { @@ -4949,9 +4967,10 @@ class App extends React.Component { element, ]); } - if (!isDeleted || isExistingElement) { - this.store.scheduleCapture(); - } + + // we need to record either way, whether the text element was added or removed + // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies + this.store.scheduleCapture(); flushSync(() => { this.setState({ @@ -5415,15 +5434,12 @@ class App extends React.Component { if ( ((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) || isLineElement(selectedLinearElement)) && - this.state.editingLinearElement?.elementId !== selectedLinearElement.id + (!this.state.selectedLinearElement?.isEditing || + this.state.selectedLinearElement.elementId !== + selectedLinearElement.id) ) { - this.store.scheduleCapture(); - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedLinearElement, - this.scene.getNonDeletedElementsMap(), - ), - }); + // Use the proper action to ensure immediate history capture + this.actionManager.executeAction(actionToggleLinearEditor); return; } else if ( this.state.selectedLinearElement && @@ -5488,8 +5504,8 @@ class App extends React.Component { return; } } else if ( - this.state.editingLinearElement && - this.state.editingLinearElement.elementId === + this.state.selectedLinearElement?.isEditing && + this.state.selectedLinearElement.elementId === selectedLinearElement.id && isLineElement(selectedLinearElement) ) { @@ -5544,7 +5560,7 @@ class App extends React.Component { // shouldn't edit/create text when inside line editor (often false positive) - if (!this.state.editingLinearElement) { + if (!this.state.selectedLinearElement?.isEditing) { const container = this.getTextBindableContainerAtPosition( sceneX, sceneY, @@ -5846,8 +5862,8 @@ class App extends React.Component { } if ( - this.state.editingLinearElement && - !this.state.editingLinearElement.isDragging + this.state.selectedLinearElement?.isEditing && + !this.state.selectedLinearElement.isDragging ) { const result = LinearElementEditor.handlePointerMove( event, @@ -5857,32 +5873,36 @@ class App extends React.Component { ); if (result) { - const { linearElementEditor: editingLinearElement, snapLines } = result; + const { editingLinearElement, snapLines } = result; if ( editingLinearElement && - editingLinearElement !== this.state.editingLinearElement + editingLinearElement !== this.state.selectedLinearElement ) { // Since we are reading from previous state which is not possible with // automatic batching in React 18 hence using flush sync to synchronously // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. flushSync(() => { this.setState({ - editingLinearElement, + selectedLinearElement: editingLinearElement, snapLines, }); }); } - if (editingLinearElement?.lastUncommittedPoint != null) { + const latestLinearElement = this.scene.getElement( + editingLinearElement.elementId, + ); + if ( + editingLinearElement.lastUncommittedPoint != null && + latestLinearElement && + isBindingElementType(latestLinearElement.type) + ) { this.maybeSuggestBindingAtCursor( scenePointer, editingLinearElement.elbowed, ); - } else { - // causes stack overflow if not sync - flushSync(() => { - this.setState({ suggestedBindings: [] }); - }); + } else if (this.state.suggestedBindings.length) { + this.setState({ suggestedBindings: [] }); } } } @@ -6118,7 +6138,7 @@ class App extends React.Component { if ( selectedElements.length === 1 && !isOverScrollBar && - !this.state.editingLinearElement + !this.state.selectedLinearElement?.isEditing ) { // for linear elements, we'd like to prioritize point dragging over edge resizing // therefore, we update and check hovered point index first @@ -6236,15 +6256,6 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if (isOverScrollBar) { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); - } else if ( - this.state.selectedLinearElement && - hitElement?.id === this.state.selectedLinearElement.elementId - ) { - this.handleHoverSelectedLinearElement( - this.state.selectedLinearElement, - scenePointerX, - scenePointerY, - ); } else if ( // if using cmd/ctrl, we're not dragging !event[KEYS.CTRL_OR_CMD] @@ -6286,6 +6297,14 @@ class App extends React.Component { } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } + + if (this.state.selectedLinearElement) { + this.handleHoverSelectedLinearElement( + this.state.selectedLinearElement, + scenePointerX, + scenePointerY, + ); + } } if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) { @@ -7123,7 +7142,7 @@ class App extends React.Component { if ( selectedElements.length === 1 && - !this.state.editingLinearElement && + !this.state.selectedLinearElement?.isEditing && !isElbowArrow(selectedElements[0]) && !( this.state.selectedLinearElement && @@ -7194,8 +7213,7 @@ class App extends React.Component { } } else { if (this.state.selectedLinearElement) { - const linearElementEditor = - this.state.editingLinearElement || this.state.selectedLinearElement; + const linearElementEditor = this.state.selectedLinearElement; const ret = LinearElementEditor.handlePointerDown( event, this, @@ -7209,10 +7227,6 @@ class App extends React.Component { } if (ret.linearElementEditor) { this.setState({ selectedLinearElement: ret.linearElementEditor }); - - if (this.state.editingLinearElement) { - this.setState({ editingLinearElement: ret.linearElementEditor }); - } } if (ret.didAddPoint) { return true; @@ -7313,11 +7327,11 @@ class App extends React.Component { this.clearSelection(hitElement); } - if (this.state.editingLinearElement) { + if (this.state.selectedLinearElement?.isEditing) { this.setState({ selectedElementIds: makeNextSelectedElementIds( { - [this.state.editingLinearElement.elementId]: true, + [this.state.selectedLinearElement.elementId]: true, }, this.state, ), @@ -8176,16 +8190,12 @@ class App extends React.Component { this.scene, ); - flushSync(() => { - if (this.state.selectedLinearElement) { - this.setState({ - selectedLinearElement: { - ...this.state.selectedLinearElement, - segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, - pointerDownState: ret.pointerDownState, - }, - }); - } + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, + pointerDownState: ret.pointerDownState, + }, }); return; } @@ -8244,7 +8254,9 @@ class App extends React.Component { pointDistance( pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) < DRAGGING_THRESHOLD + ) * + this.state.zoom.value < + MINIMUM_ARROW_SIZE ) { return; } @@ -8262,8 +8274,7 @@ class App extends React.Component { const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { - const linearElementEditor = - this.state.editingLinearElement || this.state.selectedLinearElement; + const linearElementEditor = this.state.selectedLinearElement; if ( LinearElementEditor.shouldAddMidpoint( @@ -8299,16 +8310,6 @@ class App extends React.Component { }, }); } - if (this.state.editingLinearElement) { - this.setState({ - editingLinearElement: { - ...this.state.editingLinearElement, - pointerDownState: ret.pointerDownState, - selectedPointsIndices: ret.selectedPointsIndices, - segmentMidPointHoveredCoords: null, - }, - }); - } }); return; @@ -8342,9 +8343,9 @@ class App extends React.Component { ); const isSelectingPointsInLineEditor = - this.state.editingLinearElement && + this.state.selectedLinearElement?.isEditing && event.shiftKey && - this.state.editingLinearElement.elementId === + this.state.selectedLinearElement.elementId === pointerDownState.hit.element?.id; if ( (hasHitASelectedElement || @@ -8697,23 +8698,21 @@ class App extends React.Component { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; if (event.altKey) { - flushSync(() => { - this.setActiveTool( - { type: "lasso", fromSelection: true }, - event.shiftKey, - ); - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); - this.setAppState({ - selectionElement: null, - }); + 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); + return; } + this.maybeDragNewGenericElement(pointerDownState, event); } else if (this.state.activeTool.type === "lasso") { if (!event.altKey && this.state.activeTool.fromSelection) { this.setActiveTool({ type: "selection" }); @@ -8920,7 +8919,7 @@ class App extends React.Component { const elements = this.scene.getNonDeletedElements(); // box-select line editor points - if (this.state.editingLinearElement) { + if (this.state.selectedLinearElement?.isEditing) { LinearElementEditor.handleBoxSelection( event, this.state, @@ -9163,23 +9162,23 @@ class App extends React.Component { // Handle end of dragging a point of a linear element, might close a loop // and sets binding element - if (this.state.editingLinearElement) { + if (this.state.selectedLinearElement?.isEditing) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== - this.state.editingLinearElement.elementId + this.state.selectedLinearElement.elementId ) { this.actionManager.executeAction(actionFinalize); } else { const editingLinearElement = LinearElementEditor.handlePointerUp( childEvent, - this.state.editingLinearElement, + this.state.selectedLinearElement, this.state, this.scene, ); - if (editingLinearElement !== this.state.editingLinearElement) { + if (editingLinearElement !== this.state.selectedLinearElement) { this.setState({ - editingLinearElement, + selectedLinearElement: editingLinearElement, suggestedBindings: [], }); } @@ -9282,25 +9281,54 @@ class App extends React.Component { this.state, ); - if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { - this.scene.mutateElement( - newElement, - { - points: [ - ...newElement.points, - pointFrom( - pointerCoords.x - newElement.x, - pointerCoords.y - newElement.y, - ), - ], - }, - { informMutation: false, isDragging: false }, - ); + const dragDistance = + pointDistance( + pointFrom(pointerCoords.x, pointerCoords.y), + pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), + ) * this.state.zoom.value; - this.setState({ - multiElement: newElement, - newElement, - }); + if ( + (!pointerDownState.drag.hasOccurred || + dragDistance < MINIMUM_ARROW_SIZE) && + newElement && + !multiElement + ) { + if (this.device.isTouchScreen) { + const FIXED_DELTA_X = Math.min( + (this.state.width * 0.7) / this.state.zoom.value, + 100, + ); + + this.scene.mutateElement( + newElement, + { + x: newElement.x - FIXED_DELTA_X / 2, + points: [ + pointFrom(0, 0), + pointFrom(FIXED_DELTA_X, 0), + ], + }, + { informMutation: false, isDragging: false }, + ); + + this.actionManager.executeAction(actionFinalize); + } else { + const dx = pointerCoords.x - newElement.x; + const dy = pointerCoords.y - newElement.y; + + this.scene.mutateElement( + newElement, + { + points: [...newElement.points, pointFrom(dx, dy)], + }, + { informMutation: false, isDragging: false }, + ); + + this.setState({ + multiElement: newElement, + newElement, + }); + } } else if (pointerDownState.drag.hasOccurred && !multiElement) { if ( isBindingEnabled(this.state) && @@ -9663,14 +9691,17 @@ class App extends React.Component { !pointerDownState.hit.wasAddedToSelection && // if we're editing a line, pointerup shouldn't switch selection if // box selected - (!this.state.editingLinearElement || + (!this.state.selectedLinearElement?.isEditing || !pointerDownState.boxSelection.hasOccurred) && // hitElement can be set when alt + ctrl to toggle lasso and we will // just respect the selected elements from lasso instead this.state.activeTool.type !== "lasso" ) { // when inside line editor, shift selects points instead - if (childEvent.shiftKey && !this.state.editingLinearElement) { + if ( + childEvent.shiftKey && + !this.state.selectedLinearElement?.isEditing + ) { if (this.state.selectedElementIds[hitElement.id]) { if (isSelectedViaGroup(this.state, hitElement)) { this.setState((_prevState) => { @@ -9848,8 +9879,9 @@ class App extends React.Component { (!hitElement && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) ) { - if (this.state.editingLinearElement) { - this.setState({ editingLinearElement: null }); + if (this.state.selectedLinearElement?.isEditing) { + // Exit editing mode but keep the element selected + this.actionManager.executeAction(actionToggleLinearEditor); } else { // Deselect selected elements this.setState({ diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index ebb7e4fa5..90db95db6 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -108,6 +108,7 @@ $verticalBreakpoint: 861px; display: flex; align-items: center; gap: 0.25rem; + overflow: hidden; } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 740fa0162..3c6f110d2 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { useStable } from "../../hooks/useStable"; +import { Ellipsify } from "../Ellipsify"; + import * as defaultItems from "./defaultCommandPaletteItems"; import "./CommandPalette.scss"; @@ -964,7 +966,7 @@ const CommandItem = ({ } /> )} - {command.label} + {command.label} {showShortcut && command.shortcut && ( diff --git a/packages/excalidraw/components/Ellipsify.tsx b/packages/excalidraw/components/Ellipsify.tsx new file mode 100644 index 000000000..dd21af6f1 --- /dev/null +++ b/packages/excalidraw/components/Ellipsify.tsx @@ -0,0 +1,18 @@ +export const Ellipsify = ({ + children, + ...rest +}: { children: React.ReactNode } & React.HTMLAttributes) => { + return ( + + {children} + + ); +}; diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 26a5b0984..bc3097132 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -114,7 +114,7 @@ const getHints = ({ appState.selectionElement && !selectedElements.length && !appState.editingTextElement && - !appState.editingLinearElement + !appState.selectedLinearElement?.isEditing ) { return [t("hints.deepBoxSelect")]; } @@ -129,8 +129,8 @@ const getHints = ({ if (selectedElements.length === 1) { if (isLinearElement(selectedElements[0])) { - if (appState.editingLinearElement) { - return appState.editingLinearElement.selectedPointsIndices + if (appState.selectedLinearElement?.isEditing) { + return appState.selectedLinearElement.selectedPointsIndices ? t("hints.lineEditor_pointSelected") : t("hints.lineEditor_nothingSelected"); } diff --git a/packages/excalidraw/components/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index 75cc29d08..c80045e5e 100644 --- a/packages/excalidraw/components/InlineIcon.tsx +++ b/packages/excalidraw/components/InlineIcon.tsx @@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { display: "inline-block", lineHeight: 0, verticalAlign: "middle", + flex: "0 0 auto", }} > {icon} diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 4b1cd7060..c375a2b16 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -192,7 +192,6 @@ const getRelevantAppStateProps = ( viewModeEnabled: appState.viewModeEnabled, openDialog: appState.openDialog, editingGroupId: appState.editingGroupId, - editingLinearElement: appState.editingLinearElement, selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight, offsetLeft: appState.offsetLeft, diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 01ce94c43..9e23fa500 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => { const wrapperRef = useRef(null); const isComponentMounted = useRef(false); + useEffect(() => { + props.canvas.style.width = `${props.appState.width}px`; + props.canvas.style.height = `${props.appState.height}px`; + props.canvas.width = props.appState.width * props.scale; + props.canvas.height = props.appState.height * props.scale; + }, [props.appState.height, props.appState.width, props.canvas, props.scale]); + useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) { @@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => { canvas.classList.add("excalidraw__canvas", "static"); } - const widthString = `${props.appState.width}px`; - const heightString = `${props.appState.height}px`; - if (canvas.style.width !== widthString) { - canvas.style.width = widthString; - } - if (canvas.style.height !== heightString) { - canvas.style.height = heightString; - } - - const scaledWidth = props.appState.width * props.scale; - const scaledHeight = props.appState.height * props.scale; - // setting width/height resets the canvas even if dimensions not changed, - // which would cause flicker when we skip frame (due to throttling) - if (canvas.width !== scaledWidth) { - canvas.width = scaledWidth; - } - if (canvas.height !== scaledHeight) { - canvas.height = scaledHeight; - } - renderStaticScene( { canvas, diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e48f6d71e..95d258c46 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -19,6 +19,8 @@ border-radius: var(--border-radius-lg); position: relative; transition: box-shadow 0.5s ease-in-out; + display: flex; + flex-direction: column; &.zen-mode { box-shadow: none; @@ -100,6 +102,7 @@ align-items: center; cursor: pointer; border-radius: var(--border-radius-md); + flex: 1 0 auto; @media screen and (min-width: 1921px) { height: 2.25rem; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx index b2f9e7e0a..aea13230b 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx @@ -1,5 +1,7 @@ import { useDevice } from "../App"; +import { Ellipsify } from "../Ellipsify"; + import type { JSX } from "react"; const MenuItemContent = ({ @@ -18,7 +20,7 @@ const MenuItemContent = ({ <> {icon &&
{icon}
}
- {children} + {children}
{shortcut && !device.editor.isMobile && (
{shortcut}
diff --git a/packages/excalidraw/components/footer/Footer.tsx b/packages/excalidraw/components/footer/Footer.tsx index 427628e7c..3b213d796 100644 --- a/packages/excalidraw/components/footer/Footer.tsx +++ b/packages/excalidraw/components/footer/Footer.tsx @@ -2,13 +2,7 @@ import clsx from "clsx"; import { actionShortcuts } from "../../actions"; import { useTunnels } from "../../context/tunnels"; -import { - ExitZenModeAction, - FinalizeAction, - UndoRedoActions, - ZoomActions, -} from "../Actions"; -import { useDevice } from "../App"; +import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions"; import { HelpButton } from "../HelpButton"; import { Section } from "../Section"; import Stack from "../Stack"; @@ -29,10 +23,6 @@ const Footer = ({ }) => { const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels(); - const device = useDevice(); - const showFinalize = - !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; - return (