From 258605d1d59c4f6f45ba4d0783d621dc6e73dbe3 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Mon, 30 Jun 2025 12:19:15 +0200 Subject: [PATCH 1/6] chore: release multiple packages (#9698) --- .github/workflows/autorelease-excalidraw.yml | 2 +- .github/workflows/autorelease-preview.yml | 55 ---- .../@excalidraw/excalidraw/development.mdx | 24 +- examples/with-nextjs/package.json | 3 +- examples/with-script-in-browser/package.json | 2 +- examples/with-script-in-browser/vercel.json | 2 +- package.json | 15 +- packages/common/package.json | 7 +- packages/element/package.json | 11 +- packages/excalidraw/package.json | 17 +- packages/math/package.json | 10 +- scripts/autorelease.js | 71 ----- scripts/buildBase.js | 5 +- scripts/buildPackage.js | 5 +- scripts/prerelease.js | 38 --- scripts/release.js | 249 ++++++++++++++++-- scripts/updateChangelog.js | 6 +- 17 files changed, 285 insertions(+), 237 deletions(-) delete mode 100644 .github/workflows/autorelease-preview.yml delete mode 100644 scripts/autorelease.js delete mode 100644 scripts/prerelease.js 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/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/excalidraw/package.json b/packages/excalidraw/package.json index 7ebaed742b..019a828c61 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.1", "@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/math/package.json b/packages/math/package.json index 5fac47bef5..d64a74d677 100644 --- a/packages/math/package.json +++ b/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" } } diff --git a/scripts/autorelease.js b/scripts/autorelease.js deleted file mode 100644 index 6ca5af2135..0000000000 --- a/scripts/autorelease.js +++ /dev/null @@ -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(); -}); diff --git a/scripts/buildBase.js b/scripts/buildBase.js index 336b498235..25c0874f28 100644 --- a/scripts/buildBase.js +++ b/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) { diff --git a/scripts/buildPackage.js b/scripts/buildPackage.js index baf20615f6..8a70f9f9be 100644 --- a/scripts/buildPackage.js +++ b/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", }, diff --git a/scripts/prerelease.js b/scripts/prerelease.js deleted file mode 100644 index 005a6cf5f7..0000000000 --- a/scripts/prerelease.js +++ /dev/null @@ -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); diff --git a/scripts/release.js b/scripts/release.js index 21f9f25397..3c75523411 100644 --- a/scripts/release.js +++ b/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 updateChangelog = require("./updateChangelog"); -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); +// 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= -> (optional) "test" (default), "next" for auto release, "latest" for stable release + --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); + } +})(); diff --git a/scripts/updateChangelog.js b/scripts/updateChangelog.js index b9291b7116..244427ff71 100644 --- a/scripts/updateChangelog.js +++ b/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); } From 4eadb891f87ffb8d2b2761b7fff843389cec8100 Mon Sep 17 00:00:00 2001 From: Soham Kulkarni <150679961+sohamsk13@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:37:26 +0530 Subject: [PATCH 2/6] fix(toast): prevent toast from re-rendering and resetting timeout Fixes #9714 (#9715) * Update App.tsx * fix: lint --------- Co-authored-by: Ryan Di --- packages/excalidraw/components/App.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 54305e4e97..87d1be2779 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -593,6 +593,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(); @@ -1707,14 +1711,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 && ( Date: Tue, 8 Jul 2025 19:29:44 +0530 Subject: [PATCH 3/6] docs: fix broken update scene button example in docs (#9726) fix: update scene example in docs --- dev-docs/src/theme/ReactLiveScope/index.js | 1 + 1 file changed, 1 insertion(+) 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; From cde46793f8db57187616b9c869027c41dfdc9862 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:19:10 +0200 Subject: [PATCH 4/6] feat: support timestamps for youtube video emebds (#9737) --- packages/element/src/embeddable.ts | 34 ++++- packages/element/tests/embeddable.test.ts | 153 ++++++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 packages/element/tests/embeddable.test.ts 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/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"); + } + }); +}); From 0cfa53b7649c5f856bd73b58cb147b04f15e5b39 Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Tue, 15 Jul 2025 03:43:42 -0700 Subject: [PATCH 5/6] fix: aligning and distributing elements and nested groups while editing a group (#9721) --- packages/element/src/align.ts | 11 +- packages/element/src/distribute.ts | 11 +- packages/element/src/groups.ts | 77 ++++ packages/element/tests/align.test.tsx | 420 ++++++++++++++++++ packages/excalidraw/actions/actionAlign.tsx | 15 +- .../excalidraw/actions/actionDistribute.tsx | 9 +- 6 files changed, 534 insertions(+), 9 deletions(-) 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/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/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); From 678dff25eddfc43319299495556ab9f6029bc719 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:59:55 +0200 Subject: [PATCH 6/6] fix: ellipsify MainMenu and CommandPalette items (#9743) * fix: ellipsify MainMenu and CommandPalette items * fix lint --- .../CommandPalette/CommandPalette.scss | 1 + .../CommandPalette/CommandPalette.tsx | 4 +- packages/excalidraw/components/Ellipsify.tsx | 18 +++++ packages/excalidraw/components/InlineIcon.tsx | 1 + .../components/dropdownMenu/DropdownMenu.scss | 3 + .../dropdownMenu/DropdownMenuItemContent.tsx | 4 +- packages/excalidraw/index.tsx | 1 + .../__snapshots__/excalidraw.test.tsx.snap | 78 +++++++++++++++---- 8 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 packages/excalidraw/components/Ellipsify.tsx diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index ebb7e4fa5e..90db95db69 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -108,6 +108,7 @@ $verticalBreakpoint: 861px; display: flex; align-items: center; gap: 0.25rem; + overflow: hidden; } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 740fa01620..3c6f110d27 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { useStable } from "../../hooks/useStable"; +import { Ellipsify } from "../Ellipsify"; + import * as defaultItems from "./defaultCommandPaletteItems"; import "./CommandPalette.scss"; @@ -964,7 +966,7 @@ const CommandItem = ({ } /> )} - {command.label} + {command.label} {showShortcut && command.shortcut && ( diff --git a/packages/excalidraw/components/Ellipsify.tsx b/packages/excalidraw/components/Ellipsify.tsx new file mode 100644 index 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 478ecc42f0..a592e2ea91 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/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