Browse Source

Merge branch 'master' into ryan-di/freedraw-width

# Conflicts:
#	packages/excalidraw/package.json
dwelle 1 month ago
parent
commit
02cef5ea92
35 changed files with 1108 additions and 265 deletions
  1. 1 1
      .github/workflows/autorelease-excalidraw.yml
  2. 0 55
      .github/workflows/autorelease-preview.yml
  3. 2 22
      dev-docs/docs/@excalidraw/excalidraw/development.mdx
  4. 1 0
      dev-docs/src/theme/ReactLiveScope/index.js
  5. 2 1
      examples/with-nextjs/package.json
  6. 1 1
      examples/with-script-in-browser/package.json
  7. 1 1
      examples/with-script-in-browser/vercel.json
  8. 10 5
      package.json
  9. 5 2
      packages/common/package.json
  10. 9 2
      packages/element/package.json
  11. 7 4
      packages/element/src/align.ts
  12. 9 2
      packages/element/src/distribute.ts
  13. 32 2
      packages/element/src/embeddable.ts
  14. 77 0
      packages/element/src/groups.ts
  15. 420 0
      packages/element/tests/align.test.tsx
  16. 153 0
      packages/element/tests/embeddable.test.ts
  17. 13 2
      packages/excalidraw/actions/actionAlign.tsx
  18. 8 1
      packages/excalidraw/actions/actionDistribute.tsx
  19. 7 1
      packages/excalidraw/components/App.tsx
  20. 1 0
      packages/excalidraw/components/CommandPalette/CommandPalette.scss
  21. 3 1
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  22. 18 0
      packages/excalidraw/components/Ellipsify.tsx
  23. 1 0
      packages/excalidraw/components/InlineIcon.tsx
  24. 3 0
      packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
  25. 3 1
      packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx
  26. 1 0
      packages/excalidraw/index.tsx
  27. 10 7
      packages/excalidraw/package.json
  28. 65 13
      packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
  29. 8 2
      packages/math/package.json
  30. 0 71
      scripts/autorelease.js
  31. 1 4
      scripts/buildBase.js
  32. 1 4
      scripts/buildPackage.js
  33. 0 38
      scripts/prerelease.js
  34. 231 20
      scripts/release.js
  35. 4 2
      scripts/updateChangelog.js

+ 1 - 1
.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

+ 0 - 55
.github/workflows/autorelease-preview.yml

@@ -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 }}"

+ 2 - 22
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.

+ 1 - 0
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;

+ 2 - 1
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",

+ 1 - 1
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"
   }
 }

+ 1 - 1
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"
 }

+ 10 - 5
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"

+ 5 - 2
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": [

+ 9 - 2
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"
   }
 }

+ 7 - 4
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<AppState>,
 ): ExcalidrawElement[] => {
-  const elementsMap = scene.getNonDeletedElementsMap();
-  const groups: ExcalidrawElement[][] = getMaximumGroups(
+  const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
     selectedElements,
-    elementsMap,
+    scene.getNonDeletedElementsMap(),
+    appState,
   );
   const selectionBoundingBox = getCommonBoundingBox(selectedElements);
 

+ 9 - 2
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<AppState>,
 ): 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]);
 

+ 32 - 2
packages/element/src/embeddable.ts

@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
 const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
 
 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 =
   /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
 
+const parseYouTubeTimestamp = (url: string): number => {
+  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]) {

+ 77 - 0
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<AppState>,
+): ExcalidrawElement[][] => {
+  const selectedGroupIds = getSelectedGroupIds(appState);
+  const unboundElements = selectedElements.filter(
+    (element) => !isBoundToContainer(element),
+  );
+  const groups: Map<string, ExcalidrawElement[]> = new Map();
+  const elements: Map<string, ExcalidrawElement[]> = 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()));
+};

+ 420 - 0
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);
+  });
 });

+ 153 - 0
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");
+    }
+  });
+});

+ 13 - 2
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<AppState>,
+    ).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);
 

+ 8 - 1
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<AppState>,
+    ).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);

+ 7 - 1
packages/excalidraw/components/App.tsx

@@ -595,6 +595,10 @@ class App extends React.Component<AppProps, AppState> {
    * insert to DOM before user initially scrolls to them) */
   private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
 
+  private handleToastClose = () => {
+    this.setToast(null);
+  };
+
   private elementsPendingErasure: ElementsPendingErasure = new Set();
 
   public flowChartCreator: FlowChartCreator = new FlowChartCreator();
@@ -1709,14 +1713,16 @@ class App extends React.Component<AppProps, AppState> {
                               />
                             </ElementCanvasButtons>
                           )}
+
                         {this.state.toast !== null && (
                           <Toast
                             message={this.state.toast.message}
-                            onClose={() => this.setToast(null)}
+                            onClose={this.handleToastClose}
                             duration={this.state.toast.duration}
                             closable={this.state.toast.closable}
                           />
                         )}
+
                         {this.state.contextMenu && (
                           <ContextMenu
                             items={this.state.contextMenu.items}

+ 1 - 0
packages/excalidraw/components/CommandPalette/CommandPalette.scss

@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
           display: flex;
           align-items: center;
           gap: 0.25rem;
+          overflow: hidden;
         }
       }
 

+ 3 - 1
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}
+        <Ellipsify>{command.label}</Ellipsify>
       </div>
       {showShortcut && command.shortcut && (
         <CommandShortcutHint shortcut={command.shortcut} />

+ 18 - 0
packages/excalidraw/components/Ellipsify.tsx

@@ -0,0 +1,18 @@
+export const Ellipsify = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
+  return (
+    <span
+      {...rest}
+      style={{
+        textOverflow: "ellipsis",
+        overflow: "hidden",
+        whiteSpace: "nowrap",
+        ...rest.style,
+      }}
+    >
+      {children}
+    </span>
+  );
+};

+ 1 - 0
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}

+ 3 - 0
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;

+ 3 - 1
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 && <div className="dropdown-menu-item__icon">{icon}</div>}
       <div style={textStyle} className="dropdown-menu-item__text">
-        {children}
+        <Ellipsify>{children}</Ellipsify>
       </div>
       {shortcut && !device.editor.isMobile && (
         <div className="dropdown-menu-item__shortcut">{shortcut}</div>

+ 1 - 0
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 };

+ 10 - 7
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"
   }
 }

+ 65 - 13
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap

@@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       <div
         class="dropdown-menu-item__text"
       >
-        Click me
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Click me
+        </span>
       </div>
     </button>
     <a
@@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       <div
         class="dropdown-menu-item__text"
       >
-        Excalidraw blog
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Excalidraw blog
+        </span>
       </div>
     </a>
     <div
@@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       <div
         class="dropdown-menu-item__text"
       >
-        Help
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Help
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Open
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Open
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Save to...
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Save to...
+        </span>
       </div>
     </button>
     <button
@@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Export image...
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Export image...
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Find on canvas
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Find on canvas
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Help
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Help
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Reset the canvas
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Reset the canvas
+        </span>
       </div>
     </button>
     <div
@@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         <div
           class="dropdown-menu-item__text"
         >
-          GitHub
+          <span
+            style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+          >
+            GitHub
+          </span>
         </div>
       </a>
       <a
@@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         <div
           class="dropdown-menu-item__text"
         >
-          Follow us
+          <span
+            style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+          >
+            Follow us
+          </span>
         </div>
       </a>
       <a
@@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         <div
           class="dropdown-menu-item__text"
         >
-          Discord chat
+          <span
+            style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+          >
+            Discord chat
+          </span>
         </div>
       </a>
     </div>
@@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Dark mode
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Dark mode
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"

+ 8 - 2
packages/math/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@excalidraw/math",
-  "version": "0.1.0",
+  "version": "0.18.0",
   "type": "module",
   "types": "./dist/types/math/src/index.d.ts",
   "main": "./dist/prod/index.js",
@@ -13,7 +13,10 @@
       "default": "./dist/prod/index.js"
     },
     "./*": {
-      "types": "./dist/types/math/src/*.d.ts"
+      "types": "./dist/types/math/src/*.d.ts",
+      "development": "./dist/dev/index.js",
+      "production": "./dist/prod/index.js",
+      "default": "./dist/prod/index.js"
     }
   },
   "files": [
@@ -56,5 +59,8 @@
   "scripts": {
     "gen:types": "rimraf types && tsc",
     "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
+  },
+  "dependencies": {
+    "@excalidraw/common": "0.18.0"
   }
 }

+ 0 - 71
scripts/autorelease.js

@@ -1,71 +0,0 @@
-const { exec, execSync } = require("child_process");
-const fs = require("fs");
-
-const core = require("@actions/core");
-
-const excalidrawDir = `${__dirname}/../packages/excalidraw`;
-const excalidrawPackage = `${excalidrawDir}/package.json`;
-const pkg = require(excalidrawPackage);
-const isPreview = process.argv.slice(2)[0] === "preview";
-
-const getShortCommitHash = () => {
-  return execSync("git rev-parse --short HEAD").toString().trim();
-};
-
-const publish = () => {
-  const tag = isPreview ? "preview" : "next";
-
-  try {
-    execSync(`yarn  --frozen-lockfile`);
-    execSync(`yarn run build:esm`, { cwd: excalidrawDir });
-    execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
-    console.info(`Published ${pkg.name}@${tag}🎉`);
-    core.setOutput(
-      "result",
-      `**Preview version has been shipped** :rocket:
-    You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
-    );
-  } catch (error) {
-    core.setOutput("result", "package couldn't be published :warning:!");
-    console.error(error);
-    process.exit(1);
-  }
-};
-// get files changed between prev and head commit
-exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
-  if (error || stderr) {
-    console.error(error);
-    core.setOutput("result", ":warning: Package couldn't be published!");
-    process.exit(1);
-  }
-  const changedFiles = stdout.trim().split("\n");
-
-  const excalidrawPackageFiles = changedFiles.filter((file) => {
-    return (
-      file.indexOf("packages/excalidraw") >= 0 ||
-      file.indexOf("buildPackage.js") > 0
-    );
-  });
-  if (!excalidrawPackageFiles.length) {
-    console.info("Skipping release as no valid diff found");
-    core.setOutput("result", "Skipping release as no valid diff found");
-    process.exit(0);
-  }
-
-  // update package.json
-  let version = `${pkg.version}-${getShortCommitHash()}`;
-
-  // update readme
-
-  if (isPreview) {
-    // use pullNumber-commithash as the version for preview
-    const pullRequestNumber = process.argv.slice(3)[0];
-    version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
-  }
-  pkg.version = version;
-
-  fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
-
-  console.info("Publish in progress...");
-  publish();
-});

+ 1 - 4
scripts/buildBase.js

@@ -11,12 +11,9 @@ const getConfig = (outdir) => ({
   entryNames: "[name]",
   assetNames: "[dir]/[name]",
   alias: {
-    "@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
-    "@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
-    "@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
-    "@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
     "@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
   },
+  external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
 });
 
 function buildDev(config) {

+ 1 - 4
scripts/buildPackage.js

@@ -28,12 +28,9 @@ const getConfig = (outdir) => ({
   assetNames: "[dir]/[name]",
   chunkNames: "[dir]/[name]-[hash]",
   alias: {
-    "@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
-    "@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
-    "@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
-    "@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
     "@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
   },
+  external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
   loader: {
     ".woff2": "file",
   },

+ 0 - 38
scripts/prerelease.js

@@ -1,38 +0,0 @@
-const fs = require("fs");
-const util = require("util");
-
-const exec = util.promisify(require("child_process").exec);
-const updateChangelog = require("./updateChangelog");
-
-const excalidrawDir = `${__dirname}/../packages/excalidraw/`;
-const excalidrawPackage = `${excalidrawDir}/package.json`;
-
-const updatePackageVersion = (nextVersion) => {
-  const pkg = require(excalidrawPackage);
-  pkg.version = nextVersion;
-  const content = `${JSON.stringify(pkg, null, 2)}\n`;
-  fs.writeFileSync(excalidrawPackage, content, "utf-8");
-};
-
-const prerelease = async (nextVersion) => {
-  try {
-    await updateChangelog(nextVersion);
-    updatePackageVersion(nextVersion);
-    await exec(`git add -u`);
-    await exec(
-      `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion}  🎉"`,
-    );
-
-    console.info("Done!");
-  } catch (error) {
-    console.error(error);
-    process.exit(1);
-  }
-};
-
-const nextVersion = process.argv.slice(2)[0];
-if (!nextVersion) {
-  console.error("Pass the next version to release!");
-  process.exit(1);
-}
-prerelease(nextVersion);

+ 231 - 20
scripts/release.js

@@ -1,28 +1,239 @@
+const fs = require("fs");
+const path = require("path");
+
 const { execSync } = require("child_process");
 
-const excalidrawDir = `${__dirname}/../packages/excalidraw`;
-const excalidrawPackage = `${excalidrawDir}/package.json`;
-const pkg = require(excalidrawPackage);
-
-const publish = () => {
-  try {
-    console.info("Installing the dependencies in root folder...");
-    execSync(`yarn  --frozen-lockfile`);
-    console.info("Installing the dependencies in excalidraw directory...");
-    execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
-    console.info("Building ESM Package...");
-    execSync(`yarn run build:esm`, { cwd: excalidrawDir });
-    console.info("Publishing the package...");
-    execSync(`yarn --cwd ${excalidrawDir} publish`);
-  } catch (error) {
-    console.error(error);
+const updateChangelog = require("./updateChangelog");
+
+// skipping utils for now, as it has independent release process
+const PACKAGES = ["common", "math", "element", "excalidraw"];
+const PACKAGES_DIR = path.resolve(__dirname, "../packages");
+
+/**
+ * Returns the arguments for the release script.
+ *
+ * Usage examples:
+ * - yarn release --help                          -> prints this help message
+ * - yarn release                                 -> publishes `@excalidraw` packages with "test" tag and "-[hash]" version suffix
+ * - yarn release --tag=test                      -> same as above
+ * - yarn release --tag=next                      -> publishes `@excalidraw` packages with "next" tag and version "-[hash]" suffix
+ * - yarn release --tag=next --non-interactive    -> skips interactive prompts (runs on CI/CD), otherwise same as above
+ * - yarn release --tag=latest --version=0.19.0   -> publishes `@excalidraw` packages with "latest" tag and version "0.19.0" & prepares changelog for the release
+ *
+ * @returns [tag, version, nonInteractive]
+ */
+const getArguments = () => {
+  let tag = "test";
+  let version = "";
+  let nonInteractive = false;
+
+  for (const argument of process.argv.slice(2)) {
+    if (/--help/.test(argument)) {
+      console.info(`Available arguments:
+  --tag=<tag>                                    -> (optional) "test" (default), "next" for auto release, "latest" for stable release
+  --version=<version>                            -> (optional) for "next" and "test", (required) for "latest" i.e. "0.19.0"
+  --non-interactive                              -> (optional) disables interactive prompts`);
+
+      console.info(`\nUsage examples:
+  - yarn release                                 -> publishes \`@excalidraw\` packages with "test" tag and "-[hash]" version suffix
+  - yarn release --tag=test                      -> same as above
+  - yarn release --tag=next                      -> publishes \`@excalidraw\` packages with "next" tag and version "-[hash]" suffix
+  - yarn release --tag=next --non-interactive    -> skips interactive prompts (runs on CI/CD), otherwise same as above
+  - yarn release --tag=latest --version=0.19.0   -> publishes \`@excalidraw\` packages with "latest" tag and version "0.19.0" & prepares changelog for the release`);
+
+      process.exit(0);
+    }
+
+    if (/--tag=/.test(argument)) {
+      tag = argument.split("=")[1];
+    }
+
+    if (/--version=/.test(argument)) {
+      version = argument.split("=")[1];
+    }
+
+    if (/--non-interactive/.test(argument)) {
+      nonInteractive = true;
+    }
+  }
+
+  if (tag !== "latest" && tag !== "next" && tag !== "test") {
+    console.error(`Unsupported tag "${tag}", use "latest", "next" or "test".`);
+    process.exit(1);
+  }
+
+  if (tag === "latest" && !version) {
+    console.error("Pass the version to make the latest stable release!");
+    process.exit(1);
+  }
+
+  if (!version) {
+    // set the next version based on the excalidraw package version + commit hash
+    const excalidrawPackageVersion = require(getPackageJsonPath(
+      "excalidraw",
+    )).version;
+
+    const hash = getShortCommitHash();
+
+    if (!excalidrawPackageVersion.includes(hash)) {
+      version = `${excalidrawPackageVersion}-${hash}`;
+    } else {
+      // ensuring idempotency
+      version = excalidrawPackageVersion;
+    }
+  }
+
+  console.info(`Running with tag "${tag}" and version "${version}"...`);
+
+  return [tag, version, nonInteractive];
+};
+
+const validatePackageName = (packageName) => {
+  if (!PACKAGES.includes(packageName)) {
+    console.error(`Package "${packageName}" not found!`);
     process.exit(1);
   }
 };
 
-const release = () => {
-  publish();
-  console.info(`Published ${pkg.version}!`);
+const getPackageJsonPath = (packageName) => {
+  validatePackageName(packageName);
+  return path.resolve(PACKAGES_DIR, packageName, "package.json");
 };
 
-release();
+const updatePackageJsons = (nextVersion) => {
+  const packageJsons = new Map();
+
+  for (const packageName of PACKAGES) {
+    const pkg = require(getPackageJsonPath(packageName));
+
+    pkg.version = nextVersion;
+
+    if (pkg.dependencies) {
+      for (const dependencyName of PACKAGES) {
+        if (!pkg.dependencies[`@excalidraw/${dependencyName}`]) {
+          continue;
+        }
+
+        pkg.dependencies[`@excalidraw/${dependencyName}`] = nextVersion;
+      }
+    }
+
+    packageJsons.set(packageName, `${JSON.stringify(pkg, null, 2)}\n`);
+  }
+
+  // modify once, to avoid inconsistent state
+  for (const packageName of PACKAGES) {
+    const content = packageJsons.get(packageName);
+    fs.writeFileSync(getPackageJsonPath(packageName), content, "utf-8");
+  }
+};
+
+const getShortCommitHash = () => {
+  return execSync("git rev-parse --short HEAD").toString().trim();
+};
+
+const askToCommit = (tag, nextVersion) => {
+  if (tag !== "latest") {
+    return Promise.resolve();
+  }
+
+  return new Promise((resolve) => {
+    const rl = require("readline").createInterface({
+      input: process.stdin,
+      output: process.stdout,
+    });
+
+    rl.question(
+      "Do you want to commit these changes to git? (Y/n): ",
+      (answer) => {
+        rl.close();
+
+        if (answer.toLowerCase() === "y") {
+          execSync(`git add -u`);
+          execSync(
+            `git commit -m "chore: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
+          );
+        } else {
+          console.warn(
+            "Skipping commit. Don't forget to commit manually later!",
+          );
+        }
+
+        resolve();
+      },
+    );
+  });
+};
+
+const buildPackages = () => {
+  console.info("Running yarn install...");
+  execSync(`yarn --frozen-lockfile`, { stdio: "inherit" });
+
+  console.info("Removing existing build artifacts...");
+  execSync(`yarn rm:build`, { stdio: "inherit" });
+
+  for (const packageName of PACKAGES) {
+    console.info(`Building "@excalidraw/${packageName}"...`);
+    execSync(`yarn run build:esm`, {
+      cwd: path.resolve(PACKAGES_DIR, packageName),
+      stdio: "inherit",
+    });
+  }
+};
+
+const askToPublish = (tag, version) => {
+  return new Promise((resolve) => {
+    const rl = require("readline").createInterface({
+      input: process.stdin,
+      output: process.stdout,
+    });
+
+    rl.question(
+      "Do you want to publish these changes to npm? (Y/n): ",
+      (answer) => {
+        rl.close();
+
+        if (answer.toLowerCase() === "y") {
+          publishPackages(tag, version);
+        } else {
+          console.info("Skipping publish.");
+        }
+
+        resolve();
+      },
+    );
+  });
+};
+
+const publishPackages = (tag, version) => {
+  for (const packageName of PACKAGES) {
+    execSync(`yarn publish --tag ${tag}`, {
+      cwd: path.resolve(PACKAGES_DIR, packageName),
+      stdio: "inherit",
+    });
+
+    console.info(
+      `Published "@excalidraw/${packageName}@${tag}" with version "${version}"! 🎉`,
+    );
+  }
+};
+
+/** main */
+(async () => {
+  const [tag, version, nonInteractive] = getArguments();
+
+  buildPackages();
+
+  if (tag === "latest") {
+    await updateChangelog(version);
+  }
+
+  updatePackageJsons(version);
+
+  if (nonInteractive) {
+    publishPackages(tag, version);
+  } else {
+    await askToCommit(tag, version);
+    await askToPublish(tag, version);
+  }
+})();

+ 4 - 2
scripts/updateChangelog.js

@@ -20,14 +20,16 @@ const headerForType = {
   perf: "Performance",
   build: "Build",
 };
+
 const badCommits = [];
 const getCommitHashForLastVersion = async () => {
   try {
-    const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
+    const commitMessage = `"release @excalidraw/excalidraw"`;
     const { stdout } = await exec(
       `git log --format=format:"%H" --grep=${commitMessage}`,
     );
-    return stdout;
+    // take commit hash from latest release
+    return stdout.split(/\r?\n/)[0];
   } catch (error) {
     console.error(error);
   }