diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index 5ff5690ebf..6e2c0d00e0 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 a40ed3c430..0000000000 --- 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 60700758ff..0c6c1f9c4f 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 ca5a902e8e..a1b0b33a14 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 ee8e55581d..f23ff6f040 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 2b43117117..653c2be40e 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 99a5811c3b..15014b37c1 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/package.json b/package.json index 02d989cd25..9397d00400 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 8fedd67428..cf566ad985 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/element/package.json b/packages/element/package.json index 16b9a49e77..88e3ffaaa8 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 546bbbfa48..3068aee8d1 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/distribute.ts b/packages/element/src/distribute.ts index da79837da6..add3522acc 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 78dc26fe2f..71c75cc23a 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 1cd1536e11..40f787a01b 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/tests/align.test.tsx b/packages/element/tests/align.test.tsx index afffb72cb4..b796793690 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/embeddable.test.ts b/packages/element/tests/embeddable.test.ts new file mode 100644 index 0000000000..7f585e866f --- /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/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index de5cd2c1e4..63a887635b 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/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index bd823ec01a..f02906741c 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/components/App.tsx b/packages/excalidraw/components/App.tsx index d57ddb936b..01d8d13991 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -595,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(); @@ -1709,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 && ( )} - {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 0000000000..dd21af6f15 --- /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/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index 75cc29d08d..c80045e5e8 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/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e48f6d71e7..95d258c46b 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 b2f9e7e0a9..aea13230b8 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/index.tsx b/packages/excalidraw/index.tsx index 93d2504cd1..a87406a47a 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -281,6 +281,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar"; export { Button } from "./components/Button"; export { Footer }; export { MainMenu }; +export { Ellipsify } from "./components/Ellipsify"; export { useDevice } from "./components/App"; export { WelcomeScreen }; export { LiveCollaborationTrigger }; diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index ec257d64bc..aa08abd137 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -66,12 +66,22 @@ "last 1 safari version" ] }, + "repository": "https://github.com/excalidraw/excalidraw", + "bugs": "https://github.com/excalidraw/excalidraw/issues", + "homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw", + "scripts": { + "gen:types": "rimraf types && tsc", + "build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types" + }, "peerDependencies": { "react": "^17.0.2 || ^18.2.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" }, "dependencies": { "@braintree/sanitize-url": "6.0.2", + "@excalidraw/common": "0.18.0", + "@excalidraw/element": "0.18.0", + "@excalidraw/math": "0.18.0", "@excalidraw/laser-pointer": "1.3.2", "@excalidraw/mermaid-to-excalidraw": "1.1.2", "@excalidraw/random-username": "1.1.0", @@ -124,12 +134,5 @@ "harfbuzzjs": "0.3.6", "jest-diff": "29.7.0", "typescript": "4.9.4" - }, - "repository": "https://github.com/excalidraw/excalidraw", - "bugs": "https://github.com/excalidraw/excalidraw/issues", - "homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw", - "scripts": { - "gen:types": "rimraf types && tsc", - "build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types" } } diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index bb87746c0d..ae4728e0c7 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -15,7 +15,11 @@ exports[` > > should render main menu with host menu it > > should render main menu with host menu it
> > should render main menu with host menu it